fetch-kit 0.2.0

Resilient HTTP client for Rust — retry, circuit breaker, connection pooling, and typed JSON helpers built on reqwest
Documentation

fetch-kit

Rust License docs.rs crates.io

Resilient HTTP client for Rust — retry, circuit breaker, connection pooling, and typed JSON helpers built on reqwest, with a native middleware stack that mirrors the reqwest-middleware shape.

Naming: previously fetchkit; renamed to fetch-kit (estate convention) because fetchkit on crates.io belongs to an unrelated project (maintained by everruns). Published as fetch-kit.

Features

  • Native middleware stack — Middleware trait + ClientWithMiddleware chain with reqwest-middleware-compatible signatures, no extra dependency
  • Automatic retries with exponential backoff + jitter (estate loop-retry semantics)
  • Typed JSON helpers — get_json / post_json deserialize responses directly
  • Fallback fetching — try a primary URL, fall back to another on failure
  • Circuit breaker (opt-in) — stop hammering a dead service
  • Bundled middleware — throttle (per-host token bucket), per-request timeout, base-URL prefixing
  • Connection pooling — inherits reqwest's connection pool

Quick Start

use fetch_kit::Client;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .base_url("https://api.example.com")
        .timeout(Duration::from_secs(10))
        .retries(5)
        .build();

    let user: serde_json::Value = client.get_json("/users/1").await?;
    println!("{user:#}");

    Ok(())
}

Middleware

fetch_kit::middleware::Middleware is the composition point. The trait shape mirrors reqwest-middleware 0.5, so migrating an existing middleware is an import-path swap:

use async_trait::async_trait;
use fetch_kit::middleware::{Middleware, Next, Result};
use http::Extensions;
use reqwest::{Request, Response};

pub struct AuthMiddleware { token: String }

#[async_trait]
impl Middleware for AuthMiddleware {
    async fn handle(
        &self,
        mut req: Request,
        extensions: &mut Extensions,
        next: Next<'_>,
    ) -> Result<Response> {
        req.headers_mut().insert(
            "authorization",
            format!("Bearer {}", self.token).parse().unwrap(),
        );
        next.run(req, extensions).await
    }
}

Build a chain with middleware::ClientBuilder (wrapping any reqwest::Client) or register middleware on the default stack with ClientBuilder::with_middleware (user middleware run outermost — see below):

use fetch_kit::ClientBuilder;

let client = ClientBuilder::new()
    .with_middleware(AuthMiddleware { token: "tok".into() })
    .build();

Ordering semantics

Middleware wrap the transport in an onion. The middleware registered first is the outermost layer — it observes the request first and the response last:

ClientBuilder::new(client)
    .with(AuthMiddleware)       // 1st registered → outermost
    .with(RetryMiddleware)      // 2nd registered
    .build();

request  →  Auth  →  Retry  →  transport
response ←  Auth  ←  Retry  ←  ╹

Inner middleware therefore see every attempt an outer retry-style middleware issues; outer middleware see only the final outcome. The Next::run continuation passes the (possibly rewritten) request inward; returning without calling it short-circuits everything below.

Bundled middleware

The core trait and chain are always available; each bundled implementation sits behind its own feature:

Middleware Feature Behavior
RetryMiddleware retry (default-on) Retries transport timeouts/connect errors and transient statuses (408, 429, 5xx except 501) with loop-retry exponential backoff + 10% jitter. Exhausted retryable statuses return the final response unchanged.
CircuitBreakerMiddleware circuit-breaker Short-circuits with FetchError::CircuitOpen when the estate breaker is open; records every attempt.
ThrottleMiddleware throttle Per-authority token bucket: sustained throughput never exceeds rate req/s, bursts up to burst_capacity.
TimeoutMiddleware timeout Bounds each request (and everything downstream of it) to a wall-clock duration; surfaces FetchError::Timeout(d).
BaseUrlMiddleware base-url Prefixes a base URL onto relative requests (marker host fetch-kit.relative, built via BaseUrlMiddleware::relative).

Built-ins are defaults; middleware are composition points

The ClientBuilder knobs configure the built-in stack — retries / retry_bounds shape the default retry middleware, with_breaker inserts the breaker, base_url resolves paths eagerly. The default composition (ordering: first = outermost) is:

[user middleware…] → retry → breaker → transport
  • User middleware wrap the built-ins (registered via ClientBuilder::with_middleware): they observe each logical request once, with final outcomes.
  • The breaker sits inside the retry loop: every HTTP attempt records individually, so a flaky endpoint that burns retries trips the breaker that much sooner — and a short-circuit (CircuitOpen) is a middleware error, which retries never mask.

This is fetch-kit's differentiator over bare reqwest-middleware: the resilience stack works out of the box, and the same pieces are available as middleware when you want custom composition (e.g. throttle outside retry, timeout inside it).

Request-scoped extensions

Carry typed, per-request data through the middleware chain (and across retry attempts) with RequestBuilder::with_extension:

#[derive(Clone)]
struct RequestId(u64);

let value: serde_json::Value = client
    .get("/users/1")
    .with_extension(RequestId(42))
    .json_response()
    .await?;
// inside Middleware::handle:  extensions.get::<RequestId>()

Composing at the middleware level

use fetch_kit::middleware::{
    ClientBuilder, RetryConfig, RetryMiddleware, ThrottleMiddleware, TimeoutMiddleware,
};
use std::time::Duration;

let raw = ClientBuilder::new(reqwest::Client::new())
    .with(ThrottleMiddleware::new(50.0, 10))            // outermost
    .with(RetryMiddleware::new(RetryConfig::default()))
    .with(TimeoutMiddleware::new(Duration::from_secs(5)))
    .build();

Circuit Breaker

Enable the circuit-breaker feature to wrap requests in a breaker that automatically rejects calls after repeated failures.

use fetch_kit::Client;
use breaker::{CircuitBreaker, CircuitBreakerConfig};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let breaker = CircuitBreaker::new(CircuitBreakerConfig::standard());

    let client = Client::builder()
        .base_url("https://api.example.com")
        .with_breaker(breaker)
        .build();

    // When the circuit opens, requests will return Err(FetchError::CircuitOpen).
    Ok(())
}

Request Builder

use fetch_kit::Client;
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .base_url("https://api.example.com")
        .default_header("Authorization", "Bearer token")
        .build();

    // Fluent request builder
    let value: serde_json::Value = client
        .get("/users/1")
        .timeout(Duration::from_secs(5))
        .send()
        .await?
        .json()
        .await?;

    // Or deserialize directly
    let user: serde_json::Value = client
        .post("/users")
        .json(&serde_json::json!({"name": "Alice"}))
        .json_response()
        .await?;

    Ok(())
}

Migrating from 0.1.x

  • Your own middleware: the trait shape is unchanged — swap use reqwest_middleware::{Middleware, Next, Error, Result} for use fetch_kit::middleware::{Middleware, Next, Error, Result}.
  • Client::inner() now returns &fetch_kit::middleware::ClientWithMiddleware (fetch-kit's own chain, not reqwest-middleware's). from_parts takes that type.
  • Request-scoped data moves from request fields to the extensions argument of Middleware::handle (populated via RequestBuilder::with_extension) — this matches how reqwest-middleware passes extensions, since reqwest does not expose request extensions publicly.
  • Backoff curve: retries now use the estate loop-retry timing (exponential, ×2 growth, ≤10% jitter) instead of reqwest-retry's full jitter; retries/retry_bounds semantics are unchanged.
  • The retry feature (default-on) gates the built-in retry; disable default features to drop it.

Comparison with raw reqwest

raw reqwest fetch-kit
Retries manual or separate middleware built-in (configurable) or RetryMiddleware
Middleware none (tower only) Middleware trait, reqwest-middleware shape
Throttling write it yourself ThrottleMiddleware (per-host token bucket)
JSON helpers resp.json::<T>() each time get_json / post_json
Fallback write it yourself fetch_with_fallback
Timeout per-client or per-request builder default (30s) + TimeoutMiddleware

License

Licensed under either of MIT or Apache-2.0 at your option.