better-fetch
Typed HTTP client layer on top of reqwest, inspired by @better-fetch/fetch. Independent Rust implementation.
Installation
Pick one crate name (same library):
[]
= "0.3"
= { = "1", = ["derive"] }
= { = "1", = ["macros", "rt-multi-thread"] }
= "0.7"
Aliases on crates.io: typed-fetch, api-fetch — pub use better_fetch::*.
Optional features (defaults: json, rustls-tls):
= { = "0.3", = ["tower", "validate", "multipart"] }
Minimal build (pick one TLS feature — do not enable rustls-tls and native-tls together):
= { = "0.3", = false, = ["json", "rustls-tls"] }
Quick start
Flexible requests with .get() — string paths, typed JSON response:
use ;
use Deserialize;
async
For compile-time route definitions (method, path, params, query, response), see Typed endpoint below.
Highlights
- Builder API —
Client::builder(), per-request.timeout(),.retry(),.auth(), headers, JSON body. - Retries — linear, exponential, or
count;Retry-After, jitter, customshould_retry; default retry on 408/429/502/503/504. - Hooks & plugins — compose client and plugin hooks; optional
LoggerPlugin(requires atracingsubscriber in your app). - Errors —
Result+?;TransportKindon transport failures;Error::api_json()to parse JSON error bodies from APIs;Error::hook()fromon_request/on_responsehooks. - Typed endpoints —
Endpointtrait +client.call::<E>()with typedparams/querystructs andsend_json(). - Testing — inject
ClientBuilder::backend(Arc<dyn HttpBackend>). - Cancellation —
CancellationTokenper request; cooperative abort during requests and retry backoff. - Throw mode —
throw_on_error(true)makessend()returnErron non-2xx (like upstreamthrow: true). - Form & multipart —
.form([...])for url-encoded bodies;.multipart(form)with featuremultipart.
Request options
On RequestBuilder (client.get(...) / .post(...)):
| Method | Description |
|---|---|
.param / .params / .params_iter |
Path template :id substitution |
.query / .queries |
Query string (stable insertion order via IndexMap) |
.query_json |
Serialize a value into a query param (feature json) |
.json / .body |
Request body |
.form |
application/x-www-form-urlencoded body |
.multipart |
Multipart form (feature multipart) |
.timeout / .retry |
Per-request overrides |
.auth / .bearer_token |
Per-request auth |
.cancellation_token |
Cancel in-flight request + retry sleeps |
.throw_on_error |
send() returns Err on non-2xx when true |
.send / .send_json |
Execute request (send_json::<T>() for typed JSON) |
.json_parser |
Custom Bytes → Value parser (feature json; see below) |
On EndpointRequestBuilder (client.call::<E>()):
| Method | Description |
|---|---|
.params(E::Params) |
Typed path parameters (required when E::Params is not ()) |
.query(E::Query) |
Typed query struct or IndexMap<String, QueryValue> |
.header / .bearer_token / .cancellation_token / .throw_on_error |
Same as RequestBuilder |
.send / .send_json |
Execute; send_json() returns E::Response |
Custom JSON parsing
By default, JSON responses deserialize in one step (Bytes → T via serde_json::from_slice).
ClientBuilder::json_parser (or per-request .json_parser) uses two steps: your function returns serde_json::Value, then the library maps to T. Use this for BOM stripping or payload normalization:
use ;
use Bytes;
let client = new
.base_url?
.json_parser
.build?;
For maximum performance on a single response, skip a global parser and use Response::into_json_with for a direct Bytes → T closure.
Cancellation
use ;
use Duration;
async
Throw on HTTP error
// Default: Ok(Response) even for 404
let response = client.get.send.await?;
// Like upstream throw: true
let err = client
.get
.throw_on_error
.send
.await
.unwrap_err;
Typed endpoint
Typed endpoints bind HTTP method, path template, and response type at compile time.
Path and query parameters are typed via E::Params / E::Query structs — use
.params()
and .query().
use ;
use Method;
use Deserialize;
define_params!;
;
async
With the macros feature, use #[derive(EndpointParamsDerive)] and #[derive(EndpointQueryDerive)]
instead of define_params! / impl_serde_endpoint_query!.
Typed query example:
use ;
use Serialize;
impl_serde_endpoint_query!;
// impl Endpoint { type Query = ListQuery; ... }
// client.call::<ListTodos>().query(ListQuery { user_id: Some(1) }).send_json().await?;
.get() / .post() remain available for ad-hoc requests; only client.call::<E>() uses the typed builder.
Form and multipart
// URL-encoded form
client
.post
.form
.send
.await?;
// Multipart (feature "multipart")
let form = new.text;
client.post.multipart.send.await?;
Note: automatic retry is not supported with .multipart() (the body cannot be replayed). Use .form, JSON, or raw bytes if you need retries.
Client builder
ClientBuilder::build() requires .base_url(...) — otherwise Error::MissingBaseUrl.
use ;
use Duration;
let client = new
.base_url?
.retry
.build?;
Concurrency limits
ClientBuilder::max_in_flight uses a tokio semaphore in the core client (counts retries as in-flight work). The tower feature’s ConcurrencyLimitLayer is a separate transport-level cap. Use one of these at a given limit unless you intentionally want two stacked caps (e.g. app-wide budget + per-host transport limit).
Tower transport (feature = "tower")
Wire a custom transport with ClientBuilder::http_service, http_service_boxed, or transport_stack. Stack helpers live in better_fetch::tower::stack (build, ConcurrencyLimitLayer, with_buffer, etc.).
ServiceBackend clones the boxed Tower stack per request, so concurrent transport calls can run in parallel. Wrap the inner service with tower::buffer::Buffer only when the inner service is not [Clone] or is expensive to clone (see examples/tower_stack and stack::with_buffer). When you do not need Tower middleware, use the default reqwest backend. Do not stack max_in_flight and ConcurrencyLimitLayer at the same numeric limit without intent.
Response bodies: buffered vs streaming
| API | Use when |
|---|---|
send() → Response |
Typical JSON APIs; full body in memory; hooks and retry predicates can inspect the body |
send_stream() → StreamingResponse |
Large downloads, chunked bodies, incremental processing |
collect() on a stream |
Opt back into the buffered Response API after streaming |
send() always buffers via response.bytes().await in the reqwest backend. send_stream() uses bytes_stream() and does not buffer until you call collect().
Streaming hooks: on_response_stream and on_success_stream run on the streaming path (status + headers only). Buffered on_response / on_success are not invoked for send_stream.
Retry on streams: Status/header retries work as with send(). Custom RetryPolicy::with_should_retry predicates can peek at up to retry_body_peek_bytes (default 64 KiB, bounded by max_response_bytes when set). Without a custom predicate, the body is not read before retrying.
Tower + streaming: transport_stack wires ServiceBackend so send() uses your Tower stack and send_stream() uses the same underlying reqwest::Client. Tower request middleware does not run on the streaming path — use on_request or buffered send() if you need that layer for downloads.
Other notes:
send_jsonis not available on streams — usecollect()theninto_json(), or deserialize from chunks yourself.- Cancellation is cooperative: the stream wakes on cancel when the inner read is pending, not inside a blocking OS read.
collect()withoutmax_response_bytescan use unbounded memory; set a cap for untrusted payloads.
Optional caps: ClientBuilder::max_response_bytes and per-request .max_response_bytes() yield Error::BodyTooLarge when exceeded.
Plugins
Plugin::init receives PreparedRequest with url, path, method, and headers (after auth, before lifecycle hooks). Use it to rewrite URLs or inspect auth headers.
Features
| Feature | Description |
|---|---|
json, rustls-tls (default) |
JSON API + TLS via rustls (reqwest is always the default backend) |
native-tls |
Platform TLS instead of rustls (do not combine with rustls-tls) |
blocking, cookies |
Passed through to reqwest |
multipart |
RequestBuilder::multipart + better_fetch::multipart re-export |
schema / openapi |
schemars registry, strict routes, and OpenAPI 3.0 export |
tower / tower-http |
Tower Service transport stack (better_fetch::tower) |
validate |
Response validation with garde (send_json_validated) |
macros |
define_params!, endpoint!, #[derive(EndpointParamsDerive)], #[derive(EndpointQueryDerive)] |
See CHANGELOG.md for release notes.
Examples
Crates in this repository
| crates.io | Role |
|---|---|
| better-fetch | Main library |
| typed-fetch | Re-export alias |
| api-fetch | Re-export alias |
| better-fetch-macros | Proc-macros for typed endpoint params/query |
License
MIT — see LICENSE. Upstream inspiration: THIRD_PARTY_NOTICES.md.