Upgrading
From 0.19 to 1.0
Version 1.0 splits the gem into x-core, x-uploader, x-streaming, and x-objects. 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.
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, andaccess_token_secret. - OAuth 2.0:
client_idandaccess_token, plus therefresh_tokenthat refreshes it and, for a confidential client,client_secret. - App-only:
bearer_token. - App-only:
api_keyandapi_key_secretalone.
- OAuth 1.0a:
- An OAuth 2.0 token issued without the
offline.accessscope has no refresh token. Passclient_idandaccess_tokenalone.- Such a client acts for the user and refreshes nothing. 0.19 sent its requests without credentials.
- A
bearer_tokenis 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_idor anapi_keybeside abearer_token.
- For example, a
- 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.
- OAuth 2.0 credentials beside a complete OAuth 1.0a set raise
ArgumentError, since they would share its access token. expires_atis allowed only beside the OAuth 2.0client_idandaccess_tokenthe client authenticates with. Anywhere else it raisesArgumentError.- 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", "").
- Watch for an unset environment variable read as
A client, its authenticators, and X::RateLimit are read-only. 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
withinstead, asclient.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=andresponse=.
- On
- 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, andload_tokens.- Before: a setter changed one credential and kept the rest.
- After: pass the refresh token,
save_tokens:, andload_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_atit 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, andrefresh_token. - These are private on the authenticators, so
client.authenticatorreads none of them by name:api_key_secret,access_token_secret, andclient_secret.bearer_tokenofX::BearerTokenAuthenticatorandX::AppOnlyAuthenticator.access_tokenandrefresh_tokenofX::OAuth2Authenticator, which 0.19 read.access_tokenofX::OAuth1Authenticator, which 0.19 read offX::OAuthAuthenticator.
inspectreveals no secret.- An authenticator's
headersstill returns the headers it signs a request with, since a custom authenticator overrides it.- The
Authorizationheader 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.
- The
- Still public on a client:
api_key,client_id, and the newexpires_at. On an OAuth 2.0 authenticator:client_idandexpires_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::OAuth2Tokenspassed tosave_tokens, and carry credentials to another client withwith:
# 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::OAuth2AuthorizationraiseTypeErrorfromMarshal.dump,YAML.dump,as_json, andto_json.- This catches a client held in a cached Hash, a job argument a queue writes as YAML, and
render json:.
- This catches a client held in a cached Hash, a job argument a queue writes as YAML, and
- Keep credentials in a secret store, and build the client again from them.
-
X::OAuth2Tokensstill marshal, tokens and all, since they are meant to be stored.- They write themselves as JSON with
to_json, andX::OAuth2Tokens.from_jsonreads it back.
- They write themselves as JSON with
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::OAuth2Tokensof that refresh, the objectsave_tokensis 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, anX::ClientError, where 0.19 raised anX::Errorwhose message was theerror_description.- Its
error_codeis the OAuth 2.0 error, such asinvalid_grant. Read it rather than the message. - A token endpoint that fails to answer raises the
X::HTTPErrorof its status, such asX::ServerError. rescue X::Errorcatches both, as it did.
- Its
# 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/tokenand a refresh goes to/x/2/oauth2/token.
- With
- 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
headerdid. - Before: it was passed the
Net::HTTPRequest, whosemethodis 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_urlishttps://api.x.com/2/, where 0.19 defaulted tohttps://api.twitter.com/2/.- Pass whole URLs on
api.x.com, or abase_urlonapi.twitter.com, so whole URLs keep the client's credentials.
- Pass whole URLs on
- 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, orProxy-Authorizationheader, 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 usedapi.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
ArgumentErrornaming it.- Before:
URI::InvalidURIError, or aNet::HTTPArgumentErrorthat named nothing. - After: rescue
ArgumentErrorwhere code rescuedURI::InvalidURIError.
- Before:
- An endpoint that is not a String, such as a Symbol or a
URI, raisesArgumentError. Passuri.to_s.
Requests are retried by the client, not by Net::HTTP:
- Before:
Net::HTTPresent 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 anX::NetworkErrorfor 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_retriestimes, twice by default, signing each attempt afresh. - It waits as long as a
Retry-Afterheader asks, up to a minute. A response asking for longer raises at once.
- Pass
max_retries: 0to raise at once, as code with its own retry loop may want. See the retries in README.md.
Renamed classes
| 0.19 | 1.0 |
|---|---|
X::OAuthAuthenticator |
X::OAuth1Authenticator |
X::ConnectionException |
X::Conflict |
X::MediaUploader |
X::Uploader::MediaUpload |
X::AccountUploader |
X::Uploader::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
ArgumentErrorfor an invalid category. - Pass a file to
upload. It raisesX::InvalidMediafor a missing file, and infers the type it sends. - The media category constants of
X::Uploader::MediaUpload, such asTWEET_IMAGE, remain public. - These are now private constants:
X::Uploader::Validator,X::Uploader::Chunks,X::Uploader::Multipart, andX::Uploader::Utils. - These constants of
X::Uploader::MediaUploadare private:MIME_TYPES,MIME_TYPE_MAP,BYTES_PER_MB, and the MIME type constants, such asGIF_MIME_TYPE,MP4_MIME_TYPE, andSUBRIP_MIME_TYPE. PROCESSING_INFO_STATESis gone. UseX::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.(client:, content:)
# 1.0
media = X::Uploader::MediaUpload.upload("cat.jpg", client:)
video = X::Uploader::MediaUpload.upload("cat.mp4", client:) # uploads in chunks and awaits processing
X::Uploader::Account.update_profile_image("avatar.png", client:)
X::Uploader::Account.update_profile_image(StringIO.new(content), client:)
X::Uploader::Account.(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")
uploadinfers the media category from the media, and still takesmedia_category:.- No upload method takes
boundary:. Each upload generates its own multipart boundary. await_processingandawait_processing!take the media as one argument: an upload response, a media ID, or anything that answersmedia_key, such as anX::Media.- A class that includes
X::Uploader::MediaUploadgains only its public methods.- Private ones it gained in 0.19, such as
init,append, andconstruct_upload_body, are gone from it.
- Private ones it gained in 0.19, such as
client.chunked_upload_mediauploads in chunks and returns without awaiting processing, aschunked_uploaddoes.
Media can be a path or an IO, where 0.19 took a path alone:
uploadandchunked_uploadtake aStringorPathnamepath, or an IO open on the media.- A path,
File, orTempfileis 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::InvalidMediaTypeunlessmedia_category:names it.- For example, SubRip subtitles in a
StringIO, or a PDF.
- For example, SubRip subtitles in a
The profile image and banner of X::Uploader::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
.pngwas sent.
- Before: a file was taken by its extension, so a video named
- A profile image over 700 KB, or a banner over 5 MB, raises
X::InvalidMedia. - A banner's
width:orheight:that is not a positiveIntegerraisesArgumentError. 0.19 sent it as given. - A banner's
offset_left:oroffset_top:that is not anIntegerof at least 0, such as"1500", raisesArgumentError. - Content given as a
StringIOraisesX::InvalidMediaif empty, andX::InvalidMediaTypeif 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.
- Before: they built their own client for
-
update_profile_imagereturns nil, asupdate_profile_bannerdoes. 0.19 returned the API v1.1 user Hash.- Look the user up, as with
client.current_user!ofx-objects, to read the new profile image.
- Look the user up, as with
upload_binary is gone. A client has no upload_media_binary:
- Pass content in memory as a
StringIOtoupload_mediaorX::Uploader::MediaUpload.upload. They infer its category, or takemedia_category:. - An image, or a GIF small enough, uploads in a single request, as
upload_binarydid. - 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::Uploader::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, andkey?, somedia["size"]andmedia.dig("processing_info", "state")still work. to_hreturns the Hash, for code that compares it with a Hash or callsmerge.- It also answers
id,media_key,bytesize(whatmedia["size"]holds),expires_after_secs,state,processing?,failed?, andready?. media.idis an Integer.media["id"],to_h,as_json, andto_jsonkeep 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,
uploadreturns 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_classanddefault_array_class.- Before:
X::MediaUploaderparsed its responses with the client's classes.
- Before:
The media type sent is read from the media:
infer_media_typeis internal, where 0.19 documented it. Passmedia_type:touploadorchunked_uploadto send another type.- An upload sends the type the bytes name, or else the extension's.
- Before:
video/mp4for every video. After: for example,video/quicktimeforclip.mov. - Before:
application/x-subripfor SubRip subtitles. After:text/srt.
- Before:
- Media of a type its category does not take raises
X::InvalidMediaTypebefore any request.- For example, an MP4 uploaded as
tweet_gif,dm_gif, orsubtitles, or a PNG uploaded astweet_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:tochunked_uploadto send it as another type.
- For example, an MP4 uploaded as
- 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, raisesX::InvalidMediaTypeif it lacks it.- For example, TypeScript named
.ts.
- For example, TypeScript named
- Bytes win over the name, so a PNG named
.gifuploads as an image. - These raise
X::InvalidMediaType, whatevermedia_category:says, unless uploaded in chunks withmedia_type:naming the type:- An
.avior.mkvfile that does not begin with the signature of a documented type, such as MP4 or WebM. - Matroska media that is not WebM.
- A
.glbor.usdzfile. - Before: any of them uploaded as MP4 when told it was a video.
- An
Processing and failures raise errors of their own:
await_processing!raisesX::MediaProcessingFailed, rather thanRuntimeError.-
await_processingraisesX::MediaProcessingTimeoutafter 600 seconds, rather than waiting forever.- Pass
processing_timeout: nilto wait as long as processing takes, as 0.19 did. - Otherwise pass a finite number of seconds.
Float::INFINITYraisesArgumentError. - The same goes for
uploadand the client'supload_media,await_media_processing, andawait_media_processing!.
- Pass
- It waits only while X reports processing pending or in progress. 0.19 kept checking until it failed or succeeded.
- Media in a state X does not document, or in none, is returned after that check, neither
processing?norready?. await_processing!anduploadraiseX::MediaProcessingFailedfor it, naming the state.
- Media in a state X does not document, or in none, is returned after that check, neither
- 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
{}, raisesArgumentErrorbefore any request, as doesX::UploadedMedia.new, which also refuses an ID that is neither an Integer nor a String of 1 to 19 digits.- Before:
NoMethodErrororKeyError, or an emptymedia_idwas sent.
- Before:
- A missing file, or media that cannot be read, is empty, or is too large, raises
X::InvalidMediabefore any request.- Before: the uploaders raised
RuntimeError"File not found" for a missing file. - After: rescue
X::InvalidMediawhere code rescuedRuntimeError.
- Before: the uploaders raised
- A chunked upload that fails once initialized, at a chunk or at finalize, raises
X::ChunkedUploadFailed.- Its
mediais the media the upload initialized, and itscauseis 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 readerror.cause.
- Its
The errors x-uploader raises descend from X::Uploader::Error, an X::Error:
- These are
X::Uploader::Errors:X::MissingMediaData,X::MediaProcessingFailed,X::MediaProcessingTimeout,X::ChunkedUploadFailed, andX::InvalidMedia. -
X::InvalidMediaTypedescends fromX::InvalidMedia.- So code that uploads user input can rescue it without rescuing the
ArgumentErrorof its own mistakes.
- So code that uploads user input can rescue it without rescuing the
- An
X::Errorthe API raises before there is media, such as anX::BadRequestof the INIT request, is not anX::Uploader::Error, as in 0.19.rescue X::Errorcatches both.
Chunk sizes are in bytes, and sizes are checked before any request:
chunk_size_mb:is nowchunk_size:, in bytes. Passchunk_size: 4 * 1024 * 1024where 0.19 code passedchunk_size_mb: 4.- A
chunk_size:that is not a positiveInteger, such as a Float, raisesArgumentError. -
chunk_size:above 5,242,880 bytes (5 MB), or one needing more than 10,000 segments, raisesArgumentError.- 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::Uploader::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::InvalidMediabefore anything is uploaded. - An
alt_text:that is empty or over 1,000 characters raisesArgumentError. - 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::InvalidMediabefore 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.
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_timeoutandwrite_timeoutare still 60 seconds.- Pass
open_timeout: 60for the old default on a network where connecting is slow.
Timeouts are checked when the client is built:
open_timeout,read_timeout, andwrite_timeouttake 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
ArgumentErrorfromX::Client.new.- Before:
Net::HTTPraised once a request waited.
- Before:
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-Agentnames the gem, Ruby, and the platform, asx-ruby/1.0.0 ruby/3.4.0 (arm64-darwin24), where 0.19 sentX-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:.
- Code that tells the gem's requests apart by it, such as a proxy rule, matches the new value, or sends its own with
- 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:namescontent-type, so it replaces that header. client.headersnames 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
AuthorizationandContent-Type.
# 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_proxyfor HTTPS requests, which is all these gems make, andhttp_proxyfor plain HTTP. - It connects directly to hosts listed in
no_proxy. - Before:
Net::HTTPreadhttp_proxyalone, whatever the scheme, sohttps_proxywas ignored. - Set
no_proxy, or pass aproxy_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_urlno longer reads offX::ClientorX::Connection, nor off the newX::StreamingClient.proxy_uri,proxy_host,proxy_port,proxy_user, andproxy_passno longer read off a connection.inspectshows the proxy URL without its user and password, andwithcarries 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-streaming, 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::NetworkErrorwhere 0.19 code waited forstreamto return.
- Rescue
- Pass
max_reconnects: 0tostreamingto reconnect no stream, as 0.19 did. - Pass
on_reconnect: ->(error, wait) { ... }tostreamingto hear of each reconnect, or to give up withstop. - To stop a stream from another thread or the trap of a signal, call
stopon its streaming client. Each stream it stops returns nil.- Keep the streaming client in a variable, since each
streamingcall builds a new one. - The streaming client stays stopped; build another with
streamingto stream again. - A block,
on_response, oron_reconnectthat is running finishes first.
- Keep the streaming client in a variable, since each
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_timeoutunder 25 seconds raisesArgumentError. 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 itsapi_keyandapi_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-streaming and gives every client streaming. Code that depends on x-core alone, and streamed with 0.19, adds x-streaming:
- Include its methods into the client, as
xdoes. - Or build a streaming client of a client itself:
require "x/core"
require "x/streaming"
X::Client.include(X::Streaming::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::MethodNotAllowed(405),X::RequestTimeout(408),X::UnsupportedMediaType(415), andX::UnavailableForLegalReasons(451). - A 4xx or 5xx status without a class of its own raises
X::ClientErrororX::ServerError, rather thanX::HTTPError. -
X::NetworkErrorwraps every network failure:IOError, includingEOFError.SystemCallError, including everyErrnoerror.Timeout::Error,Net::ProtocolError,Net::HTTPBadResponse,Zlib::Error,OpenSSL::SSL::SSLError, andSocketError.
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.probleminstead.
- Code that matches a message against a String should allow for that prefix, or read
X::TooManyRedirectsreadsGET /2/users/2: Too many redirects, rather thanToo many redirects.error.http_methodanderror.uriread the request onX::HTTPError,X::NetworkError, andX::InvalidResponse.
A successful response whose body is not JSON, such as a proxy's page, raises X::InvalidResponse:
- Before: it returned nil.
X::InvalidResponseis anX::HTTPError.- A successful response without a body still returns nil.
error.bodyreads 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.bodyanderror.http_response.bodycan be joined with non-ASCII Strings withoutEncoding::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_afterreads it from any refused response.-
X::TooManyRequests#retry_afterreads 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_instill 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 withX::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_messageand#message_from_json_responseare gone. Readmessage.#json?is private.-
#codeis gone. It read the status as the String"404".#statusreads it as the Integer404, asX::Response#statusdoes.- For the String, call
status.to_sor readerror.http_response.code.
- The
Net::HTTPresponse ishttp_responseonX::HTTPErrorandX::InvalidResponse, rather thanresponse.X::Response#http_responsereads the same object.
X::RateLimit#responseis gone. Readlimit,remaining, andreset_at, or the response of the error orX::Response.#problemis new, and returns the response'sX::Problem, withdetail,title, andto_h.
Building errors and limits changed:
-
X::HTTPError.newno longer takesresponse:, so code that builds an error, such as a test double, raisesArgumentError.- Pass
http_response:with thehttp_method:anduri:of the request. - Or pass
status:,headers:, andbody:without a response. - Or raise it with a message alone.
- Pass
X::RateLimit.new, which tooktype:andresponse:, 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_limitsreports every limit the response names. 0.19 reported only the exhausted ones.#rate_limitis 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_atandreset_inread it, and so doesretry_afterwithout aRetry-Afterheader, so code that sleeps forretry_afteris 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
VERSIONconstants ofX::Core,X::Uploader,X::Streaming, andX::Objects. - Code that logs or sends a version reads the constant itself.
- Code that compares versions calls
gem_version, which builds theGem::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::Streaming, so they can change within 1.x:
X::RequestBuilder,X::RedirectHandler,X::ResponseParser, andX::ClientCredentialsare underX::Core.X::StreamParserisX::Streaming::StreamParser.-
X::ConnectionisX::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, andDEFAULT_KEEP_ALIVE_TIMEOUT.
X::OAuth2Authenticatorhas noconnection. A client refreshes its tokens over its own connection.- Configure the internals through the settings of
X::ClientandX::StreamingClient, such asmax_redirects. - Read their defaults from
X::Client::DEFAULT_MAX_REDIRECTS,DEFAULT_MAX_RATE_LIMIT_RETRIES,DEFAULT_MAX_RATE_LIMIT_WAIT, andX::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, andOAUTH_SIGNATURE_ALGORITHM.X::OAuth2Authenticator::REFRESH_GRANT_TYPE.
X::OAuth2Authenticator::TOKEN_HOSTandTOKEN_PATHare gone. Tokens are requested at the origin of the client'sbase_url.X::MediaUploader::MAX_RETRIESis gone. A chunk is retried up to the client'smax_retries.X::AccountUploader::MIME_TYPE_MAPandSUPPORTED_EXTENSIONSare gone. A profile image or banner is checked by its signature.X::AccountUploader::V1_BASE_URLis gone. The endpoints are private, relative to the client'sbase_url.X::HTTPError::JSON_CONTENT_TYPE_REGEXPandX::OAuth2Authenticator::EXPIRATION_BUFFERare private.X::Connection::DEFAULT_HOSTandDEFAULT_PORTare gone, since every request names its host.- The gems no longer depend on
base64, whichx0.19 did. Add it to your own Gemfile if you use it.