better-fetch 0.2.2

Typed HTTP client layer on top of reqwest — inspired by @better-fetch/fetch
Documentation
better-fetch-0.2.2 has been yanked.

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):

[dependencies]
better-fetch = "0.2"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-util = "0.7"

Aliases on crates.io: typed-fetch, api-fetchpub use better_fetch::*.

Optional features:

better-fetch = { version = "0.2", features = ["tower", "validate", "multipart"] }

Quick start

use better_fetch::{Client, Result};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Todo {
    user_id: u64,
    id: u64,
    title: String,
    completed: bool,
}

#[tokio::main]
async fn main() -> Result<()> {
    let client = Client::new("https://jsonplaceholder.typicode.com")?;

    // send() returns Response (any status); json() fails on non-2xx
    let todo: Todo = client
        .get("/todos/:id")
        .param("id", 1)
        .send()
        .await?
        .json()
        .await?;

    // Or in one step:
    let todo: Todo = client.get("/todos/:id").param("id", 1).send_json().await?;

    // Hot path (no extra await): client.get(...).send().await?.into_json()
    println!("{todo:#?}");
    Ok(())
}

Highlights

  • Builder APIClient::builder(), per-request .timeout(), .retry(), .auth(), headers, JSON body.
  • Retries — linear, exponential, or count; Retry-After, jitter, custom should_retry; default retry on 408/429/502/503/504.
  • Hooks & plugins — compose client and plugin hooks; optional LoggerPlugin (requires a tracing subscriber in your app).
  • ErrorsResult + ?; Error::api_json() to parse JSON error bodies from APIs; Error::hook() from on_request / on_response hooks.
  • Typed endpointsEndpoint trait + client.call::<E>()EndpointRequestBuilder with typed send_json().
  • Testing — inject ClientBuilder::backend(Arc<dyn HttpBackend>).
  • CancellationCancellationToken per request; cooperative abort during requests and retry backoff.
  • Throw modethrow_on_error(true) makes send() return Err on non-2xx (like upstream throw: true).
  • Form & multipart.form([...]) for url-encoded bodies; .multipart(form) with feature multipart.

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 BytesValue parser (feature json; see below)

Custom JSON parsing

By default, JSON responses deserialize in one step (BytesT 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 better_fetch::{ClientBuilder, Result};
use bytes::Bytes;

let client = ClientBuilder::new()
    .base_url("https://api.example.com")?
    .json_parser(|body: &Bytes| {
        let slice = body.strip_prefix(b"\xef\xbb\xbf").unwrap_or(body);
        serde_json::from_slice(slice).map_err(|e| e.to_string())
    })
    .build()?;

For maximum performance on a single response, skip a global parser and use Response::into_json_with for a direct BytesT closure.

Cancellation

use better_fetch::{CancellationToken, Client, Result};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<()> {
    let token = CancellationToken::new();
    let client = Client::new("https://httpbin.org")?;

    let handle = tokio::spawn({
        let token = token.clone();
        let client = client.clone();
        async move {
            client
                .get("/delay/10")
                .cancellation_token(token)
                .send()
                .await
        }
    });

    tokio::time::sleep(Duration::from_millis(100)).await;
    token.cancel();

    assert!(handle.await.unwrap().unwrap_err().is_cancelled());
    Ok(())
}

Throw on HTTP error

// Default: Ok(Response) even for 404
let response = client.get("/missing").send().await?;

// Like upstream throw: true
let err = client
    .get("/missing")
    .throw_on_error(true)
    .send()
    .await
    .unwrap_err();

Typed endpoint

use better_fetch::{Client, Endpoint, Result};
use http::Method;
use serde::Deserialize;

struct GetTodo;
impl Endpoint for GetTodo {
    const METHOD: Method = Method::GET;
    const PATH: &'static str = "/todos/:id";
    type Response = Todo;
    type Params = ();
    type Query = ();
}

#[derive(Deserialize)]
struct Todo { id: u64, title: String }

async fn example(client: &Client) -> Result<()> {
    let todo = client
        .call::<GetTodo>()
        .param("id", 1)
        .send_json()
        .await?;
    Ok(())
}

Form and multipart

// URL-encoded form
client
    .post("/login")
    .form([("user", "alice"), ("pass", "secret")])
    .send()
    .await?;

// Multipart (feature "multipart")
let form = better_fetch::multipart::Form::new().text("file", "hello");
client.post("/upload").multipart(form).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 better_fetch::{ClientBuilder, RetryPolicy};
use std::time::Duration;

let client = ClientBuilder::new()
    .base_url("https://api.example.com")?
    .retry(RetryPolicy::exponential(3, Duration::from_secs(1), Duration::from_secs(30)))
    .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

cargo run -p better-fetch --example basic
cargo run -p better-fetch --example tower_stack --features tower,json
cargo run -p better-fetch --example multipart --features multipart
cargo run -p better-fetch --example retry
cargo test -p better-fetch
cargo test -p better-fetch --features default,validate,tower,multipart

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.