Upgrading

From 0.19 to 1.0

Version 1.0 splits the gem into x-core, x-uploads, x-streams, and x-resources. The x gem depends on all four, and require "x" loads them.

Version 1.0 renames or removes what 0.19 had under old names, without deprecating them first. This guide covers what code written for 0.19 needs to change. See CHANGELOG.md for everything that was added.

Ruby

Version 1.0 requires Ruby 3.4 or later.

Gems and requires

The files of 0.19 moved into the gem each belongs to, so a require of one of them raises LoadError:

  • require "x" loads everything, as it did. Replace a require of a single file, such as x/client, x/connection, x/oauth_authenticator, x/rate_limit, or x/errors/too_many_requests, with it.
  • Code that depends on one gem alone requires that gem: x/core, x/uploads, x/streams, or x/resources. Each of the last three loads x/core.

x-core depends on two gems that 0.19 did not:

  • net-http ~> 0.9, >= 0.9.1. An app that pins net-http below 0.9.1 must loosen the pin, or Bundler cannot resolve x 1.0. Where the Gemfile gives x no version, Bundler installs 0.19 instead, without saying so, so write gem "x", "~> 1.0".
  • simple_oauth ~> 1.0, which signs OAuth 1.0a requests.

x depends on exactly its own version of the other four gems, so bundle update x updates all five together.

Credentials

X::Client.new checks its credentials, where 0.19 sent requests without the ones it could not use:

  • Credentials that do not form a complete set raise ArgumentError. Pass one of these sets:
    • OAuth 1.0a: api_key, api_key_secret, access_token, and access_token_secret.
    • OAuth 2.0: client_id and access_token, plus the refresh_token that refreshes it and, for a confidential client, client_secret.
    • App-only: api_key and api_key_secret alone.
    • App-only: bearer_token.
  • An OAuth 2.0 token issued without the offline.access scope has no refresh token. Pass client_id and access_token alone.
    • Such a client acts for the user and refreshes nothing. 0.19 sent its requests without credentials.
  • A bearer_token is the app's. Endpoints that take app-only authentication, such as streams, are sent it.
  • A credential outside a complete set raises ArgumentError, rather than being ignored. Leave it out.
    • For example, a client_id or an api_key beside a bearer_token.
  • A client may hold several complete sets, such as an app's bearer token beside its API key and secret. It authenticates with the first, in the order above.
    • A bearer_token beside an api_key and api_key_secret is sent until the API rejects it. The client then fetches another with the key and secret.
  • OAuth 2.0 credentials beside a complete OAuth 1.0a set raise ArgumentError, since they would share its access token.
  • expires_at is allowed only beside the OAuth 2.0 client_id and access_token the client authenticates with. Anywhere else it raises ArgumentError.
  • A credential that is an empty String raises ArgumentError. 0.19 sent it for the API to refuse.
    • Watch for an unset environment variable read as ENV.fetch("X_BEARER_TOKEN", "").

A client, its authenticators, and X::RateLimit are read-only, and the Strings their readers return, such as base_url, api_key, and the values of headers, are frozen copies, so changing one in place raises FrozenError. Derive a changed client with with, which takes anything X::Client.new takes and checks it the same way:

# 0.19
client.authenticator.access_token = "new_token"

# 1.0
rotated = client.with(access_token: "new_token", access_token_secret: "new_secret")
  • A client keeps its credentials and settings for life, so a request never signs with a mix of old and new credentials.
  • These setters are gone. Pass the option to with instead, as client.with(read_timeout: 30):
    • On X::Client: base_url=, default_array_class=, default_object_class=, open_timeout=, read_timeout=, write_timeout=, debug_output=, proxy_url=, max_redirects=, and the setter of each credential.
    • On the authenticators: the setter of each credential, and connection=.
    • On X::RateLimit: type= and response=.
  • The credentials of a copy must form a complete set. Clearing one so that the set is incomplete raises ArgumentError.
  • A client and its copies with the same OAuth 2.0 credentials share one authenticator, so a refresh by any of them reaches all.
  • A copy given other OAuth 2.0 credentials, such as another user's access token, drops the client's refresh_token, expires_at, scopes, save_tokens, and load_tokens.
    • Before: a setter changed one credential and kept the rest.
    • After: pass the refresh token, save_tokens:, and load_tokens: beside the access token they belong to.
  • Clients built separately share an authenticator when each is given it as authenticator:, in place of credentials.

A client refreshes an OAuth 2.0 access token itself, where 0.19 never did:

  • It refreshes when the expires_at it was given passes, or when X rejects the token.
  • X issues a new refresh token with each refresh and no longer accepts the old one.
  • Store the new tokens from save_tokens:, as below, or the stored refresh token stops working.
  • Processes that share a user's tokens also pass load_tokens:, a callable that reads the store, so none refreshes with a refresh token another already spent.

A client and its authenticator no longer reveal secret credentials:

  • These are private on X::Client, where 0.19 read them: api_key_secret, access_token, access_token_secret, bearer_token, client_secret, and refresh_token.
  • These are private on the authenticators, so client.authenticator reads none of them by name:
    • api_key_secret, access_token_secret, and client_secret.
    • bearer_token of X::BearerTokenAuthenticator and X::AppOnlyAuthenticator.
    • access_token and refresh_token of X::OAuth2Authenticator, which 0.19 read.
    • access_token of X::OAuth1Authenticator, which 0.19 read off X::OAuthAuthenticator.
  • inspect reveals no secret.
  • An authenticator's headers still returns the headers it signs a request with, since a custom authenticator overrides it.
    • The Authorization header of a bearer token, an app-only token, and an OAuth 2.0 access token holds the token itself, so guard an authenticator as you guard its client.
  • Still public on a client: api_key, client_id, and the new expires_at. On an OAuth 2.0 authenticator: client_id and expires_at.
  • Read the user an OAuth 1.0a access token names with X::Authenticator#user_id, rather than parsing the token.
  • Read refreshed tokens from the frozen X::OAuth2Tokens passed to save_tokens, and carry credentials to another client with with:
# 0.19
store(client.access_token, client.refresh_token)

# 1.0
X::Client.new(**credentials, save_tokens: ->(tokens) { store(tokens.access_token, tokens.refresh_token, tokens.expires_at) })

Clients and credentials refuse to be serialized, where 0.19 wrote them, credentials and all, in the clear:

  • A client, a streaming client, an authenticator, and an X::OAuth2Authorization raise TypeError from Marshal.dump, YAML.dump, as_json, and to_json.
    • This catches a client held in a cached Hash, a job argument a queue writes as YAML, and render json:.
  • Keep credentials in a secret store, and build the client again from them.
  • X::OAuth2Tokens still marshal, tokens and all, since they are meant to be stored.
    • They write themselves as JSON with to_json, and X::OAuth2Tokens.from_json reads it back.

An OAuth 2.0 authenticator's refresh_token! is now refresh!:

  • Before: it returned the Hash of the token response.
  • After: it returns the frozen X::OAuth2Tokens of that refresh, the object save_tokens is passed.
  • Read the new tokens from what it returns. The authenticator's tokens are private, and another thread's refresh may replace them.
  • A refresh X refuses raises X::AuthorizationError, an X::ClientError, where 0.19 raised an X::Error whose message was the error_description.
    • Its error_code is the OAuth 2.0 error, such as invalid_grant. Read it rather than the message.
    • A token endpoint that fails to answer raises the X::HTTPError of its status, such as X::ServerError.
    • rescue X::Error catches both, as it did.
# 0.19
store(client.authenticator.refresh_token!["refresh_token"])

# 1.0
store(client.authenticator.refresh!.refresh_token)

Token requests go to the origin of the base URL, where 0.19 sent them to api.x.com whatever the base URL:

  • This is the app-only bearer token, an OAuth 2.0 refresh, and the code exchange of X::OAuth2Authorization.
  • The path is the base URL's minus a trailing API version segment.
    • With base_url: "https://gateway.example/x/2/", an app-only token comes from /x/oauth2/token and a refresh goes to /x/2/oauth2/token.
  • A client whose base URL names a gateway or proxy sends its tokens there, so it must forward the token endpoints to X too.

A custom authenticator overrides headers, where 0.19 overrode header:

  • It returns the headers that authenticate a request, as header did.
  • Before: it was passed the Net::HTTPRequest, whose method is a String such as "POST".
  • After: the request it is passed answers only http_method (a Symbol such as :post), uri, body, and [] for a header.
# 0.19
def header(request) = {"Authorization" => sign(request.method, request.uri)}

# 1.0
def headers(request) = {"Authorization" => sign(request.http_method.to_s.upcase, request.uri)}

Requests

An endpoint that begins with a slash is relative to the base URL, like one without:

  • Before: /1.1/... resolved against the host, dropping the /2/ path of the base URL.
  • After: pass a whole URL, or derive a client with another base_url, to reach another API version.
# 0.19
client.get("/1.1/account/settings.json")

# 1.0
client.with(base_url: "https://api.x.com/1.1/").get("account/settings.json")
client.get("https://api.x.com/1.1/account/settings.json")

Credentials are sent only to the origin of the base_url:

  • The default base_url is https://api.x.com/2/, where 0.19 defaulted to https://api.twitter.com/2/.
    • Pass whole URLs on api.x.com, or a base_url on api.twitter.com, so whole URLs keep the client's credentials.
  • An endpoint or stream is signed only when it has the scheme, host, and port of the base_url. 0.19 signed a request to any host.
  • A request to another origin is sent without any Authorization, Cookie, or Proxy-Authorization header, the client's or the request's.
  • A redirect off the origin drops them too. 0.19 signed the first redirect for whatever host it named.
  • To reach another host with its own credentials, build a client for it with its own base_url.
  • Token endpoints (app-only and OAuth 2.0) are requested at the origin of the base_url. 0.19 always used api.x.com.
    • So a client pointed at a test server or recording proxy fetches and refreshes its tokens there.

Invalid endpoints raise ArgumentError before any request:

  • An endpoint that is not a valid URL (such as one holding a space), or not an http or https URL with a host, raises ArgumentError naming it.
    • Before: URI::InvalidURIError, or a Net::HTTP ArgumentError that named nothing.
    • After: rescue ArgumentError where code rescued URI::InvalidURIError.
  • An endpoint that is not a String, such as a Symbol or a URI, raises ArgumentError. Pass uri.to_s.

Requests are retried by the client, not by Net::HTTP:

  • Before: Net::HTTP resent a GET, PUT, or DELETE after a timeout or dropped connection, reusing the OAuth 1.0a nonce and signature. A 5xx raised at once.
  • After: the client resends a GET, PUT, or DELETE after X::ServerError, X::RequestTimeout, or an X::NetworkError for a request that never reached the API.
    • It does not resend after a read timeout, since the API bills a read it answered.
    • It retries max_retries times, twice by default, signing each attempt afresh.
    • It waits as long as a Retry-After header asks, up to a minute. A response asking for longer raises at once.
  • Pass max_retries: 0 to raise at once, as code with its own retry loop may want. See the retries in README.md.

max_redirects is the number of redirects a request follows. 0.19 followed one more, so max_redirects: 0 now follows none.

Renamed classes

0.19 1.0
X::OAuthAuthenticator X::OAuth1Authenticator
X::ConnectionException X::Conflict
X::MediaUploader X::Uploads::MediaUpload
X::AccountUploader X::Uploads::Account

Remove require "x/media_uploader" and require "x/account_uploader". require "x" loads the uploaders.

X::MediaUploadValidator is gone, with no public replacement:

  • The uploaders validate their own arguments, raising ArgumentError for an invalid category.
  • Pass a file to upload. It raises X::InvalidMedia for a missing file, and infers the type it sends.
  • The media category constants of X::Uploads::MediaUpload, such as TWEET_IMAGE, remain public.
  • These are now private constants: X::Uploads::Validator, X::Uploads::Chunks, X::Uploads::Multipart, and X::Uploads::Utils.
  • These constants of X::Uploads::MediaUpload are private: MIME_TYPES, MIME_TYPE_MAP, BYTES_PER_MB, and the MIME type constants, such as GIF_MIME_TYPE, MP4_MIME_TYPE, and SUBRIP_MIME_TYPE.
  • PROCESSING_INFO_STATES is gone. Use X::UploadedMedia#processing?.

Uploads

The uploaders take the file, content, or media as their first argument, and the client as a keyword. upload_profile_image_binary and upload_profile_banner_binary are gone: pass the content to update_profile_image or update_profile_banner as a StringIO.

# 0.19
media = X::MediaUploader.upload(client:, file_path: "cat.jpg", media_category: "tweet_image")
video = X::MediaUploader.chunked_upload(client:, file_path: "cat.mp4", media_category: "tweet_video")
X::MediaUploader.await_processing!(client:, media: video)
X::AccountUploader.update_profile_image(client:, file_path: "avatar.png")
X::AccountUploader.upload_profile_image_binary(client:, content:)
X::AccountUploader.upload_profile_banner_binary(client:, content:)

# 1.0
media = X::Uploads::MediaUpload.upload("cat.jpg", client:)
video = X::Uploads::MediaUpload.upload("cat.mp4", client:) # uploads in chunks and awaits processing
X::Uploads::Account.update_profile_image("avatar.png", client:)
X::Uploads::Account.update_profile_image(StringIO.new(content), client:)
X::Uploads::Account.update_profile_banner(StringIO.new(content), client:)

# 1.0, as methods of a client, which require "x" adds
media = client.upload_media("cat.jpg")
client.update_profile_image("avatar.png")
  • upload infers the media category from the media, and still takes media_category:.
  • No upload method takes boundary:. Each upload generates its own multipart boundary.
  • await_processing and await_processing! take the media as one argument: an upload response, a media ID, or anything that answers media_key, such as an X::Media.
  • A class that includes X::Uploads::MediaUpload gains only its public methods.
    • Private ones it gained in 0.19, such as init, append, and construct_upload_body, are gone from it.
  • client.chunked_upload_media uploads in chunks and returns without awaiting processing, as chunked_upload does.

Media can be a path or an IO, where 0.19 took a path alone:

  • upload and chunked_upload take a String or Pathname path, or an IO open on the media.
  • A path, File, or Tempfile is read a chunk at a time, so media of any size uploads without being held in memory.
  • Any other IO, such as a StringIO, is read to its end and held in memory.
  • Media that names no file is categorized by its leading bytes, so client.upload_media(StringIO.new(png)) uploads an image.
  • Media whose type neither its bytes nor its file name reveal raises X::InvalidMediaType unless media_category: names it.
    • For example, SubRip subtitles in a StringIO, or a PDF.

The profile image and banner of X::Uploads::Account are checked before any request:

  • They take a path or an IO, where 0.19 took a path alone.
  • They must begin with the signature of a GIF, JPEG, or PNG, whatever the file name, or raise X::InvalidMediaType.
    • Before: a file was taken by its extension, so a video named .png was sent.
  • A profile image over 700 KB, or a banner over 5 MB, raises X::InvalidMedia.
  • A banner's width: or height: that is not a positive Integer raises ArgumentError. 0.19 sent it as given.
  • A banner's offset_left: or offset_top: that is not an Integer of at least 0, such as "1500", raises ArgumentError.
  • Content given as a StringIO raises X::InvalidMedia if empty, and X::InvalidMediaType if not a GIF, JPEG, or PNG.
  • They are posted with the client given, and its credentials, to API v1.1 at the host of its base_url.
    • Before: they built their own client for https://api.x.com/1.1/ from the OAuth 1.0a credentials.
  • update_profile_image returns nil, as update_profile_banner does. 0.19 returned the API v1.1 user Hash.
    • Look the user up, as with client.current_user! of x-resources, to read the new profile image.

upload_binary is gone. A client has no upload_media_binary:

  • Pass content in memory as a StringIO to upload_media or X::Uploads::MediaUpload.upload. They infer its category, or take media_category:.
  • An image, or a GIF small enough, uploads in a single request, as upload_binary did.
  • A video, subtitles, or a larger GIF uploads in chunks, which sends their media type and has no 5 MB limit. 0.19 sent them in a single request.
  • They await processing of media that X processes.
# 0.19
X::MediaUploader.upload_binary(client:, content:, media_category: "tweet_image")

# 1.0
client.upload_media(StringIO.new(content))                            # the category read from the signature
client.upload_media(StringIO.new(content), media_category: "tweet_image")
X::Uploads::MediaUpload.upload(StringIO.new(content), client:, media_category: "tweet_image")

upload, chunked_upload, await_processing, and await_processing! return a frozen X::UploadedMedia, rather than a Hash:

  • It reads like the Hash did, with [], fetch, dig, and key?, so media["size"] and media.dig("processing_info", "state") still work.
  • to_h returns the Hash, for code that compares it with a Hash or calls merge.
  • It also answers id, media_key, bytesize (what media["size"] holds), expires_after_secs, state, processing?, failed?, and ready?.
  • media.id is an Integer. media["id"], to_h, as_json, and to_json keep the String the API gave, as 0.19 did.
  • media_ids: still sends each ID as a String.
  • For media X processes, such as a video or an animated GIF, upload returns the processing status rather than the upload response. Both hold the ID.
  • The other uploaders return Hashes and Arrays, whatever the client's default_object_class and default_array_class.
    • Before: X::MediaUploader parsed its responses with the client's classes.

The media type sent is read from the media:

  • infer_media_type is internal, where 0.19 documented it. Pass media_type: to upload or chunked_upload to send another type.
  • An upload sends the type the bytes name, or else the extension's.
    • Before: video/mp4 for every video. After: for example, video/quicktime for clip.mov.
    • Before: application/x-subrip for SubRip subtitles. After: text/srt.
  • Media of a type its category does not take raises X::InvalidMediaType before any request.
    • For example, an MP4 uploaded as tweet_gif, dm_gif, or subtitles, or a PNG uploaded as tweet_video.
    • Before: it was sent as the category's first type, such as an MP4 sent as a GIF.
    • After: pass the right category, or media_type: to chunked_upload to send it as another type.
  • Content in memory raises it too, when its signature names a type its category does not take, or names none for a GIF category.
  • A file named as a type whose files all begin with a signature, such as .png, .gif, or .ts, raises X::InvalidMediaType if it lacks it.
    • For example, TypeScript named .ts.
  • Bytes win over the name, so a PNG named .gif uploads as an image.
  • These raise X::InvalidMediaType, whatever media_category: says, unless uploaded in chunks with both a media_category: and a media_type: naming the type:
    • An .avi or .mkv file that does not begin with the signature of a documented type, such as MP4 or WebM.
    • Media whose name names no type, such as a StringIO, that begins with the header of Matroska and is not WebM.
    • A .glb or .usdz file.
    • Before: any of them uploaded as MP4 when told it was a video.

Processing and failures raise errors of their own:

  • await_processing! raises X::MediaProcessingFailed, rather than RuntimeError.
  • await_processing raises X::MediaProcessingTimeout after 600 seconds, rather than waiting forever.
    • Pass processing_timeout: nil to wait as long as processing takes, as 0.19 did.
    • Otherwise pass a finite number of seconds. Float::INFINITY raises ArgumentError.
    • The same goes for upload and the client's upload_media, await_media_processing, and await_media_processing!.
  • It waits until X reports processing succeeded or failed, as 0.19 did, but no longer than processing_timeout:.
    • Media in any other state, even one X does not document, or in none, is still processing? and is checked again, each second if X asks for no wait.
    • await_processing! and upload raise X::MediaProcessingFailed for failed processing alone.
  • Media that already says its processing ended, or an image's upload response, is returned without a request. 0.19 checked it again.
  • An upload response that describes no media, or has a nil or empty ID, raises X::MissingMediaData.
  • Media given with no ID, such as nil or {}, raises ArgumentError before any request, as does X::UploadedMedia.new, which also refuses an ID that is neither an Integer nor a String of 1 to 19 digits.
    • Before: NoMethodError or KeyError, or an empty media_id was sent.
  • A missing file, or media that cannot be read, is empty, or is too large, raises X::InvalidMedia before any request.
    • Before: the uploaders raised RuntimeError "File not found" for a missing file.
    • After: rescue X::InvalidMedia where code rescued RuntimeError.
    • An X::InvalidMedia whose cause is a system error, such as Errno::EMFILE or Errno::EIO, means the machine could not read the media, not that the media is wrong.
  • A chunked upload that fails once initialized, at a chunk or at finalize, raises X::ChunkedUploadFailed.
    • Its media is the media the upload initialized, and its cause is the error that failed it.
    • Before: the request's error, such as X::ServerError, was raised, and the media was lost.
    • After: rescue X::ChunkedUploadFailed, or read error.cause.
  • An upload whose media is uploaded, but whose check of its processing fails, raises X::MediaProcessingCheckFailed.
    • Its media is the media that was uploaded, and its cause is the error that failed the check.
    • Before: upload did not await processing. Code that called await_processing itself saw the request's error, as it still does.
    • After: rescue X::MediaProcessingCheckFailed around upload, and await error.media again.
  • An upload given alt_text: whose media is uploaded, but whose alt text cannot be added, raises X::AltTextFailed.
    • Its media is the media that was uploaded, and its cause is the error that failed to add the alt text.
    • After: rescue X::AltTextFailed, and attach error.media or call add_alt_text with it again.
  • An X::TokenReportFailed, which a save_tokens that raises during a request of an upload raises, is raised as it is, not as the cause of any of these.
    • Rescue it around the upload to store error.tokens, as around any other request.

The errors x-uploads raises descend from X::Uploads::Error, an X::Error:

  • These are X::Uploads::Errors: X::MissingMediaData, X::MediaProcessingFailed, X::MediaProcessingTimeout, X::MediaProcessingCheckFailed, X::ChunkedUploadFailed, X::AltTextFailed, and X::InvalidMedia.
  • X::InvalidMediaType descends from X::InvalidMedia.
    • So code that uploads user input can rescue it without rescuing the ArgumentError of its own mistakes.
    • An ArgumentError is not an X::Error. Besides a mistake in the arguments, a request raises one when the proxy named in the environment cannot be parsed, or is not an http or https URL with a host. The INIT request, or the one request of a single-request upload, raises it as it is; a later request that opens a connection raises X::ChunkedUploadFailed, X::MediaProcessingCheckFailed, or X::AltTextFailed with it as the cause.
  • An X::Error the API raises before there is media, such as an X::BadRequest of the INIT request, is not an X::Uploads::Error, as in 0.19.
    • rescue X::Error catches both.

Chunk sizes are in bytes, and sizes are checked before any request:

  • chunk_size_mb: is now chunk_size:, in bytes. Pass chunk_size: 4 * 1024 * 1024 where 0.19 code passed chunk_size_mb: 4.
  • A chunk_size: that is not a positive Integer, such as a Float, raises ArgumentError.
  • chunk_size: above 5,242,880 bytes (5 MB), or one needing more than 10,000 segments, raises ArgumentError.
    • 0.19 sent it for the server to refuse above 8 MB.
  • chunk_size: defaults to nil, which uploads in chunks of 4 MB, X::Uploads::MediaUpload::DEFAULT_CHUNK_SIZE, where 0.19 used 1 MB chunks.
    • Why: each chunk is a request a rate limit can refuse, and the API takes at most 10,000 segments, so 0.19's 1 MB chunks failed for files over 10,000 MiB.
  • A file over 16 GB (17,179,869,184 bytes) raises X::InvalidMedia before anything is uploaded.
  • An alt_text: that is empty or over 1,000 characters raises ArgumentError.
  • An animated GIF over 5 MB uploads in chunks, up to 15 MB. 0.19 sent it in a single request for X to refuse.
  • These raise X::InvalidMedia before any request, where 0.19 sent them for X to refuse:
    • An image over 5 MB.
    • A GIF over 15 MB.
    • Subtitles over 1 MB.
  • A megabyte is 1,048,576 bytes. A video's limit depends on the account and is left to X, up to 16 GB.

Objects

require "x" loads x-resources, which has no counterpart in 0.19. get, post, put, and delete return what they did, so code written for 0.19 changes only where one of its own names is now one of the gem's:

  • These constants are new under X:
    • Resources: X::User, X::Post and its alias X::Tweet, X::Media, X::List, X::Space, X::Community, X::DirectMessage, X::Poll, X::Place, X::Topic, X::BookmarkFolder, and their base, X::Resource.
    • Values: X::Trend, X::PersonalizedTrend, X::MatchingRule, and X::PostUsage.
    • Collections: X::Page and X::Cursor.
    • Errors: X::MissingResource, X::MissingClient, X::UnreadableResponse, X::InvalidAttribute, and X::PageLimitReached.
    • x-uploads and x-streams add X::UploadedMedia, X::StreamingClient, X::StreamRule, X::StreamError, X::RulesRejected, and the errors named under Uploads.
    • x-core adds X::Response, X::StreamResponse, X::Problem, X::OAuth2Tokens, X::OAuth2Authorization, X::AppOnlyAuthenticator, the new errors named under Errors, and X::AuthorizationError, X::AuthorizationDenied, X::TokenReportFailed, X::UnsupportedOperation, and X::UnsupportedFormat.
    • Each gem adds a module of its own name: X::Core, X::Uploads, X::Streams, and X::Resources.
    • An app that defined one of these itself, such as its own X::User, now reopens the gem's class. Rename it, or move it out of X.
  • X::Client has about a hundred new public methods, which X::Resources::API documents:
    • Lookups, such as find_user, find_post, find_all_users, current_user, and current_user_id.
    • Searches and counts, such as search_posts, search_users, count_posts, and trends.
    • Actions, such as create_post, delete_post, follow, like, repost, bookmark, block, mute, create_list, and create_dm.
    • x-uploads adds upload_media and seven more, and x-streams adds streaming.
    • x-core adds with, app_only, close, headers, get_stream, with_retries, memoize, memoized, scopes, expires_at, and a reader for each setting, such as max_retries.
    • A subclass of X::Client, or code that reopens it, that defines a method of one of these names replaces the gem's. Rename it.
  • Code that depends on x-core alone, and requires x/core, gets the names of x-core alone.

Timeouts

open_timeout defaults to 10 seconds, rather than the 60 of 0.19:

  • Why: a reachable host finishes the TCP and TLS handshakes in well under a second, so 10 seconds gives up on an unresponsive host sooner.
  • read_timeout and write_timeout are still 60 seconds.
  • Pass open_timeout: 60 for the old default on a network where connecting is slow.

Timeouts are checked when the client is built:

  • open_timeout, read_timeout, and write_timeout take a finite number of seconds of at least 0, or nil for none, as in 0.19.
  • Anything else, such as a String read from an environment variable, raises ArgumentError from X::Client.new.
    • Before: Net::HTTP raised once a request waited.

debug_output is checked when the client is built too:

  • It takes nil, or what takes a String with <<, such as an IO, a StringIO, or a Logger. A value that does not, such as true, or whose << does something else, such as an Integer or a Proc, raises ArgumentError.
    • Before: NoMethodError from the first request for true, and TypeError for an Integer or a Proc.
  • A String, such as "debug.log", raises ArgumentError too. Pass File.open("debug.log", "a"), or a StringIO to collect the output.
    • Before: every request, with its Authorization header, was appended to the String, and no file was written.

Headers

A client takes headers:, which it sends with every request and stream:

  • They are defaults. A header of the same name passed to a request replaces the client's.
  • The client's headers replace the gem's defaults, such as its User-Agent.
  • The gem's default User-Agent names the gem, Ruby, and the platform, as x-ruby/1.0.0 ruby/3.4.0 (arm64-darwin24), where 0.19 sent X-Client.
    • Code that tells the gem's requests apart by it, such as a proxy rule, matches the new value, or sends its own with headers:.
  • A header that carries credentials is dropped on a redirect to another origin, whether given to the client or to the request.
  • A header named by a Symbol is sent with its underscores as hyphens. content_type: names content-type, so it replaces that header.
  • client.headers names each header by a String, the way it is sent, whether it was given by a String or a Symbol.
  • They are sent with the client's token requests too, the app-only bearer token, an OAuth 2.0 refresh, and the code exchange, beneath the token request's own Authorization, Content-Type, and Accept.
# 1.0
client = X::Client.new(headers: {"User-Agent" => "my-app/1.0"}, **x_credentials)
traced = client.with(headers: {"User-Agent" => "my-app/1.0", "X-Trace" => "abc"})

Proxies

A client given no proxy_url picks the proxy for each request's scheme from the environment:

  • It uses https_proxy for HTTPS requests, which is all these gems make, and http_proxy for plain HTTP.
  • It connects directly to hosts listed in no_proxy.
  • Before: Net::HTTP read http_proxy alone, whatever the scheme, so https_proxy was ignored.
  • A proxy URL in one of these variables that cannot be parsed, or that is not an http or https URL with a host, as proxy.example.com:8080 and socks5://proxy.example.com:1080 are not, raises ArgumentError from each request that opens a connection. An empty variable names no proxy. The message names the variables read for the scheme of the request, as https_proxy or HTTPS_PROXY, and leaves out the value, which can hold a password.
  • Set no_proxy, or pass a proxy_url, where this changes which proxy a process reaches X through.

A proxy URL can hold a user and password, so a client keeps it private:

  • proxy_url no longer reads off X::Client or X::Connection, nor off the new X::StreamingClient.
  • proxy_uri, proxy_host, proxy_port, proxy_user, and proxy_pass no longer read off a connection.
  • inspect leaves the proxy out, and with carries the proxy to a copy.
  • Keep the URL you built the client with if your code needs to read it again.

Streaming

stream moved from X::Client to X::StreamingClient, from x-streams, which streaming builds from a client:

# 0.19
client.stream("tweets/search/stream") { |post| puts post }

# 1.0
client.streaming.stream("tweets/search/stream") { |post| puts post }

A stream reconnects when it ends or drops:

  • Before: a stream that ended returned, and one that dropped raised.
  • After: it reconnects, and once it has no reconnects left raises X::NetworkError, whether it ended or dropped.
    • Rescue X::NetworkError where 0.19 code waited for stream to return.
  • Pass max_reconnects: 0 to streaming to reconnect no stream, as 0.19 did.
  • Pass on_reconnect: ->(error, wait) { ... } to streaming to hear of each reconnect, or to give up with stop.
  • To stop a stream from another thread or the trap of a signal, call stop on its streaming client. Each stream it stops returns nil.
    • Keep the streaming client in a variable, since each streaming call builds a new one.
    • The streaming client stays stopped; build another with streaming to stream again.
    • A block, on_response, or on_reconnect that is running finishes first.

A line that holds errors and no data is not yielded to the block:

  • Before: every line X sent was yielded, so a block read post["errors"] to tell a problem from a post.
  • After: such a line raises X::StreamError, an X::Streams::Error, whose problems are the X::Problems of the line.
    • A line of operational-disconnects alone reconnects instead, and raises once no reconnects are left.
    • Any other stops the stream. Rescue X::StreamError around stream, beside X::NetworkError, where the block handled those lines.
  • A line that is not JSON reconnects, and raises X::InvalidResponse once no reconnects are left. 0.19 raised JSON::ParserError.

A stream has its own read_timeout, 30 seconds by default:

  • Before: it read with the client's 60 seconds. Pass streaming(read_timeout: 60) for the old timeout.
  • A read_timeout under 25 seconds raises ArgumentError. Pass nil for no timeout.
    • Why: X sends a quiet stream a keep-alive every 20 seconds, so a shorter timeout would drop a live stream whenever one ran late.

Streams authenticate as the app, since the stream endpoints take app-only authentication:

  • A client that signs with OAuth 1.0a fetches the app's bearer token with its API key and secret.
  • A client that authenticates with OAuth 2.0 as a user streams with the app's bearer_token, or its api_key and api_key_secret, given beside the user's credentials.
  • An OAuth 2.0 user client with neither streams as the user. X refuses it with 403, raising X::Forbidden.
    • The same goes for reading and changing filtered-stream rules.

require "x" loads x-streams and gives every client streaming. Code that depends on x-core alone, and streamed with 0.19, adds x-streams:

  • Include its methods into the client, as x does.
  • Or build a streaming client of a client itself:
require "x/core"
require "x/streams"

X::Client.include(X::Streams::API)
client.streaming.stream("tweets/search/stream") { |post| puts post }

# or, without changing X::Client
X::StreamingClient.new(client).stream("tweets/search/stream") { |post| puts post }

Errors

Some statuses raise new or different errors. rescue X::Error still catches them all:

  • New, each an X::ClientError: X::PaymentRequired (402), X::MethodNotAllowed (405), X::RequestTimeout (408), X::UnsupportedMediaType (415), and X::UnavailableForLegalReasons (451).
  • A 4xx or 5xx status without a class of its own raises X::ClientError or X::ServerError, rather than X::HTTPError.
  • X::NetworkError wraps every network failure:
    • IOError, including EOFError.
    • SystemCallError, including every Errno error.
    • Net::HTTP's open, read, and write timeouts, Net::ProtocolError, Net::HTTPBadResponse, Zlib::Error, OpenSSL::SSL::SSLError, and SocketError.

Error messages name the request:

  • Before: the API's message alone. After: GET /2/users/1: Could not find user.
    • Code that matches a message against a String should allow for that prefix, or read error.problem instead.
  • X::TooManyRedirects reads GET /2/users/2: Too many redirects, rather than Too many redirects.
  • error.http_method and error.uri read the request on X::HTTPError, X::NetworkError, and X::InvalidResponse.

A successful response whose body is not JSON, such as a proxy's page, raises X::InvalidResponse:

  • Before: it returned nil.
  • X::InvalidResponse is an X::HTTPError.
  • A successful response without a body still returns nil.
  • error.body reads the body of the response when the error was built without one, once that response has been read whole.

A response body, which 0.19 read as error.response.body, is tagged UTF-8, where 0.19 left it binary:

  • error.body and error.http_response.body can be joined with non-ASCII Strings without Encoding::CompatibilityError.
  • Code that forced a body's encoding no longer needs to.
  • A body that is not valid UTF-8 keeps its bytes. valid_encoding? tells it apart.

X::TooManyRequests#reset_at, #reset_in, and #retry_after return nil when the response does not say when the limit resets:

  • Before: they returned Time.at(0) and 0, which retried at once.
  • After: fall back to a wait of your own. X recommends a minute:
# 0.19
sleep error.retry_after

# 1.0
sleep(error.retry_after || 60)

retry_after reads the Retry-After header:

  • X::HTTPError#retry_after reads it from any refused response.
  • X::TooManyRequests#retry_after reads it in place of the limit's reset time, which 0.19 read alone.
    • It counts seconds from when the response was sent, so the wait is right however far the local clock is off.
    • It answers for a refusal that reports no limit, such as a daily cap.
  • #reset_in still reads the limit alone.

X::RateLimit#retry_after is gone:

  • It was an alias for #reset_in, so one name would have meant two waits.
  • Read a limit with #reset_in, and the wait a refusal asks for with X::TooManyRequests#retry_after:
# 0.19
sleep error.rate_limit.retry_after

# 1.0
sleep(error.limiting_rate_limit&.reset_in || error.retry_after || 60)

Several readers of X::HTTPError are renamed or gone:

  • #error_message and #message_from_json_response are gone. Read message.
  • #json? is private.
  • #code is gone. It read the status as the String "404".
    • #status reads it as the Integer 404, as X::Response#status does.
    • For the String, call status.to_s or read error.http_response.code.
  • The Net::HTTP response is http_response on X::HTTPError and X::InvalidResponse, rather than response.
    • X::Response#http_response reads the same object.
    • It is an escape hatch: its class is the transport's, and not part of what 1.x promises.
  • X::RateLimit#response is gone. Read limit, remaining, and reset_at, or the response of the error or X::Response.
  • #problem is new, and returns the response's X::Problem, with detail, title, and to_h.

Building errors and limits changed:

  • X::HTTPError.new no longer takes response:, so code that builds an error, such as a test double, raises ArgumentError.
    • Pass http_response: with the http_method: and uri: of the request.
    • Or pass status:, headers:, and body: without a response.
    • Or raise it with a message alone.
  • X::RateLimit.new, which took type: and response:, is private. Read the limits of a response or an error instead.
# 0.19
X::NotFound.new(response: response)

# 1.0
X::NotFound.new(http_response: response, http_method: :get, uri: URI("https://api.x.com/2/users/1"))
X::NotFound.new(status: 404, headers: {"content-type" => "application/json"}, body: %({"title":"Not Found Error"}))
raise X::TooManyRequests, "Too Many Requests"

X::TooManyRequests#rate_limits and #rate_limit mean what they mean on X::Response:

  • #rate_limits reports every limit the response names. 0.19 reported only the exhausted ones.
  • #rate_limit is the 15-minute limit. 0.19 gave the exhausted limit that resets last.
  • The exhausted limits are now #exhausted_rate_limits.
  • The one a request waits for is #limiting_rate_limit.
    • reset_at and reset_in read it, and so does retry_after without a Retry-After header, so code that sleeps for retry_after is unchanged.
# 0.19
error.rate_limits          # the exhausted limits
error.rate_limit           # the exhausted limit that resets last

# 1.0
error.exhausted_rate_limits
error.limiting_rate_limit

Version

X::VERSION is a String, rather than a Gem::Version:

  • So are the VERSION constants of X::Core, X::Uploads, X::Streams, and X::Resources.
  • Code that logs or sends a version reads the constant itself.
  • Code that compares versions calls gem_version, which builds the Gem::Version:
# 0.19
X::VERSION >= Gem::Version.new("0.19")
X::VERSION.segments.first

# 1.0
X.gem_version >= Gem::Version.new("1.0")
X.gem_version.segments.first

Internals

Internal classes and modules are private constants under X::Core or X::Streams, so they can change within 1.x:

  • X::RequestBuilder, X::RedirectHandler, X::ResponseParser, and X::ClientCredentials are under X::Core.
  • X::StreamParser is X::Streams::StreamParser.
  • X::Connection is X::Core::Connection.
    • Build a client with the settings you gave a connection.
    • Read timeout defaults from X::Client::DEFAULT_OPEN_TIMEOUT, DEFAULT_READ_TIMEOUT, DEFAULT_WRITE_TIMEOUT, and DEFAULT_KEEP_ALIVE_TIMEOUT.
  • X::OAuth2Authenticator has no connection. A client refreshes its tokens over its own connection.
  • Configure the internals through the settings of X::Client and X::StreamingClient, such as max_redirects.
  • Read their defaults from X::Client::DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_RATE_LIMIT_RETRIES, DEFAULT_MAX_RATE_LIMIT_WAIT, and X::StreamingClient::DEFAULT_MAX_RECONNECTS.
  • X::Client, X::BearerTokenAuthenticator, X::OAuth2Authenticator, X::RateLimit, and the errors of 0.19 keep their names, except as the table above shows.

Removed constants

  • Gone, since simple_oauth signs requests and builds token refreshes:
    • X::OAuthAuthenticator::OAUTH_VERSION, OAUTH_SIGNATURE_METHOD, and OAUTH_SIGNATURE_ALGORITHM.
    • X::OAuth2Authenticator::REFRESH_GRANT_TYPE.
  • X::OAuth2Authenticator::TOKEN_HOST and TOKEN_PATH are gone. Tokens are requested at the origin of the client's base_url.
  • X::MediaUploader::MAX_RETRIES is gone. A chunk is retried up to the client's max_retries.
  • X::AccountUploader::MIME_TYPE_MAP and SUPPORTED_EXTENSIONS are gone. A profile image or banner is checked by its signature.
  • X::AccountUploader::V1_BASE_URL is gone. The endpoints are private, relative to the client's base_url.
  • X::HTTPError::JSON_CONTENT_TYPE_REGEXP and X::OAuth2Authenticator::EXPIRATION_BUFFER are private.
  • X::Connection::DEFAULT_HOST and DEFAULT_PORT are gone, since every request names its host.
  • The gems no longer depend on base64, which x 0.19 did. Add it to your own Gemfile if you use it.