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.2"
= { = "1", = ["derive"] }
= { = "1", = ["macros", "rt-multi-thread"] }
= "0.7"
Aliases on crates.io: typed-fetch, api-fetch — pub use better_fetch::*.
Optional features:
= { = "0.2", = ["tower", "validate", "multipart"] }
Quick start
use ;
use Deserialize;
async
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+?;Error::api_json()to parse JSON error bodies from APIs;Error::hook()fromon_request/on_responsehooks. - Typed endpoints —
Endpointtrait +client.call::<E>()→EndpointRequestBuilderwith typedsend_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
| 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 |
.json_parser |
Custom Bytes → Value parser (feature json; see below) |
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
use ;
use Method;
use Deserialize;
;
async
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, etc.).
Production pattern: wrap the inner service with tower::buffer::Buffer (its worker is spawned on the Tokio runtime), then pass the buffered service to the client. See examples/tower_stack.
ServiceBackend holds a Mutex around the boxed service, so concurrent requests still take turns at the transport lock. When you do not need Tower middleware, use the default reqwest backend (no transport mutex). Do not stack max_in_flight and ConcurrencyLimitLayer at the same numeric limit without intent.
Response bodies and size limits
Every response is read fully into memory (Bytes) before you get a Response. This fits typical JSON APIs. It is not a streaming client: large downloads, chunked bodies, or custom size limits should use reqwest (or another backend) directly. Error types may clone the body for debugging (Error::Http, Error::Deserialize).
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 |
|---|---|
reqwest, json, rustls-tls (default) |
Async client, JSON, TLS |
native-tls |
Platform 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 |
Reserved better-fetch-macros crate |
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 (reserved) |
License
MIT — see LICENSE. Upstream inspiration: THIRD_PARTY_NOTICES.md.