timvw-rust-runtime
The runtime of Rust clients generated by
openapi-generator-timvw-rust (-g timvw-rust). A generated client sends every request through a Client: a
tower service from http::Request<Body> to http::Response<Body>. That makes everything
around sending a tower layer: retries, timeouts, logging, headers, authentication, rate limits. Use the layers this
crate ships, any layer from the tower ecosystem, or write your own.
- Quick start
- Retries
- Timeouts
- Logging
- Middleware: headers, authentication, layers of your own
- Providers: the same API at another base URL
- Concurrency and rate limits
- Choosing the HTTP client, and tests without a network
- Your own types, and streams of server-sent events
- Uploads and downloads
- Credentials
- Headers and query parameters for one call
- Errors
- WebAssembly
- Features
Quick start
A generated client keeps its Client in Configuration.client, which starts as Client::default(): reqwest, with
retries of 429, 408, 5xx and failed connections. layer wraps it in more:
use Duration;
use TimeoutLayer;
use TraceLayer;
use Client;
let client = default // reqwest, with retries
.layer // logs each call, and each retry
.layer; // bounds the whole call, retries included
// In a generated client:
// let mut configuration = my_client::apis::configuration::Configuration::new();
// configuration.client = client;
Layers apply from the inside out: the last one added sees a request first. Layers added to Client::default()
wrap its retries. To put a layer inside the retries (a timeout or a log line per attempt), or to retry differently,
build the stack from the bare service, Client::from_service(ReqwestService::default()):
use Duration;
use ;
use TimeoutLayer;
use TraceLayer;
use ;
let client = from_service // reqwest, no retries
.layer // each attempt
.layer // logs each attempt
.layer; // retries, a timed-out attempt too
A Client is cheap to clone, and clones share their layers' state (a rate limit, a token cache).
Retries
Client::default(), Client::new() and Client::from_reqwest(..) retry with
retry::RetryLayer::new(RetryPolicy::default()) (feature retry, on by default). retry::RetryPolicy is a
tower::retry::Policy for HTTP APIs:
- It retries
429 Too Many Requests,408 Request Timeout, every5xxand failures to connect. Any other response or error is returned at once. - It waits as long as the server asks, with
retry-after-ms(milliseconds, as OpenAI sends) orRetry-After(seconds, or an HTTP date such asWed, 21 Oct 2015 07:28:00 GMT), up tomax_retry_after(60 s by default). A response that asks for longer is returned at once instead of retried: waiting less than asked would likely fail again, and an hour-long wait is rarely what a caller wants. The wait is read once into a deadline, counted from when the response arrived (a date in the past means now), and the policy sleeps until that deadline: reading a429's body forpermanent_429does not make it wait longer than asked. - Otherwise it backs off exponentially: 200 ms, 400 ms, 800 ms, ..., capped at
max_delay(8 s). Each delay is jittered, picked at random between half and all of it, so that clients that failed together do not retry together;.jitter(false)turns that off. A server'sRetry-Afteris not jittered, nor capped bymax_delay. - It retries at most 3 times by default.
- It retries a request only when its body can be sent again: JSON, form and text bodies, bytes, and a file
Upload, which is opened again for each attempt (so is a multipart form of such parts). A body that can be read once (a stream, a reader) is not retried.
use Duration;
use ;
use ;
let policy = new
.base_delay
.max_delay // the backoff
.max_retry_after; // the longest Retry-After to wait for
let client = from_service.layer;
retry::RetryLayer::new(policy) retries just like tower::retry::RetryLayer::new(policy), which works too.
429s retried only when the server says when
Some APIs send Retry-After with a 429 that passes by waiting, and leave it out when waiting will not help. With
RetryPolicy::server_directed_429(true), a 429 without Retry-After or retry-after-ms (or with one that does not
parse) is returned at once, and one with them is retried at the time the server gave. 408, 5xx and failed
connections still back off without advice. The default retries every 429.
use ;
use ;
let client = from_service
.layer;
Permanent 429s: an exhausted quota
Some APIs answer 429 both for a rate limit, which passes, and for a quota or credit that is used up, which does not:
OpenAI's insufficient_quota. Only the body tells them apart. RetryLayer::permanent_429 reads the body of each
429 (up to body_limit, 64 KiB by default) and gives it to a function; when that returns true, the response is
returned at once, marked with the retry::Permanent extension. The body is put back, so the caller, and a generated
client's error, still read all of it. retry::openai_insufficient_quota is that function for OpenAI (an error.code
or error.type of insufficient_quota):
use ;
use ;
let client = from_service.layer;
// Any API: a function of the body's bytes.
let client = from_service.layer;
Client::default() does not read 429 bodies: what makes a 429 permanent differs between APIs, so the runtime
leaves it to the client of each API to turn on. A body longer than the limit, or one that fails to read, is treated as
a rate limit. A layer of your own inside the retries can mark any response not to retry:
response.extensions_mut().insert(retry::Permanent).
Without retries: Client::from_service(ReqwestService::default()), or build without the retry feature.
The policy retries every method, POST included: for most APIs a 429 or 503 means the request was not processed.
If a retried POST could act twice on your API, send an idempotency key the API understands, or write a
tower::retry::Policy of your own: the trait has two methods, retry (whether to try again and after which delay)
and clone_request. Body::try_clone clones a body held in memory.
Timeouts
A client has no timeout until you add one. timeout::TimeoutLayer (feature time, on with retry) fails a request
whose response (its status and headers) does not arrive in time; reading the body is not part of it. It waits with
tokio's timer natively and the host's setTimeout on WebAssembly, where fetch has no timeout of its
own and tower's TimeoutLayer (tokio's timer) does not work. A timeout is an Error whose is_timeout() is true:
use Duration;
use ;
async
let client = default.layer;
Where it sits decides what it bounds (layers apply from the inside out, see Quick start):
| Stack | The timeout bounds |
|---|---|
Client::default().layer(TimeoutLayer::new(t)) |
the whole call: every attempt and the waits between them |
Client::from_service(service).layer(TimeoutLayer::new(t)).layer(RetryLayer::new(policy)) |
each attempt; one that times out ends the call, unless policy.retry_timeouts(true) retries it |
| both, the per-attempt one first | each attempt, and the whole call |
RetryPolicy::retry_timeouts(true) retries a request that timed out like one that failed to connect. It is off by
default: a request that timed out may have reached the server, so turn it on for requests that are safe to repeat.
use Duration;
use ;
use TimeoutLayer;
use ;
let client = from_service
.layer // each attempt
.layer
.layer; // the whole call
tower's tower::timeout::TimeoutLayer works natively too, and its Elapsed error is a timeout for is_timeout() and
retry_timeouts as well.
reqwest's own timeouts (connect timeout, read timeout) are set on a reqwest::Client, see
Choosing the HTTP client. For a response streamed for minutes (server-sent events), a
total timeout ends the stream; use a connect timeout instead, or none, and bound the silence between events with
Events::idle_timeout.
Logging
trace::TraceLayer (feature tracing) logs every request with tracing:
- a
http.requestspan withhttp.request.method,url.full(without the query, which can hold an API key) andhttp.response.status_code; - an event when the response arrives, with
statusandelapsed_ms:DEBUGbelow500,WARNfor a5xxor a failure to send; - with
RetryPolicy, aDEBUGeventretryingwithattempt,max_retries,delay_msandstatus, and aDEBUGeventnot retrying: ...for aRetry-Afterbeyondmax_retry_afteror a permanent429.
Headers and bodies are never logged. The target is timvw_rust_runtime::trace:
use TraceLayer;
use Client;
// RUST_LOG=timvw_rust_runtime=debug shows every request, RUST_LOG=timvw_rust_runtime=warn only failures.
fmt
.with_env_filter
.init;
let client = default.layer;
WARN http.request{http.request.method=POST url.full=https://api.example.com/v1/items http.response.status_code=503}: timvw_rust_runtime::trace: response status=503 elapsed_ms=41
DEBUG timvw_rust_runtime::trace: retrying attempt=1 max_retries=3 delay_ms=200 status=503
DEBUG http.request{http.request.method=POST url.full=https://api.example.com/v1/items http.response.status_code=200}: timvw_rust_runtime::trace: response status=200 elapsed_ms=380
reqwest's and hyper's own DEBUG events (connecting, pooling) are logged inside the http.request span too.
For more (headers, custom spans and fields, OpenTelemetry), use tower-http's
TraceLayer; it fits the same way:
let client = default.layer;
Middleware
Anything that changes a request or a response is a layer. Three ways to write one, from the shortest:
A header on every request: tower::util::MapRequestLayer with a function, or tower-http's
SetRequestHeaderLayer:
use ;
use ;
let client = default
.layer
.layer;
Several layers at once: tower::ServiceBuilder composes layers top to bottom (the first one sees a request
first), the reverse of chaining Client::layer:
use Duration;
use RetryLayer;
use TraceLayer;
use ;
let client = from_service.layer;
A layer of your own, for work that needs state or await, such as fetching and caching an access token. A layer
makes the service; the service changes the request and calls the service inside it:
use Future;
use Pin;
use Arc;
use ;
use ;
use ;
use ;
async
let client = default.layer;
A layer's service must be Clone + Send; it need not be Sync (on WebAssembly, not Send either:
MaybeSend). Its response body can be any http_body::Body.
Providers
Several services serve one API under different base URLs: OpenAI-compatible providers, a proxy, a local server, a
cloud's deployment of it. ProviderLayer (or provider::ProviderLayer) sends the requests of a generated client to
one of them. To each request for the provider, it:
- replaces the base URL (scheme, host, port and path prefix) with the provider's, keeping the rest of the path and the query;
- rewrites the rest of the path with
map_path, if set (a deployment in the path, say); - sets the headers of
header,sensitive_headerandheaders, each replacing the request's values of that name (the generated authentication included);sensitive_headermarks the value sensitive, for an API key; - sets the query parameters of
query(anapi-version, say), each replacing the request's parameters of that name.
A request is the provider's when its URL is under the provider's base URL, or under the base declared with
rewrite_from: the same scheme, host and port, and a path that starts with the base's at a segment boundary (/v1
holds /v1/models, not /v10). Any other request (a download from another host) goes on unchanged, without the
provider's headers. A generated Configuration.base_path works with it in either of two ways:
base_pathset to the provider: setconfiguration.base_pathto the provider's base URL and useProviderLayer::new(provider_base)for headers, query parameters and paths.base_pathleft as generated: declare it with.rewrite_from(generated_base), and the layer moves every request under it. This suits aClientshared by severalConfigurations, or a crate that hands out a client for a provider.
An OpenAI-compatible provider under another base URL, with its own key and headers:
use AUTHORIZATION;
use ;
let key = from;
let provider = new?
.rewrite_from? // the generated client's base_path
.sensitive_header?
.header?;
let client = default.layer;
// configuration.client = client;
let uri = "https://api.openai.com/v1/chat/completions?stream=true".parse?;
assert_eq!;
# Ok::
A provider that puts a deployment in the path, takes an api-version query parameter and an api-key header:
use ;
let deployment = "gpt-4o";
let provider = new?
.rewrite_from?
.sensitive_header?
.query?
.map_path;
let client = default.layer;
let uri = "https://api.openai.com/v1/chat/completions".parse?;
assert_eq!;
# Ok::
map_path gets the path after the base (/chat/completions), percent-encoded, and returns the one to put after the
provider's base. rewrite_uri shows where a URL goes, without sending. Added last, the layer sees each request first,
so it rewrites once, before the retries. A call's RequestOptions win
over the layer: it does not replace a header or query parameter the options set (with an api-version in both, the
call's is sent, once), and it does not move a call the options sent to a base URL of their own; if that base is the
provider's, the layer still maps the path and adds its headers and query, any other base gets nothing from it. The layer knows no provider by name: presets for one API belong in a crate for that API.
Concurrency and rate limits
tower::limit::ConcurrencyLimitLayer caps requests in flight; the clones of a Client share the cap.
RateLimit cannot be cloned, so put a Buffer in front of it: the buffer shares one limiter between all clones. A
buffer starts a tokio task, so build that client inside a tokio runtime.
use Duration;
use Client;
async
These layers need tower's limit and buffer features in your Cargo.toml, like timeout for TimeoutLayer:
tower = { version = "0.5", features = ["limit", "buffer", "timeout"] }.
Choosing the HTTP client
Client::from_reqwest sends with a reqwest::Client you built (a proxy, a custom TLS setup, connect and read timeouts,
pool settings), with the default retries; Client::from_service(ReqwestService::new(reqwest_client)) without them:
use Duration;
use Client;
let reqwest_client = builder
.connect_timeout
.proxy
.build
.unwrap;
let client = from_reqwest;
Client::from_service sends with any tower service from http::Request<Body> to http::Response<B>, as is, with no
retries added: a hyper client, a client for another runtime, or a function that answers without a network, for tests:
use ;
let client = from_service;
Without the reqwest feature (and rustls or native-tls), the crate does not depend on reqwest, and
Client::default() fails every request: set a service with Client::from_service.
Your own types and server-sent events
A generated function decodes to the spec's models. To send and decode types of your own (fewer fields, a provider
whose replies differ, serde_json::Value), call the client directly: the same layers apply (retries, logging).
Response::events() reads a text/event-stream body, and .json::<T>() decodes each event's data::
use StreamExt;
use Deserialize;
use ;
async
data: [DONE] or the end of the body ends the stream. A payload that does not decode yields an error whose
is_decode() is true, and the stream goes on. events() alone gives each sse::Event undecoded (event:, data:,
id:), and sse::Decoder is the parser, for a body read some other way. A generated client's typed event streams use
the same parser.
A stream can stall without closing: the server or a proxy keeps the connection open and sends nothing.
events().idle_timeout(duration) ends it with an error (is_timeout() is true) when no bytes arrive for duration;
every chunk, a : keep-alive comment too, restarts the clock, and the whole stream has no limit (feature time, part
of retry):
use Duration;
use StreamExt;
async
Uploads and downloads
An Upload is something to send: a request body (.body(upload)) or a multipart part (multipart::Part::upload).
A generated client takes one for every file parameter (and a multipart union's file variant holds one), and a
PathBuf, a &Path, a Vec<u8> or Bytes converts into one. Two uploads are equal (PartialEq) when they send the
same thing: equal bytes, the same path, or the same stream (an upload and its clones).
| Made with | Length | Sent again by a retry |
|---|---|---|
Upload::bytes(..), Vec<u8>, Bytes, &'static [u8] |
known | yes, without a copy |
Upload::path(path), Upload::file(path).await, PathBuf, &Path |
from the file's metadata | yes: the file is opened again |
Upload::from_fn(|| stream) |
.len(n) |
yes: a fresh stream per attempt |
Upload::reader(reader), Upload::stream(stream) |
.len(n) |
no, unless .buffer_for_retry(limit) holds it |
- A file is streamed from disk with tokio, in chunks, never read into memory. Its part is named after the file and
typed from its extension (
photo.png,image/png);.file_name(..)and.mime(..)set others, and a part of an upload without a name is namedfile. - When the length of the body is known, which for a multipart form means the length of every part, it is sent with a
Content-Length, not chunked: some servers refuse chunked uploads.Upload::resolve().awaitreads a path's length (Upload::fileisUpload::path(..).resolve(), and fails at once on a missing file); generated clients resolve every upload. .slice(range)sends part of a file (a seek, no copy), of bytes or of a stream: for APIs that take a large file in parts..buffer_for_retry(limit)letsresolveread a stream or a reader into memory when it is no longer thanlimit, so that a retry can send it again; a longer one is sent once.
use ;
use ;
async
A response body can go to a file, or be read as a tokio AsyncRead. Response::save_to(path) streams it into a
temporary file in the directory of path, flushes it to disk and renames it to path: path holds the whole body or
is left as it was, never half a download. It returns the number of bytes written; a failure to write is an Error
whose is_io() is true:
use ;
async
Files, readers and downloads are feature fs (on by default; a generated crate with a file parameter turns it on).
Credentials
A generated Configuration keeps its tokens, basic-auth password and api key in
secrecy's SecretString, which this crate re-exports (timvw_rust_runtime::secrecy,
SecretString and ExposeSecret). A SecretString is made from a String or a &str, its Debug is redacted,
and it is zeroed when dropped. bearer_auth, basic_auth and sensitive_header take the exposed value and mark the
header sensitive, so the Debug of the request's headers leaves it out:
use ;
let key = from;
assert_eq!;
let request = default
.request
.sensitive_header
.build?;
assert!;
assert!;
# Ok::
Headers and query parameters for one call
A layer changes every request. RequestOptions holds extra headers and query parameters, and another base URL, for
one call; every operation of a generated client takes it (.options(&options) on its builder, or its last argument
without builders), and RequestBuilder::options applies it to a request of your own:
use ;
async
- A header of the options replaces every value the request has under its name, whatever set it: a generated header
parameter,
Authorization,Content-Type,Accept. Generated clients apply the options last, after everything else.RequestOptions::headersets one value (a second call for the same name replaces it);headersmerges aHeaderMap, keeping several values of one name. - Query parameters are appended after the request's own: a name the request already has is sent twice.
headerandquerycheck their input at once and return anErrorwhoseis_builder()is true for an invalid header name or value, or a query that is not a flat list of pairs.base_urlsends the call to another base: the scheme, host, port and path prefix of the base the request was built on are replaced, and the operation's path and the query are kept. Generated code declares itsConfiguration.base_pathwithRequestBuilder::base, sohttps://api.example.com/v1/itemswithbase_url("https://eu.example.com/v2")goes tohttps://eu.example.com/v2/items. On a request of your own withoutbase, the whole path is kept. There is no per-call path: the operation decides it.- The options do not change the body. One value can be cloned, or reused for many calls.
- They win over a
ProviderLayer. - A header value marked with
HeaderValue::set_sensitivestays sensitive (hidden fromDebugoutput).
Errors
A request that fails to send is an Error:
| Method | True when |
|---|---|
is_builder() |
the request could not be built (an invalid URL, header or body) |
is_request() |
the service failed; is_timeout() and is_connect() narrow it down |
is_body() |
reading the response body failed |
is_decode() |
the response could not be decoded (JSON) |
is_io() |
a file could not be read or written: a missing upload, a failed save_to |
source() is the error of the service or layer that failed: a reqwest::Error, a timeout, or your own. A
generated client wraps it as apis::Error::Http(error). A response with an error status is not an Error here; the
generated client turns it into apis::Error::ResponseError.
WebAssembly
The crate, and the clients generated with it, build for wasm32-unknown-unknown (a browser, a web worker, Node.js,
Cloudflare Workers: any JavaScript host). Everything that differs is behind #[cfg(target_family = "wasm")], so the
same Cargo.toml works for both:
# Natively and on WebAssembly. On WebAssembly `rustls`/`native-tls` add nothing (the host does TLS), and `fs` adds only
# `Upload::path` and `From<PathBuf>`, which fail when sent.
= { = "0.4", = false, = ["reqwest", "retry"] }
default-features = true builds too. On WebAssembly:
- Sending. reqwest sends with the host's
fetch.fetchtakes a request body whole, so a streamed body (anUpload::stream, a multipart form with one) is read into memory first; responses still stream as they arrive. Proxies and TLS settings belong to the host, andfetchhas no timeout: addtimeout::TimeoutLayer, which waits with the host'ssetTimeout(tower'sTimeoutLayerneeds tokio's timer and does not work there). A request that times out is dropped, and reqwest aborts itsfetch. Any other layer works. - Retries wait as natively: exponential backoff with jitter,
Retry-Afterandretry-after-ms(an HTTP date is read against the host's clock,Date.now()). The timer is the host'ssetTimeout(gloo-timers) and deadlines useperformance.now()(web-time), asstd::time::Instant::now()panics there. A failure tofetchis not retried:fetchdoes not tell a failure to connect from one after the request was sent (or a CORS refusal), soError::is_connect()is alwaysfalse. - Server-sent events work,
Events::idle_timeoutwith the host's timer;TraceLayermeasures withperformance.now(). SendandSync. The host's futures and streams cannot leave its thread, so on WebAssembly nothing here needs to beSendorSync: the bounds ofClient::from_service,Client::layer,Body::wrap,Body::wrap_stream,Upload::stream,Upload::from_fn,Events::from_bytesand the layers areMaybeSendandMaybeSync:SendandSyncnatively, nothing on WebAssembly. Use them in your own bounds to write code that builds for both.HttpServiceis aLocalBoxCloneServicethere (tower'sBoxCloneSyncServicenatively), andClient,Body,Responseand the futures ofsendare notSend: run them withwasm_bindgen_futures::spawn_local, not on another thread.- No files. There is no file system:
Upload::file,Upload::reader,Response::save_toandResponse::into_async_readare not there, and anUpload::path(or aPathBufgiven to a generated client) fails with an I/O error of kindUnsupportedwhen it is resolved or sent. SendUpload::bytes,Upload::streamorUpload::from_fn; read a response withbytes,bytes_streamorevents.
tests/wasm.rs runs in Node.js with wasm-bindgen-test-runner (see the CI workflow).
Features
| Feature | Default | What it adds |
|---|---|---|
rustls |
yes | reqwest with rustls, as Client::default() (on WebAssembly: reqwest, the host does TLS) |
native-tls |
no | reqwest with the platform's TLS (on WebAssembly: reqwest, the host does TLS) |
reqwest |
via rustls or native-tls |
ReqwestService, Client::from_reqwest |
retry |
yes | retry::RetryLayer and retry::RetryPolicy (sleep with the time timer, read Retry-After dates with httpdate), and retries in Client::default(); turns on time |
time |
via retry |
timeout::TimeoutLayer, sse::Events::idle_timeout and the sleeps of retries: tokio's timer, the host's setTimeout on WebAssembly |
fs |
yes | file and reader uploads (Upload::path, Upload::file, Upload::reader), Response::save_to and Response::into_async_read (tokio); on WebAssembly only Upload::path and From<PathBuf>, which fail when sent |
tracing |
no | trace::TraceLayer, and a log event per retry |
A generated crate forwards native-tls, rustls, retry and tracing under the same names, turns on fs when it
has a file parameter and time when it has typed event streams (their idle_timeout), and builds for WebAssembly as
it is.
What the generated code uses
The generated functions build requests with RequestBuilder (query, header, sensitive_header, bearer_auth,
basic_auth, json, form, body, multipart, options, build) and read responses with Response (status, headers, text, bytes,
bytes_stream): the methods of reqwest's types of the same names. multipart::Form encodes multipart bodies, and
Upload (resolve, multipart::Part::upload) carries file parameters.
License
MIT OR Apache-2.0.