diff --git a/.spelling b/.spelling
index a5b75af48..82bf70418 100644
--- a/.spelling
+++ b/.spelling
@@ -42,7 +42,6 @@ C-RELNOTES
C-RW-VALUE
C-SERDE
C-SMART-PTR
-CAS
CLI
CONTRIBUTING.md
CPUs
@@ -126,7 +125,6 @@ RMWs
RPC
Rc
Redis
-Reqwest
Resize
Reusability
Rustdoc
@@ -561,3 +559,10 @@ rustls
TLS
verifier
Verifier
+customizable
+durations
+unencrypted
+globals
+seatbelt
+CAS
+runnable
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39afd8b22..b6631cdff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Please see each crate's change log below:
- [`data_privacy`](./crates/data_privacy/CHANGELOG.md)
- [`data_privacy_macros`](./crates/data_privacy_macros/CHANGELOG.md)
- [`data_privacy_macros_impl`](./crates/data_privacy_macros_impl/CHANGELOG.md)
+- [`fetch`](./crates/fetch/CHANGELOG.md)
- [`fetch_hyper`](./crates/fetch_hyper/CHANGELOG.md)
- [`fetch_options`](./crates/fetch_options/CHANGELOG.md)
- [`fetch_tls`](./crates/fetch_tls/CHANGELOG.md)
diff --git a/Cargo.lock b/Cargo.lock
index a1bac8b04..124d9f796 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -91,6 +91,34 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "argh"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033"
+dependencies = [
+ "argh_derive",
+ "argh_shared",
+]
+
+[[package]]
+name = "argh_derive"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d"
+dependencies = [
+ "argh_shared",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "argh_shared"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1"
+
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -679,6 +707,16 @@ dependencies = [
"thiserror",
]
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1069,6 +1107,57 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+[[package]]
+name = "fetch"
+version = "0.10.0"
+dependencies = [
+ "alloc_tracker",
+ "anyspawn",
+ "argh",
+ "bytes",
+ "bytesbuf",
+ "criterion",
+ "data_privacy",
+ "fastrand",
+ "fetch_hyper",
+ "fetch_options",
+ "fetch_tls",
+ "fundle",
+ "futures",
+ "http",
+ "http_extensions",
+ "hyper",
+ "hyper-util",
+ "insta",
+ "layered",
+ "mutants",
+ "native-tls",
+ "ohno 0.3.4",
+ "opentelemetry",
+ "opentelemetry-semantic-conventions",
+ "opentelemetry-stdout",
+ "opentelemetry_sdk",
+ "rstest",
+ "rustls",
+ "rustls-platform-verifier",
+ "seatbelt",
+ "seatbelt_http",
+ "serde",
+ "serde_json",
+ "smallvec",
+ "static_assertions",
+ "templated_uri",
+ "testing_aids",
+ "thread_aware",
+ "tick",
+ "tokio",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
+ "uuid",
+ "wiremock",
+]
+
[[package]]
name = "fetch_hyper"
version = "0.3.0"
@@ -1900,6 +1989,55 @@ dependencies = [
"jiff-tzdb",
]
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn",
+]
+
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -2879,6 +3017,18 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -2888,6 +3038,33 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
+
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -3105,6 +3282,22 @@ dependencies = [
"libc",
]
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "similar"
version = "2.7.0"
@@ -3174,6 +3367,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+[[package]]
+name = "symlink"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+
[[package]]
name = "syn"
version = "2.0.117"
@@ -3586,6 +3785,19 @@ dependencies = [
"tracing-core",
]
+[[package]]
+name = "tracing-appender"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
+dependencies = [
+ "crossbeam-channel",
+ "symlink",
+ "thiserror",
+ "time",
+ "tracing-subscriber",
+]
+
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -3904,6 +4116,15 @@ dependencies = [
"semver",
]
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "widestring"
version = "1.2.1"
diff --git a/Cargo.toml b/Cargo.toml
index 284a0829a..9963ce991 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,6 +34,7 @@ data_privacy = { path = "crates/data_privacy", default-features = false, version
data_privacy_core = { path = "crates/data_privacy_core", default-features = false, version = "0.1.0" }
data_privacy_macros = { path = "crates/data_privacy_macros", default-features = false, version = "0.10.0" }
data_privacy_macros_impl = { path = "crates/data_privacy_macros_impl", default-features = false, version = "0.10.0" }
+fetch = { path = "crates/fetch", default-features = false, version = "0.10.0" }
fetch_hyper = { path = "crates/fetch_hyper", default-features = false, version = "0.3.0" }
fetch_options = { path = "crates/fetch_options", default-features = false, version = "0.2.0" }
fetch_tls = { path = "crates/fetch_tls", default-features = false, version = "0.2.1" }
@@ -63,6 +64,7 @@ ahash = { version = "0.8.4", default-features = false }
alloc_tracker = { version = "0.5.9", default-features = false }
allocator-api2 = { version = "0.4.0", default-features = false }
anyhow = { version = "1.0.100", default-features = false }
+argh = { version = "0.1.13", default-features = false }
async-once-cell = { version = "0.5.0", default-features = false }
base64 = { version = "0.22.0", default-features = false, features = ["alloc"] }
bolero = { version = "0.13.4", default-features = false }
@@ -127,6 +129,7 @@ rstest = { version = "0.26.0", default-features = false }
rustc-hash = { version = "2.1.0", default-features = false }
rustls = { version = "0.23.40", default-features = false }
rustls-pki-types = { version = "1.14.1", default-features = false }
+rustls-platform-verifier = { version = "0.7.0", default-features = false }
serde = { version = "1.0.228", default-features = false }
serde_core = { version = "1.0.228", default-features = false }
serde_json = { version = "1.0.145", default-features = false }
@@ -142,6 +145,7 @@ tower = { version = "0.5.2", default-features = false }
tower-layer = { version = "0.3.3", default-features = false }
tower-service = { version = "0.3.3", default-features = false }
tracing = { version = "0.1.41", default-features = false }
+tracing-appender = { version = "0.2.5", default-features = false }
tracing-subscriber = { version = "0.3.20", default-features = false }
tracing-test = { version = "0.2.6", default-features = false }
trait-variant = { version = "0.1.2", default-features = false }
diff --git a/README.md b/README.md
index 64b4eab80..c6ea0d5d9 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ These are the primary crates built out of this repo:
- [`cachet_service`](./crates/cachet_service/README.md) - Layered service integration for the cachet caching library.
- [`cachet_tier`](./crates/cachet_tier/README.md) - Core cache tier trait and abstractions for building cache backends.
- [`data_privacy`](./crates/data_privacy/README.md) - Mechanisms to classify, manipulate, and redact sensitive data.
+- [`fetch`](./crates/fetch/README.md) - "Universal, composable and resilient HTTP client."
- [`fetch_hyper`](./crates/fetch_hyper/README.md) - Hyper-based HTTP transport utilities for fetch.
- [`fetch_options`](./crates/fetch_options/README.md) - Options types for 'fetch' crate.
- [`fundle`](./crates/fundle/README.md) - Compile-time safe dependency injection for Rust.
diff --git a/crates/fetch/AGENTS.md b/crates/fetch/AGENTS.md
new file mode 100644
index 000000000..d5ba23505
--- /dev/null
+++ b/crates/fetch/AGENTS.md
@@ -0,0 +1,28 @@
+# fetch crate
+
+## Conditional Compilation
+
+**A TLS backend is mandatory to create an `HttpClient` for real network use.**
+The `tokio` runtime alone is not enough — the hyper transport layer is built on top of
+TLS, so both must be enabled together. There are currently two TLS backends, `rustls`
+and `native-tls`, and more may be added. This produces the recurring cfg-guard pattern:
+
+```rust
+// "compile when the tokio runtime is selected AND a TLS backend is enabled"
+#[cfg(all(feature = "tokio", any(feature = "rustls", feature = "native-tls")))]
+```
+
+When adding a new TLS backend, extend the inner `any(...)` accordingly (e.g. add the new
+feature alongside `rustls` and `native-tls`).
+
+Some items are guarded by a single TLS backend plus `test`, for example the TLS error
+label resolution:
+
+```rust
+#[cfg(any(feature = "rustls", test))]
+// ...
+#[cfg(any(feature = "native-tls", test))]
+```
+
+`test-util` is the only escape hatch: it enables `FakeHandler` (canned responses, no
+network), so no TLS backend is needed.
diff --git a/crates/fetch/CHANGELOG.md b/crates/fetch/CHANGELOG.md
new file mode 100644
index 000000000..29a24382d
--- /dev/null
+++ b/crates/fetch/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Changelog
+## [0.10.0] - 2026-06-04
+
+- ✨ Features
+
+ - introduce fetch crate
+
diff --git a/crates/fetch/Cargo.toml b/crates/fetch/Cargo.toml
new file mode 100644
index 000000000..347571847
--- /dev/null
+++ b/crates/fetch/Cargo.toml
@@ -0,0 +1,282 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+[package]
+name = "fetch"
+description = "HTTP client with resilience, observability, and Tokio runtime support."
+version = "0.10.0"
+readme = "README.md"
+keywords = ["http", "client", "async", "resilience", "observability"]
+categories = ["network-programming"]
+
+edition.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository = "https://github.com/microsoft/oxidizer/tree/main/crates/fetch"
+
+[package.metadata.cargo_check_external_types]
+allowed_external_types = [
+ "bytesbuf::mem::global::GlobalPool",
+ "bytesbuf::mem::has_memory::HasMemory",
+ "bytesbuf::mem::memory::Memory",
+ "data_privacy::redaction_engine::RedactionEngine",
+ "fetch_options::*",
+ "fetch_tls::*",
+ "http::*",
+ "http_extensions::*",
+ "seatbelt_http::*",
+ "templated_uri::*",
+ "layered::intercept::InterceptLayer",
+ "layered::service::Service",
+ "opentelemetry::common::KeyValue",
+ "opentelemetry::metrics::meter::Meter",
+ "opentelemetry::metrics::meter::MeterProvider",
+ "recoverable::Recovery",
+ "recoverable::RecoveryInfo",
+ "thread_aware::core::ThreadAware",
+ "tick::clock::Clock",
+ "tower_layer::Layer",
+]
+
+[features]
+default = []
+tls = ["rustls"]
+rustls = [
+ "dep:rustls",
+ "dep:rustls-platform-verifier",
+ "fetch_hyper?/rustls",
+ "fetch_tls/rustls",
+]
+native-tls = [
+ "dep:native-tls",
+ "fetch_hyper?/native-tls",
+ "fetch_tls/native-tls",
+]
+json = ["dep:serde", "http_extensions/json"]
+test-util = ["tick/test-util", "http_extensions/test-util"]
+tokio = [
+ "dep:tokio",
+ "dep:anyspawn",
+ "anyspawn/tokio",
+ "dep:fetch_hyper",
+ "dep:hyper-util",
+ "hyper-util/tokio",
+ "hyper-util/http1",
+ "hyper-util/http2",
+ "tick/tokio",
+ # The aws-lc-rs crypto provider is only pulled in when both `tokio` and
+ # `rustls` are enabled, because it is exclusively used by the Tokio
+ # transport's rustls backend (`tokio::build_tls_backend`).
+ "rustls?/aws-lc-rs",
+]
+
+[dependencies]
+# internal
+anyspawn = { workspace = true, optional = true }
+bytesbuf = { workspace = true }
+data_privacy = { workspace = true }
+fetch_hyper = { workspace = true, optional = true }
+fetch_options = { workspace = true }
+fetch_tls = { workspace = true }
+fundle = { workspace = true }
+http_extensions = { workspace = true }
+layered = { workspace = true, features = [
+ "dynamic-service",
+ "intercept",
+] }
+ohno.workspace = true
+seatbelt = { workspace = true, features = ["timeout", "retry", "breaker", "hedging", "logs", "metrics"] }
+seatbelt_http = { workspace = true, features = ["timeout", "retry", "breaker", "hedging"] }
+templated_uri = { workspace = true, features = ["uuid"] }
+thread_aware = { workspace = true, features = ["derive"] }
+tick = { workspace = true }
+
+# external
+bytes = { workspace = true }
+futures = { workspace = true, features = ["std"] }
+http = { workspace = true }
+opentelemetry = { workspace = true, features = ["futures", "metrics"] }
+opentelemetry-semantic-conventions = { workspace = true, features = [
+ "semconv_experimental",
+] }
+serde = { workspace = true, optional = true }
+smallvec = { workspace = true }
+tracing = { workspace = true }
+
+# tokio runtime transport
+hyper-util = { workspace = true, optional = true, features = ["client-legacy"] }
+tokio = { workspace = true, features = ["rt", "net"], optional = true }
+
+# TLS related crates
+native-tls = { workspace = true, features = ["alpn"], optional = true }
+rustls = { workspace = true, features = [
+ "tls12",
+ "std",
+], default-features = false, optional = true }
+rustls-platform-verifier = { workspace = true, optional = true }
+
+[dev-dependencies]
+# internal
+anyspawn = { path = "../anyspawn", features = ["tokio"] }
+data_privacy = { path = "../data_privacy" }
+fetch_hyper = { path = "../fetch_hyper", features = ["rustls", "native-tls"] }
+fetch_tls = { path = "../fetch_tls", features = ["rustls", "native-tls"] }
+http_extensions = { path = "../http_extensions", features = ["test-util", "json"] }
+ohno = { path = "../ohno", features = ["test-util", "app-err"] }
+testing_aids = { path = "../testing_aids" }
+tick = { path = "../tick", features = [
+ "test-util",
+ "tokio",
+] }
+
+# external
+alloc_tracker = { workspace = true }
+argh = { workspace = true }
+criterion = { workspace = true }
+fastrand.workspace = true
+hyper = { workspace = true, features = ["http2", "client"] }
+hyper-util = { workspace = true, features = ["tokio", "http1", "http2", "client-legacy"] }
+insta = { workspace = true }
+mutants = { workspace = true }
+native-tls = { workspace = true, features = ["alpn"] }
+opentelemetry-stdout = { workspace = true, features = [
+ "metrics",
+], default-features = false }
+opentelemetry_sdk = { workspace = true, features = [
+ "metrics",
+ "testing",
+], default-features = false }
+rstest = { workspace = true }
+rustls = { workspace = true, features = [
+ "tls12",
+ "std",
+ "aws-lc-rs",
+], default-features = false }
+rustls-platform-verifier = { workspace = true }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+static_assertions = { workspace = true }
+tokio = { workspace = true, features = [
+ "macros",
+ "rt-multi-thread",
+ "rt",
+ "net",
+] }
+tracing-appender = { workspace = true }
+tracing-subscriber = { workspace = true, features = ["env-filter"] }
+uuid = { workspace = true }
+wiremock = { workspace = true }
+
+[lints]
+workspace = true
+
+[[example]]
+name = "http_client_fake"
+required-features = ["test-util"]
+
+[[example]]
+name = "http_client_breaker"
+required-features = ["test-util"]
+
+[[example]]
+name = "http_client_resilience"
+required-features = ["test-util"]
+
+[[example]]
+name = "http_client_tokio"
+required-features = ["tokio", "rustls"]
+
+[[example]]
+name = "http_client_native_tls"
+required-features = ["native-tls", "tokio"]
+
+[[example]]
+name = "http_client_custom"
+
+[[example]]
+name = "http_client_custom_pipeline"
+required-features = ["test-util"]
+
+[[example]]
+name = "http_client_telemetry"
+required-features = ["test-util"]
+
+[[example]]
+name = "http_client_customization"
+required-features = ["test-util"]
+
+[[example]]
+name = "http_client_native_tls_mtls"
+required-features = ["native-tls", "tokio"]
+
+[[example]]
+name = "http_client_advanced"
+required-features = ["tokio", "rustls"]
+
+[[example]]
+name = "http_client_api_with_templated_uri"
+required-features = ["test-util", "json"]
+
+[[example]]
+name = "http_client_app"
+required-features = ["tokio", "rustls"]
+
+[[example]]
+name = "http_client_connection_scaling"
+required-features = ["tokio", "rustls"]
+
+[[example]]
+name = "http_client_json"
+required-features = ["test-util", "json"]
+
+[[example]]
+name = "http_client_minimal_pipeline"
+required-features = ["tokio", "rustls"]
+
+[[example]]
+name = "http_client_mtls"
+required-features = ["tokio", "rustls"]
+
+[[example]]
+name = "http_client_pooling"
+required-features = ["test-util"]
+
+[[example]]
+name = "http_client_streaming"
+required-features = ["tokio", "rustls"]
+
+[[bench]]
+harness = false
+name = "pipelines"
+required-features = ["test-util"]
+
+[[bench]]
+harness = false
+name = "http_crate"
+required-features = ["test-util"]
+
+[[test]]
+name = "requests"
+required-features = ["tokio", "rustls", "test-util"]
+
+[[test]]
+name = "uri_handling"
+required-features = ["test-util"]
+
+[[test]]
+name = "thread_aware"
+required-features = ["tokio", "rustls", "test-util"]
+
+[[test]]
+name = "resilience"
+required-features = ["test-util"]
+
+[[test]]
+name = "standard_pipeline"
+required-features = ["test-util"]
+
+[[test]]
+name = "timeout"
+required-features = ["test-util"]
diff --git a/crates/fetch/README.md b/crates/fetch/README.md
new file mode 100644
index 000000000..229874848
--- /dev/null
+++ b/crates/fetch/README.md
@@ -0,0 +1,777 @@
+
+

+
+# Fetch
+
+[](https://crates.io/crates/fetch)
+[](https://docs.rs/fetch)
+[](https://crates.io/crates/fetch)
+[](https://github.com/microsoft/oxidizer/actions/workflows/main.yml)
+[](https://codecov.io/gh/microsoft/oxidizer)
+[](../../LICENSE)
+

+
+
+
+A fast, safe HTTP client that just works.
+
+This crate provides a powerful HTTP client that works with different async runtimes, handles
+security properly by default, and makes testing easy. The [`HttpClient`][__link0] provides a clean API
+for making HTTP requests without worrying about the complex details of modern HTTP.
+
+## Why a new HTTP client?
+
+`fetch` bundles the capabilities real-world services need into a single client, ready to use
+out of the box:
+
+* **Secure, resilient and observable by default**: Strong TLS validation, built-in resilience
+ (retries, circuit breaking, hedging), and OpenTelemetry-compatible observability are
+ pre-configured for real-world use.
+* **Built-in testability**: The `test-util` feature lets you mock HTTP responses without complex
+ setup, making tests fast and deterministic.
+* **Composable pipeline**: Modular request handlers make it easy to add or customize behaviors
+ like logging, metrics, or retries.
+* **Memory efficient**: Uses smart pooling and zero-copy techniques to handle large responses
+ with minimal overhead.
+
+Crucially, `fetch` delivers these features **without forcing a runtime, an I/O implementation, or
+a particular HTTP transport on you**. The request pipeline is built around a *transport handler*
+at its leaf that you can swap out, with everything above it — resilience, observability, routing,
+logging, retries — layered on top. This makes `fetch`:
+
+* **runtime-agnostic**: Tokio works out of the box, or plug in any async runtime and I/O by
+ supplying your own transport handler; and
+* **transport-agnostic**: the transport handler is just a [`RequestHandler`][__link1] that turns a request
+ into a response, so you can keep the bundled hyper transport, wrap a hand-rolled client, or even
+ reuse an existing one like [`reqwest`][__link2].
+
+That makes `fetch` an excellent fit for **libraries that want to stay runtime- and
+transport-agnostic**: they depend on `fetch` for its features while leaving the runtime and
+transport choice to the consuming application, which plugs in whatever it already uses. See the
+[`custom`][__link3] module and [`custom::create_builder`][__link4] for a worked example.
+
+### How does it compare to `reqwest`?
+
+By default both `fetch` and [`reqwest`][__link5] are built on top of the powerful
+[`hyper`][__link6] HTTP implementation. While `reqwest` has been the go-to HTTP
+client for many Rust applications, `fetch` offers a different set of trade-offs that may
+better suit your needs, especially for crates that require resilience and multi-runtime support.
+Unlike `reqwest`, `fetch` is not tied to its default transport at all: you can swap hyper out for
+any transport — including `reqwest` itself — and keep all of `fetch`’s surrounding features.
+
+|Feature|`fetch`|`reqwest`|
+|-------|-------|---------|
+|**Runtime Support**|✅ Tokio **and custom runtimes**|✅ Tokio only|
+|**Custom Transport / IO**|✅ **Built-in** — plug in your own runtime, I/O, or even another HTTP client (e.g. `reqwest`) as the transport|❌ Not supported|
+|**TLS/HTTPS**|✅ Via rustls or native-tls|✅ Via rustls or native-tls|
+|**Resilience**|✅ Built-in and default|❌ Optional, external crates required|
+|**JSON support**|✅ Built-in|✅ Built-in|
+|**Testing tools**|✅ Built-in|❌ Custom, external crates required|
+|**`OTel` Metrics/Logging**|✅ Built-in|❌ Custom implementation needed|
+|**Advanced HTTP Client Features [^1]**|❌ Not yet supported [^2]|✅ Via optional features|
+|**Request Pipeline**|✅ Built-in|❌ Custom, external crates required|
+|**Zero-copy Buffers**|✅ Built-in|❌ Partial, uses `Bytes`|
+|**Linux support**|✅ Full support|✅ Full support|
+
+[^1]: Advanced HTTP client features include things like file uploads, cookies, proxies, and redirects.
+
+[^2]: The features currently missing (cookies, redirects, forms) may be added in future versions as the
+ client matures.
+
+ >
+ > **Note**: If you’re already familiar with `reqwest`, you’ll feel right at home with `fetch`.
+ > The APIs are intentionally similar, with familiar methods like `get()`, `post()`, and `fetch()`. Most
+ > basic HTTP operations follow the same patterns, making it easy to switch between the two libraries.
+
+## Getting Started
+
+This client runs on the Tokio runtime by default. (Other runtimes can be plugged in via a
+custom transport — see the [`custom`][__link7] module.)
+
+```rust
+use fetch::{HttpClient, HttpError, Response, StatusExt};
+
+#[tokio::main]
+async fn main() -> Result<(), HttpError> {
+ // Create a client using the builder
+ let client: HttpClient = HttpClient::new_tokio();
+
+ // Retrieve the response as text
+ let response: Response = client
+ .get("https://example.com")
+ .fetch_text()
+ .await?
+ .ensure_success()?; // Verifies that the response was successful
+
+ println!("response: {}", response.body());
+
+ Ok(())
+}
+```
+
+ >
+ > **Customization**: If you need to customize the HTTP client (e.g., add custom handlers, modify timeouts,
+ > or configure other options), use [`HttpClient::builder_tokio`][__link8] instead of `new_tokio` to access
+ > the full builder API.
+
+## Making Requests
+
+The HTTP client makes it easy to send different types of requests. Use convenient methods like
+[`HttpClient::get`][__link9] and [`HttpClient::post`][__link10] for common operations, and the builder pattern to customize
+your requests.
+
+### GET Requests
+
+```rust
+// Simple GET request
+let response: HttpResponse = client
+ .get("https://www.example.com")
+ .fetch() // Fetch executes the request and returns a response
+ .await?;
+
+```
+
+### POST Requests
+
+```rust
+// POST request with text body
+let response = client
+ .post("https://httpbin.org/post")
+ .text("the exact body that is sent") // Attaches a text body to the request
+ .fetch()
+ .await?;
+
+```
+
+### Handling Complex Requests
+
+The client supports all standard HTTP methods through dedicated methods like [`HttpClient::put`][__link11],
+[`HttpClient::delete`][__link12], and more. For anything else, use [`HttpClient::request`][__link13] with any HTTP method:
+
+```rust
+// Using a custom method
+let response = client
+ .request(Method::PATCH, "https://api.example.com/items/42")
+ .fetch()
+ .await?;
+```
+
+You can customize requests with headers, specific HTTP versions, or by attaching bodies:
+
+```rust
+let response = client
+ .post("https://api.example.com/upload")
+ // Add HTTP headers
+ .header(header::AUTHORIZATION, "Bearer token123")
+ .header(header::CONTENT_TYPE, "application/json")
+ // Set HTTP version
+ .version(Version::HTTP_2)
+ .text("{\"name\": \"document.pdf\"}")
+ .fetch()
+ .await?;
+```
+
+All these methods return a [`HttpRequestBuilder`][__link14] object that lets you customize and then execute your request.
+
+### Handling Multiple Requests to the Same Base URI
+
+If you need to make multiple requests to the same base URI efficiently, use the [`HttpClientBuilder::base_uri`][__link15] builder method.
+This allows you to set a [`BaseUri`][__link16] for all requests, so you don’t have to repeat the base URI each time.
+
+This setting overrides any base URI set in the URI you pass to the request methods.
+
+```rust
+let client = builder
+ .base_uri(BaseUri::from_static("https://example.com/api/v1/")) // Trailing slash is mandatory
+ .build();
+
+let response = client.get("/foo/bar").fetch().await?; // Full URL called by this is `https://example.com/api/v1/foo/bar`
+```
+
+## Handling Responses
+
+When you call [`HttpRequestBuilder::fetch`][__link17], you get an [`HttpResponse`][__link18] with everything about the response -
+the body, status code, headers, and more. Under the hood, `HttpResponse` is just a type alias for
+[`Response`][__link19].
+
+Here’s what you can do with a response:
+
+* Check if it worked: [`HttpResponse::ensure_success`][__link20] returns an error if the status isn’t `2xx`.
+* Look at status codes: [`HttpResponse::status`][__link21] gives you the HTTP status.
+* Read headers: [`HttpResponse::headers`][__link22] lets you access the response headers.
+* Get the body: [`HttpResponse::into_body`][__link23] gives you just the response body.
+* Process the data: Convert the body to different formats using methods like [`HttpBody::into_text`][__link24],
+ [`HttpBody::into_bytes`][__link25], or when the `json` feature is enabled, [`HttpBody::into_json`][__link26].
+
+```rust
+// Make a GET request
+let mut response: HttpResponse = client.get("https://www.example.com").fetch().await?;
+
+// Check if the response was successful
+response = response.ensure_success()?;
+
+// Check the headers
+println!("Headers: {}", response.headers().len());
+
+// Consume the response and extract the body
+let body: HttpBody = response.into_body();
+
+// Process the body as text
+let text: String = body.into_text().await?;
+
+println!("Response body: {}", text);
+```
+
+### Specialized Fetch Methods
+
+Instead of calling [`HttpRequestBuilder::fetch`][__link27] and then converting the response body separately, use these
+convenient shortcut methods:
+
+* [`fetch_text`][__link28]: Gets the response body as a string in one step.
+* [`fetch_bytes`][__link29]: Gets the body as a memory-efficient `BytesView`.
+* [`fetch_json`][__link30]: Gets the response body as zero-copy JSON (requires `json` feature).
+* [`fetch_json_owned`][__link31]: Gets the response body as owned JSON (requires `json` feature).
+
+These methods automatically convert the response body to the format you want (string, JSON, etc.),
+saving you from handling the raw [`HttpBody`][__link32] type directly. They return a [`Response`][__link33] where `T`
+is your desired format, so you still get all response details and can check the status and headers
+before using the body.
+
+```rust
+// Retrieve the response as text
+let response = client
+ .get("https://api.example.com/users")
+ .fetch_text()
+ .await?;
+
+// We can examine response metadata before handling the body
+println!("Status: {}", response.status());
+println!("Content-Type: {:?}", response.headers().get("content-type"));
+
+// Then ensure success and extract the body
+let text: String = response
+ .ensure_success()? // Ensure the response was successful
+ .into_body(); // Discard the response metadata and get the body as a string
+
+```
+
+## URL Handling
+
+The HTTP client uses the [`templated_uri`][__link34] crate for
+URL handling, which provides a powerful and flexible way to work with URIs.
+
+You can use the [`Uri`][__link35] type to build URIs with templated paths and queries, allowing you to
+create URLs with dynamic segments and query parameters.
+The template format follows [RFC 6570][__link36] level 3,
+which means you can use it to easily template more complex paths and queries as well.
+
+You can also use the [`Uri`][__link37] type or string types to represent URIs for backwards compatibility, or
+if you don’t need templated paths. In that case, the whole `PathAndQuery` string is treated as a template.
+
+[`handlers::Logging`][__link38] will log the used URL template as
+`url.path.template`
+
+For example, you can create a [`Uri`][__link39] with a templated path like this:
+
+```rust
+use templated_uri::{BaseUri, EscapedString, PathAndQueryTemplate, Uri, templated};
+
+#[templated(template = "/users/{user_id}/", unredacted)]
+#[derive(Clone)]
+struct UserPath {
+ user_id: EscapedString, // EscapedString ensures the value is safe for use in URIs
+}
+
+let user_path = UserPath {
+ user_id: EscapedString::from_static("12345"),
+};
+
+client
+ .get(
+ Uri::default()
+ .with_base(BaseUri::from_static("https://api.example.com"))
+ .with_path_and_query(user_path),
+ )
+ .fetch_text()
+ .await?;
+
+```
+
+### Classification in URLs
+
+`templated_uri` supports classification of URL paths and queries using the `data_privacy` crate.
+
+You can also use the `classified` attribute to mark [`PathAndQueryTemplate`][__link40]
+structs as classified, allowing you to use classified types from `data_privacy` in your URL templates.
+
+```rust
+use data_privacy::Sensitive;
+use templated_uri::{EscapedString, PathAndQueryTemplate, templated};
+
+#[templated(template = "/{org_id}/user/{user_id}")]
+struct UserPath {
+ #[unredacted]
+ org_id: u32,
+ user_id: Sensitive,
+}
+```
+
+## JSON Support
+
+Working with JSON APIs is straightforward with the `json` feature, which offers:
+
+* **Send JSON data**: [`HttpRequestBuilder::json`][__link41] serializes any Rust type to JSON.
+* **Receive zero-copy JSON**: [`HttpRequestBuilder::fetch_json`][__link42] returns a
+ [`Json`][__link43] wrapper that borrows strings and byte arrays directly from the response buffer for maximum
+ performance. Use it when you can work with borrowed data.
+* **Receive owned JSON**: [`HttpRequestBuilder::fetch_json_owned`][__link44]
+ deserializes directly into owned Rust types. Use it when the data must outlive the response or cross thread boundaries.
+* **Convert bodies to JSON**: [`HttpBody::into_json`][__link45] transforms response bodies into JSON values.
+
+### Zero-Copy JSON with `fetch_json`
+
+Pair `fetch_json` with borrowed string fields (`Cow<'a, str>`) to avoid allocations; the
+[`Json`][__link46] wrapper borrows directly from the response buffer. Prefer `Cow<'a, str>` over
+`&'a str`, as it transparently falls back to an owned value when a JSON string was escaped in the buffer
+and cannot be borrowed:
+
+```rust
+// Define a Person type that borrows data to avoid allocations
+#[derive(Serialize, Deserialize)]
+struct Person<'a> {
+ id: u32,
+ #[serde(borrow)]
+ name: Cow<'a, str>,
+}
+
+let person = Person {
+ id: 1,
+ name: "Alice Johnson".into(),
+};
+
+// Send and receive zero-copy JSON
+let response: Response> = client
+ .put("https://api.company.com/people")
+ .json(&person) // Add JSON payload
+ .fetch_json::() // Returns Response>
+ .await?;
+
+// You can inspect the response metadata if needed
+let response = response.ensure_success()?;
+
+// Extract the JSON wrapper from the response
+let mut json_body: Json = response.into_body();
+
+// Parse the JSON data using zero-copy deserialization.
+// The parsed Person borrows string data from the underlying buffer.
+let person: Person = json_body.read()?;
+
+println!("Person retrieved, name: {}", person.name);
+
+```
+
+This minimizes heap allocations and copying because string fields borrow directly from the
+response buffer instead of allocating new memory for each string.
+
+### Owned JSON with `fetch_json_owned`
+
+Use `fetch_json_owned` when you need owned data; it deserializes the JSON directly into your
+target type with owned `String` fields:
+
+```rust
+// Define a Person type with owned data
+#[derive(Serialize, Deserialize)]
+struct Person {
+ id: u32,
+ name: String,
+}
+
+let person = Person {
+ id: 1,
+ name: "Alice Johnson".to_owned(),
+};
+
+// Send and receive owned JSON
+let response: Response = client
+ .put("https://api.company.com/people")
+ .json(&person) // Add JSON payload
+ .fetch_json_owned::() // Returns Response directly
+ .await?;
+
+// You can inspect the response metadata if needed
+let response = response.ensure_success()?;
+
+// Extract the deserialized Person directly - no wrapper needed
+let person: Person = response.into_body();
+
+println!("Person retrieved, name: {}", person.name);
+
+```
+
+This performs more allocations than `fetch_json`, but is more convenient when the data must
+outlive the response, cross thread boundaries, or satisfy APIs that require owned types.
+
+## Request Pipeline
+
+The HTTP client uses a pipeline architecture to process requests. Think of it like an assembly
+line - every request passes through a sequence of [`RequestHandler`][__link47]s, each handling
+a specific aspect of HTTP communication.
+
+Each handler in the pipeline can:
+
+* Modify the request before it’s sent
+* Intercept the request completely (e.g., for caching)
+* Process the response after it’s received
+* Add cross-cutting functionality like logging or metrics
+
+At the very end of the pipeline sits the **transport handler** — the leaf [`RequestHandler`][__link48]
+that actually performs the I/O and turns a request into a response. This is the seam that makes
+`fetch` transport- and runtime-agnostic: everything above the transport (resilience, telemetry,
+routing, logging, …) is supplied by `fetch`, while the transport itself can be the bundled
+hyper-based implementation, your own runtime/I/O, or a wrapper around an existing HTTP client
+such as [`reqwest`][__link49]. To supply your own transport, see the
+[`custom`][__link50] module and [`custom::create_builder`][__link51].
+
+### Built-in Pipeline Types
+
+The client offers three types of pipelines to suit different needs - think of them as
+different levels of “batteries included”:
+
+#### Standard Pipeline
+
+The standard pipeline is what you get by default - it includes all the essential handlers
+you’ll want for production use. Handlers are applied in a nested structure, with the outermost
+handler processing the request first and the response last.
+
+See the [`StandardRequestPipeline`][__link52] and [`HttpClientBuilder::standard_pipeline`][__link53]
+for more details and examples.
+
+#### Custom Pipeline
+
+When you need precise control over request processing, you can build a custom pipeline with
+exactly the handlers you want. See the [`HttpClientBuilder::custom_pipeline`][__link54] method for
+more details and examples.
+
+#### Minimal Pipeline
+
+For maximum flexibility, you can use the minimal pipeline that includes only the
+essential [`Dispatch`][__link55] handler that actually sends requests to the network.
+This gives you a clean slate to build on:
+
+```rust
+// Create a client with just the dispatch handler
+let minimal_client = builder.minimal_pipeline().build();
+
+// Then wrap it with your own processing logic
+let wrapped_client = MyHttpWrapper::new(minimal_client);
+```
+
+This is great when you need to implement your own complete request processing pipeline
+or integrate with external middleware systems.
+
+### Creating Custom Handlers
+
+To add your own processing logic, see the [`RequestHandler`][__link56] trait documentation, which covers
+patterns for modifying requests, processing responses, and integrating with the pipeline.
+
+## Testing with the HTTP Client
+
+The `fetch` crate makes testing easy with its built-in fake response system. Enable the
+`test-util` feature to simulate HTTP responses without making real network requests.
+
+By using the fake HTTP client in your tests, you can:
+
+* Test your code’s handling of different HTTP responses
+* Verify retry behaviors and error handling
+* Make tests fast and deterministic by avoiding actual network calls
+* Test edge cases that would be challenging to reproduce with real services
+
+The simplest way to create a test client is `HttpClient::new_fake`, which responds with predefined
+responses instead of making real HTTP requests. It accepts various parameters to streamline testing:
+
+```rust
+
+// Create a fake HTTP client that always returns a 200 response
+let client = HttpClient::new_fake(StatusCode::OK);
+
+// Create a fake HTTP client that returns a sequence of responses without a body
+let client = HttpClient::new_fake(vec![StatusCode::OK, StatusCode::INTERNAL_SERVER_ERROR]);
+
+// Create a fake response
+let response = HttpResponseBuilder::new_fake()
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
+ .text("fake text")
+ .build()
+ .expect("always succeeds");
+
+// Create a fake HTTP client that always returns the same response
+let client = HttpClient::new_fake(response);
+
+// Create a fake HTTP client that uses a custom handler. The handler can be
+// synchronous or asynchronous. Usually for testing, the synchronous handler is sufficient.
+let fake_handler = FakeHandler::from_sync_handler(|req| {
+ println!("Fake handler called for request, url: {}", req.uri());
+
+ HttpResponseBuilder::new_fake()
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
+ .text("fake text")
+ .build()
+});
+
+// Create a fake HTTP client that uses the custom handler
+let client = HttpClient::new_fake(fake_handler);
+```
+
+## Smart Memory Management with `BytesView`
+
+When handling large HTTP responses or sending big requests, memory usage matters, so `fetch` uses
+[`BytesView`][__link57] for request and response bodies. Its key features:
+
+* **Memory Pooling**: Reuses memory instead of constantly allocating and freeing it.
+* **Less Copying**: Smart buffer management reduces unnecessary data copying.
+* **Multiple Chunks**: Can handle data as multiple pieces that look like a single buffer.
+* **Zero-Copy When Possible**: Avoids copying data when it can for better performance.
+* **Works with Ecosystem**: Fully compatible with the popular [`bytes`][__link58] crate.
+
+You can use [`BytesView`][__link59] just like other byte buffer types because it implements the same
+interfaces ([`Buf`][__link60] and [`BufMut`][__link61]) as the [`bytes`][__link62] crate:
+
+```rust
+// Get a response body as a BytesView
+let response = client.get("https://example.com").fetch().await?;
+let mut body_bytes = response.into_body().into_bytes().await?;
+
+// Work with the BytesView using standard bytes methods
+let length = body_bytes.remaining();
+
+// Easy to extract data when you need it
+let mut buffer = vec![0; 10.min(length)];
+if !body_bytes.is_empty() {
+ body_bytes.copy_to_slice(&mut buffer);
+}
+```
+
+This lets your app handle large files, streaming media, or other big data without
+wasting memory or hurting performance.
+
+## Performance Best Practices
+
+Follow these tips for the best performance:
+
+```rust
+// 1. Create a client ONCE and reuse it
+let client = HttpClient::new_tokio();
+
+// 2. Parse URIs ahead of time for repeated use
+let users_uri: Uri = "https://api.example.com/users".parse()?;
+let items_uri: Uri = "https://api.example.com/items".parse()?;
+
+// 3. Work with raw BytesView to avoid allocations when possible
+let response = client.get(users_uri.clone()).fetch().await?;
+let bytes = response.into_body().into_bytes().await?;
+process_binary_data(bytes);
+```
+
+In detail:
+
+* **Reuse your client**: Creating an [`HttpClient`][__link63] is expensive (connection pooling, security setup).
+ Create it once and keep using it throughout your application. Share a single instance across
+ multiple tasks.
+* **Pre-parse URIs**: If you’re repeatedly calling the same endpoints, parse the [`Uri`][__link64]s once and
+ reuse them to skip the parsing overhead.
+* **Work with raw [`BytesView`][__link65]**: Converting between formats (like [`BytesView`][__link66] to `String`) creates
+ allocations and copies data. When handling binary data or large responses, work with [`BytesView`][__link67] directly.
+
+## Integration with the HTTP Ecosystem
+
+Instead of creating our own HTTP types from scratch, we use extensions and wrappers around
+the widely adopted [`http`][__link68] crate. These extensions are defined in the [`http_extensions`][__link69] crate
+and re-exported here for convenience.
+
+## Resilience
+
+The HTTP client has built-in resilience features powered by the [`seatbelt`][__link70] crate. These
+resilience patterns help your application handle failures gracefully and maintain availability
+even when network issues or server problems occur.
+
+The resilience middleware integrates directly into the request pipeline via the
+[`Service`][__link71] trait. Because both the client’s handlers and the seatbelt
+middleware implement this trait, they compose seamlessly - no adapter code is needed to mix
+resilience patterns with other request processing logic.
+
+Common resilience patterns available include:
+
+* **Retries**: Automatically retry failed requests with configurable backoff strategies
+* **Timeouts**: Prevent requests from hanging indefinitely
+* **Circuit Breakers**: Fail fast when a service is down to avoid cascading failures
+
+These patterns are already configured in the [standard pipeline][__link72] with sensible defaults.
+
+## TLS Support
+
+The HTTP client supports two TLS backends for making HTTPS requests:
+
+* **`rustls`** (default): Uses [`rustls`][__link73] with the
+ [`aws-lc-rs`][__link74] crypto provider. This is the recommended
+ backend and is selected by default when the `tls` feature is enabled.
+* **`native-tls`**: Uses the platform’s native TLS implementation (`SChannel` on Windows,
+ Security Framework on `macOS`, `OpenSSL` on Linux). This can be explicitly selected, or is
+ chosen automatically when the `native-tls` feature is enabled and `rustls` is not.
+
+When using the `rustls` backend, the HTTP client validates server certificates against the
+platform trust store via [`rustls-platform-verifier`][__link75],
+which takes care of essential TLS operations:
+
+* Verifies certificates against the operating system’s trusted root `CAs`.
+* Validates host names and checks certificate expiration.
+* Enforces TLS security policies.
+
+TLS is configured automatically; simply construct a client and make HTTPS requests:
+
+```rust
+use fetch::HttpClient;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let client = HttpClient::new_tokio();
+
+ // Now you can make HTTPS requests
+ let response = client.get("https://www.example.com").fetch_text().await?;
+
+ Ok(())
+}
+```
+
+To enable TLS support, add the `tls` feature (which enables `rustls` by default) to your dependencies:
+
+```toml
+fetch = { version = "*", features = ["tls", "tokio"] }
+```
+
+To use native TLS instead, enable the `native-tls` feature explicitly:
+
+```toml
+fetch = { version = "*", features = ["native-tls", "tokio"] }
+```
+
+You can also select the TLS backend at runtime via [`TlsOptions::builder_rustls()`][__link76] or
+`TlsOptions::builder_native_tls()` when both features are enabled, allowing different client
+instances to use different backends.
+
+## Features
+
+The `fetch` crate provides several optional features that you can enable in your `Cargo.toml`:
+
+```toml
+[dependencies]
+fetch = { version = "*", features = ["json", "tokio"] }
+```
+
+* **`tokio`**: Enables integration with the Tokio runtime. This feature provides the `HttpClient::builder_tokio`
+ constructor and related APIs for using the HTTP client in a Tokio-based application.
+
+* **`json`**: Adds support for JSON serialization and deserialization, enabling methods like
+ `HttpRequestBuilder::json` for sending JSON data and `HttpRequestBuilder::fetch_json` for receiving JSON responses.
+
+* **`tls`**: Enables TLS support using `rustls` with the `aws-lc-rs` crypto provider. This is the
+ recommended way to enable HTTPS support and is an alias for the `rustls` feature.
+
+* **`rustls`**: Enables the `rustls` TLS backend with `aws-lc-rs`. This is the default TLS backend
+ and is selected automatically by the `tls` feature.
+
+* **`native-tls`**: Enables the platform native TLS backend (`SChannel` on Windows, Security Framework
+ on `macOS`, `OpenSSL` on Linux). Use this when you need the platform’s native TLS stack. When both
+ `rustls` and `native-tls` are enabled, `rustls` is the default but you can select native TLS via
+ `TlsOptions::builder_native_tls()`.
+
+* **`test-util`**: Provides APIs to fake responses and HTTP client behavior for testing purposes.
+ This feature makes it easy to write fast, deterministic tests without making real network requests.
+
+ >
+ > **Note**: Most users should enable the `tokio` feature along with the `tls` feature for HTTPS
+ > support. The `json` feature is recommended for most applications that need to work with JSON APIs.
+
+
+
+
+This crate was developed as part of The Oxidizer Project. Browse this crate's source code.
+
+
+ [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbLiTyV0MU86EbZU15e0PmecoboQ9jo59bnAEbyDXw04U13GlhYvRhcoQbg9gUGftKOZYbOcUidqRvtlIb4jNz0TaSSBQbzELqodjgpHxhZIeCZWJ5dGVzZjEuMTEuMYJoYnl0ZXNidWZlMC41LjKCZWZldGNoZjAuMTAuMIJvaHR0cF9leHRlbnNpb25zZTAuNC40gmdsYXllcmVkZTAuMy4ygmhzZWF0YmVsdGUwLjUuM4JtdGVtcGxhdGVkX3VyaWUwLjIuMw
+ [__link0]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient
+ [__link1]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=RequestHandler
+ [__link10]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient::post
+ [__link11]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient::put
+ [__link12]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient::delete
+ [__link13]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient::request
+ [__link14]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpRequestBuilder
+ [__link15]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClientBuilder::base_uri
+ [__link16]: https://docs.rs/templated_uri/0.2.3/templated_uri/?search=BaseUri
+ [__link17]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpRequestBuilder::fetch
+ [__link18]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpResponse
+ [__link19]: https://docs.rs/fetch/0.10.0/fetch/?search=http::Response
+ [__link2]: https://docs.rs/reqwest/
+ [__link20]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpResponse::ensure_success
+ [__link21]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpResponse::status
+ [__link22]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpResponse::headers
+ [__link23]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpResponse::into_body
+ [__link24]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpBody::into_text
+ [__link25]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpBody::into_bytes
+ [__link26]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpBody::into_json
+ [__link27]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpRequestBuilder::fetch
+ [__link28]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpRequestBuilder::fetch_text
+ [__link29]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpRequestBuilder::fetch_bytes
+ [__link3]: https://docs.rs/fetch/0.10.0/fetch/custom/index.html
+ [__link30]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpRequestBuilder::fetch_json
+ [__link31]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpRequestBuilder::fetch_json_owned
+ [__link32]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=HttpBody
+ [__link33]: https://docs.rs/fetch/0.10.0/fetch/?search=http::Response
+ [__link34]: https://crates.io/crates/templated_uri/0.2.3
+ [__link35]: https://docs.rs/templated_uri/0.2.3/templated_uri/?search=Uri
+ [__link36]: https://datatracker.ietf.org/doc/html/rfc6570
+ [__link37]: https://docs.rs/templated_uri/0.2.3/templated_uri/?search=Uri
+ [__link38]: https://docs.rs/fetch/0.10.0/fetch/?search=handlers::Logging
+ [__link39]: https://docs.rs/templated_uri/0.2.3/templated_uri/?search=Uri
+ [__link4]: https://docs.rs/fetch/0.10.0/fetch/?search=custom::create_builder
+ [__link40]: https://docs.rs/templated_uri/0.2.3/templated_uri/?search=PathAndQueryTemplate
+ [__link41]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpRequestBuilder::json
+ [__link42]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpRequestBuilder::fetch_json
+ [__link43]: https://docs.rs/fetch/0.10.0/fetch/?search=Json
+ [__link44]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpRequestBuilder::fetch_json_owned
+ [__link45]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpBody::into_json
+ [__link46]: https://docs.rs/fetch/0.10.0/fetch/?search=Json
+ [__link47]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=RequestHandler
+ [__link48]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=RequestHandler
+ [__link49]: https://docs.rs/reqwest/
+ [__link5]: https://docs.rs/reqwest/
+ [__link50]: https://docs.rs/fetch/0.10.0/fetch/custom/index.html
+ [__link51]: https://docs.rs/fetch/0.10.0/fetch/?search=custom::create_builder
+ [__link52]: https://docs.rs/fetch/0.10.0/fetch/?search=pipeline::StandardRequestPipeline
+ [__link53]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClientBuilder::standard_pipeline
+ [__link54]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClientBuilder::custom_pipeline
+ [__link55]: https://docs.rs/fetch/0.10.0/fetch/?search=handlers::Dispatch
+ [__link56]: https://docs.rs/http_extensions/0.4.4/http_extensions/?search=RequestHandler
+ [__link57]: https://docs.rs/bytesbuf/0.5.2/bytesbuf/?search=BytesView
+ [__link58]: https://docs.rs/bytes
+ [__link59]: https://docs.rs/bytesbuf/0.5.2/bytesbuf/?search=BytesView
+ [__link6]: https://docs.rs/hyper/
+ [__link60]: https://docs.rs/bytes/1.11.1/bytes/?search=Buf
+ [__link61]: https://docs.rs/bytes/1.11.1/bytes/?search=BufMut
+ [__link62]: https://docs.rs/bytes
+ [__link63]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient
+ [__link64]: https://docs.rs/templated_uri/0.2.3/templated_uri/?search=Uri
+ [__link65]: https://docs.rs/bytesbuf/0.5.2/bytesbuf/?search=BytesView
+ [__link66]: https://docs.rs/bytesbuf/0.5.2/bytesbuf/?search=BytesView
+ [__link67]: https://docs.rs/bytesbuf/0.5.2/bytesbuf/?search=BytesView
+ [__link68]: https://docs.rs/fetch/0.10.0/fetch/http/index.html
+ [__link69]: https://crates.io/crates/http_extensions/0.4.4
+ [__link7]: https://docs.rs/fetch/0.10.0/fetch/custom/index.html
+ [__link70]: https://crates.io/crates/seatbelt/0.5.3
+ [__link71]: https://docs.rs/layered/0.3.2/layered/?search=Service
+ [__link72]: https://docs.rs/fetch/0.10.0/fetch/?search=pipeline::StandardRequestPipeline
+ [__link73]: https://docs.rs/rustls
+ [__link74]: https://docs.rs/aws-lc-rs
+ [__link75]: https://docs.rs/rustls-platform-verifier
+ [__link76]: https://docs.rs/fetch/0.10.0/fetch/?search=tls::TlsOptions::builder_rustls
+ [__link8]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient::builder_tokio
+ [__link9]: https://docs.rs/fetch/0.10.0/fetch/?search=HttpClient::get
diff --git a/crates/fetch/benches/http_crate.rs b/crates/fetch/benches/http_crate.rs
new file mode 100644
index 000000000..10063e13b
--- /dev/null
+++ b/crates/fetch/benches/http_crate.rs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#![allow(clippy::missing_panics_doc, reason = "improves readability in benchmarks")]
+#![allow(clippy::unwrap_used, reason = "benchmark code")]
+#![expect(missing_docs, reason = "Benchmark code")]
+
+use alloc_tracker::{Allocator, Session};
+use criterion::{Criterion, criterion_group, criterion_main};
+use http::header::{CONTENT_LENGTH, CONTENT_TYPE};
+use http::{HeaderValue, Method, Request};
+
+#[global_allocator]
+static ALLOCATOR: Allocator = Allocator::system();
+
+const URI_STRING: &str = "https://example.com/some/path?query=value";
+
+fn get_uri() -> &'static str {
+ URI_STRING
+}
+
+fn entry(c: &mut Criterion) {
+ let session = Session::new();
+ let mut group = c.benchmark_group("http_crate");
+
+ let uri_allocs = session.operation("uri");
+ group.bench_function("uri", |b| {
+ b.iter(|| {
+ let _measure = uri_allocs.measure_thread();
+ let _request = Request::builder().method(Method::GET).uri(get_uri()).body(()).unwrap();
+ });
+ });
+
+ let uri_raw_allocs = session.operation("uri_raw");
+ group.bench_function("uri_raw", |b| {
+ b.iter(|| {
+ let _measure = uri_raw_allocs.measure_thread();
+ let _request = Request::builder().method(Method::GET).uri(URI_STRING).body(()).unwrap();
+ });
+ });
+
+ let single_header_allocs = session.operation("uri_single_header");
+ group.bench_function("uri_single_header", |b| {
+ b.iter(|| {
+ let _measure = single_header_allocs.measure_thread();
+ let _request = Request::builder()
+ .method(Method::GET)
+ .uri(get_uri())
+ .header(CONTENT_LENGTH, HeaderValue::from_static("0"))
+ .body(())
+ .unwrap();
+ });
+ });
+
+ let two_headers_allocs = session.operation("uri_two_headers");
+ group.bench_function("uri_two_headers", |b| {
+ b.iter(|| {
+ let _measure = two_headers_allocs.measure_thread();
+ let _request = Request::builder()
+ .method(Method::GET)
+ .uri(get_uri())
+ .header(CONTENT_LENGTH, HeaderValue::from_static("0"))
+ .header(CONTENT_TYPE, HeaderValue::from_static("text/plain"))
+ .body(())
+ .unwrap();
+ });
+ });
+
+ group.finish();
+ session.print_to_stdout();
+}
+
+criterion_group!(benches, entry);
+criterion_main!(benches);
diff --git a/crates/fetch/benches/pipelines.rs b/crates/fetch/benches/pipelines.rs
new file mode 100644
index 000000000..722cbdd79
--- /dev/null
+++ b/crates/fetch/benches/pipelines.rs
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#![allow(
+ clippy::missing_panics_doc,
+ clippy::wildcard_imports,
+ reason = "improves readability in benchmarks"
+)]
+#![allow(clippy::unwrap_used, reason = "benchmark code")]
+#![expect(missing_docs, reason = "Benchmark code")]
+
+use std::time::Duration;
+
+use alloc_tracker::{Allocator, Session};
+use criterion::{Criterion, criterion_group, criterion_main};
+use fetch::HttpClient;
+use fetch::handlers::{Logging, Metrics};
+use fetch::resilience::retry::HttpRetryLayerExt;
+use fetch::resilience::timeout::HttpTimeoutLayerExt;
+use futures::executor::block_on;
+use http::StatusCode;
+use layered::Stack;
+use seatbelt::retry::Retry;
+use seatbelt::timeout::Timeout;
+use tick::Clock;
+
+#[global_allocator]
+static ALLOCATOR: Allocator = Allocator::system();
+
+const URI_STRING: &str = "https://example.com/some/path?query=value";
+
+fn get_uri() -> &'static str {
+ URI_STRING
+}
+
+fn entry(c: &mut Criterion) {
+ let session = Session::new();
+ let mut group = c.benchmark_group("http_client_pipelines");
+
+ let client = HttpClient::builder_fake(StatusCode::OK, &Clock::new_frozen()).build();
+ let standard_allocs = session.operation("standard_pipeline");
+ group.bench_function("standard_pipeline", |b| {
+ b.iter(|| {
+ let _measure = standard_allocs.measure_thread();
+ _ = block_on(client.get(get_uri()).fetch()).unwrap();
+ });
+ });
+
+ let client = HttpClient::builder_fake(StatusCode::OK, &Clock::new_frozen())
+ .minimal_pipeline()
+ .build();
+ let minimal_allocs = session.operation("minimal_pipeline");
+ group.bench_function("minimal_pipeline", |b| {
+ b.iter(|| {
+ let _measure = minimal_allocs.measure_thread();
+ _ = block_on(client.get(get_uri()).fetch()).unwrap();
+ });
+ });
+
+ let client = HttpClient::builder_fake(StatusCode::OK, &Clock::new_frozen())
+ .custom_pipeline(|dispatch, _context| dispatch)
+ .build();
+ let custom_minimal_allocs = session.operation("custom_minimal_pipeline");
+ group.bench_function("custom_minimal_pipeline", |b| {
+ b.iter(|| {
+ let _measure = custom_minimal_allocs.measure_thread();
+ _ = block_on(client.get(get_uri()).fetch()).unwrap();
+ });
+ });
+
+ let client = HttpClient::builder_fake(StatusCode::OK, &Clock::new_frozen())
+ .custom_pipeline(|dispatch, context| {
+ let stack = (
+ Timeout::layer("total_timeout", context.resilience_context())
+ .timeout(Duration::from_secs(30))
+ .http_timeout_error(),
+ Retry::layer("retry", context.resilience_context()).http_configure_defaults(),
+ Timeout::layer("attempt_timeout", context.resilience_context())
+ .timeout(Duration::from_secs(10))
+ .http_timeout_error(),
+ Logging::layer(context.clock(), context.redaction_engine()),
+ Metrics::layer(context.clock()).meter_provider(opentelemetry::global::meter_provider().as_ref()),
+ dispatch,
+ );
+
+ stack.into_service()
+ })
+ .build();
+ let custom_standard_allocs = session.operation("custom_standard_pipeline");
+ group.bench_function("custom_standard_pipeline", |b| {
+ b.iter(|| {
+ let _measure = custom_standard_allocs.measure_thread();
+ _ = block_on(client.get(get_uri()).fetch()).unwrap();
+ });
+ });
+
+ group.finish();
+ session.print_to_stdout();
+}
+
+criterion_group!(benches, entry);
+criterion_main!(benches);
diff --git a/crates/fetch/examples/http_client_advanced.rs b/crates/fetch/examples/http_client_advanced.rs
new file mode 100644
index 000000000..2a961fbbb
--- /dev/null
+++ b/crates/fetch/examples/http_client_advanced.rs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates advanced HTTP client configuration options including:
+//! - Connection keep-alive strategies
+//! - Connection pooling configuration
+//! - HTTP/2-specific settings
+//! - A custom rustls server certificate verifier
+//! - Resilience pipeline configuration
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use fetch::HttpClient;
+use fetch::options::{ConnectionKeepAlive, ConnectionLifetime, ConnectionPoolOptions, Http2Options};
+use fetch::tls::TlsOptions;
+use http::Version;
+use rustls::client::danger::ServerCertVerifier;
+use rustls::crypto::CryptoProvider;
+use tracing::info;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+/// Builds a custom rustls server certificate verifier.
+///
+/// This example simply delegates to the platform trust store via
+/// [`rustls_platform_verifier::Verifier`], but the same hook can plug in any
+/// custom [`ServerCertVerifier`] implementation (for example, certificate
+/// pinning or a private root of trust).
+fn custom_verifier(provider: Arc) -> Arc {
+ Arc::new(
+ rustls_platform_verifier::Verifier::new(provider)
+ .expect("the platform certificate verifier must initialize with the supplied crypto provider"),
+ )
+}
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ // Create an HTTP client with extensive configuration.
+ let client = HttpClient::builder_tokio(fetch::tokio::TokioDeps::default())
+ // Customize the certificate validation with a custom rustls verifier.
+ .tls_options(TlsOptions::builder_rustls().server_certificate_verifier(custom_verifier).build())
+ // Configure connection keep-alive to maintain connections for better performance.
+ // This keeps both active and idle connections alive with periodic probes.
+ .connection_keep_alive(ConnectionKeepAlive::active_and_idle_connections(
+ Duration::from_secs(30), // Keep-alive probe interval
+ Duration::from_secs(5), // Keep-alive probe timeout
+ ))
+ // Configure connection pooling for optimal connection reuse.
+ .connection_pool_options(
+ ConnectionPoolOptions::default()
+ // Limit to 10 connections per host to control resource usage.
+ .max_connections(10)
+ // Keep idle connections for 5 seconds before closing them.
+ .connection_idle_timeout(Duration::from_secs(5))
+ // Limit max connection lifetime to 60 seconds.
+ .connection_lifetime(ConnectionLifetime::fixed(Duration::from_mins(1))),
+ )
+ // Configure HTTP/2-specific options:
+ // Allow up to 100 concurrent streams per HTTP/2 connection.
+ .http2_options(Http2Options::default().initial_max_send_streams(100))
+ // Support both HTTP/1.1 and HTTP/2 (this is the default, shown for clarity).
+ .supported_http_versions(&[Version::HTTP_11, Version::HTTP_2])
+ // Configure the standard pipeline with custom resilience settings.
+ .standard_pipeline(|pipeline, _context| {
+ // Change the default attempt timeout to 3 seconds.
+ pipeline.attempt_timeout(|timeout| timeout.timeout(Duration::from_secs(3)))
+ })
+ .build();
+
+ info!("Advanced HTTP client created successfully");
+
+ // No requests are made in this example; the focus is on configuration.
+ drop(client);
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_api_with_templated_uri.rs b/crates/fetch/examples/http_client_api_with_templated_uri.rs
new file mode 100644
index 000000000..87ebeb9fb
--- /dev/null
+++ b/crates/fetch/examples/http_client_api_with_templated_uri.rs
@@ -0,0 +1,140 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to use the HTTP client with multiple REST endpoints
+//! and templated URIs for more complex API interactions with multiple target paths.
+
+use fetch::fake::FakeDeps;
+use fetch::{BaseUri, HttpClient};
+use ohno::AppError;
+use templated_uri::EscapedString;
+
+use crate::crates_io_mock::crates_io_fake_handler;
+
+#[path = "util/crates_io_mock.rs"]
+mod crates_io_mock;
+
+mod api {
+ use fetch::HttpClient;
+ use templated_uri::{EscapedString, templated};
+
+ type Result = std::result::Result;
+
+ pub struct CratesClient {
+ client: HttpClient,
+ }
+
+ impl CratesClient {
+ pub(crate) fn new(client: HttpClient) -> Self {
+ Self { client }
+ }
+
+ async fn fetch(&self, api_endpoint: CratesApi) -> Result
+ where
+ T: serde::de::DeserializeOwned,
+ {
+ let mut result = self
+ .client
+ .get(templated_uri::Uri::from(api_endpoint))
+ .header("User-Agent", "http-client")
+ .fetch_json::()
+ .await?
+ .into_body();
+ Ok(result.read()?)
+ }
+
+ pub async fn get_crate(&self, crate_name: EscapedString) -> Result {
+ let api_endpoint = CratesApi::get_crate(crate_name);
+ self.fetch(api_endpoint).await
+ }
+
+ pub async fn search_crates(&self, query: EscapedString) -> Result {
+ let search_url = CratesApi::search_crates(query);
+ self.fetch(search_url).await
+ }
+ }
+
+ #[derive(serde::Deserialize, Debug)]
+ pub struct CrateResponse {
+ #[serde(rename = "crate")]
+ pub model: Crate,
+ }
+
+ #[derive(serde::Deserialize, Debug)]
+ pub struct CratesResponse {
+ #[serde(rename = "crates")]
+ pub crates: Vec,
+ }
+
+ #[derive(serde::Deserialize, Debug)]
+ pub struct Crate {
+ pub name: String,
+ pub downloads: u64,
+ pub description: String,
+ }
+
+ #[templated]
+ #[derive(Clone)]
+ enum CratesApi {
+ Crate(CrateUrl),
+ CrateSearch(CrateSearchUrl),
+ }
+
+ impl CratesApi {
+ fn get_crate(crate_name: EscapedString) -> Self {
+ CrateUrl { crate_name }.into()
+ }
+ fn search_crates(query: EscapedString) -> Self {
+ CrateSearchUrl { q: query }.into()
+ }
+ }
+
+ // See http_client_telemetry.rs example for cases where URIs have classified components.
+ #[templated(template = "/api/v1/crates/{crate_name}", unredacted)]
+ #[derive(Clone)]
+ struct CrateUrl {
+ crate_name: EscapedString,
+ }
+
+ // See http_client_telemetry.rs example for cases where URIs have classified components.
+ #[templated(template = "/api/v1/crates{?q}", unredacted)]
+ #[derive(Clone)]
+ struct CrateSearchUrl {
+ q: EscapedString,
+ }
+}
+
+#[tokio::main]
+async fn main() -> Result<(), AppError> {
+ let crate_name = EscapedString::from_static("serde");
+
+ let client =
+ HttpClient::new_fake(crates_io_fake_handler(crate_name.to_string())).with_base_uri(BaseUri::from_static("https://crates.io/"));
+
+ let watch = FakeDeps::default().clock.stopwatch();
+ let api_client = api::CratesClient::new(client);
+ let crate_response = api_client.get_crate(crate_name.clone()).await?;
+
+ println!("Single crate response:");
+
+ println!(
+ "crate: {}, downloads: {}, took: {} ms, description: '{}'",
+ crate_name,
+ crate_response.model.downloads,
+ watch.elapsed().as_millis(),
+ crate_response.model.description,
+ );
+
+ let search_response = api_client.search_crates(crate_name).await?;
+
+ println!("Crate search response:");
+
+ for response in &search_response.crates {
+ println!(
+ "crate: {}, downloads: {}, description: '{}'",
+ response.name, response.downloads, response.description,
+ );
+ }
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_app.rs b/crates/fetch/examples/http_client_app.rs
new file mode 100644
index 000000000..0216c2f64
--- /dev/null
+++ b/crates/fetch/examples/http_client_app.rs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to use [`fundle`] to build an HTTP client application with
+//! telemetry (an OpenTelemetry meter provider) and a custom rustls certificate verifier.
+
+use std::sync::Arc;
+
+use bytesbuf::mem::GlobalPool;
+use fetch::HttpClient;
+use fetch::tls::TlsOptions;
+use ohno::ErrorExt;
+use opentelemetry_sdk::metrics::SdkMeterProvider;
+use rustls::client::danger::ServerCertVerifier;
+use rustls::crypto::CryptoProvider;
+use tick::Clock;
+
+#[fundle::bundle]
+struct App {
+ clock: Clock,
+ global_pool: GlobalPool,
+ client: HttpClient,
+}
+
+/// Builds a custom rustls server certificate verifier.
+///
+/// This example delegates to the platform trust store via
+/// [`rustls_platform_verifier::Verifier`], but the same hook accepts any custom
+/// [`ServerCertVerifier`] implementation.
+fn custom_verifier(provider: Arc) -> Arc {
+ Arc::new(
+ rustls_platform_verifier::Verifier::new(provider)
+ .expect("the platform certificate verifier must initialize with the supplied crypto provider"),
+ )
+}
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ // Telemetry is configured up front and shared with the HTTP client.
+ let meter_provider = initialize_meter_provider();
+
+ // Initialize and set up the App instance; fundle ensures all fields are properly constructed.
+ let app = App::builder()
+ .clock(|_| Clock::new_tokio())
+ .global_pool(|_| GlobalPool::new())
+ .client({
+ let meter_provider = meter_provider.clone();
+ move |x| {
+ HttpClient::builder_tokio(x)
+ .meter_provider(&meter_provider)
+ .tls_options(TlsOptions::builder_rustls().server_certificate_verifier(custom_verifier).build())
+ .build()
+ }
+ })
+ .build();
+
+ // Use the client.
+ match app.client.get("https://www.example.com").fetch().await {
+ Ok(response) => {
+ println!("response success, status: {}", response.status());
+ }
+ Err(e) => {
+ println!("response error, message: {}", e.message());
+ }
+ }
+
+ Ok(())
+}
+
+fn initialize_meter_provider() -> SdkMeterProvider {
+ SdkMeterProvider::builder()
+ .with_periodic_exporter(opentelemetry_stdout::MetricExporter::default())
+ .build()
+}
diff --git a/crates/fetch/examples/http_client_breaker.rs b/crates/fetch/examples/http_client_breaker.rs
new file mode 100644
index 000000000..d6783c042
--- /dev/null
+++ b/crates/fetch/examples/http_client_breaker.rs
@@ -0,0 +1,97 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Demonstrates the HTTP circuit breaker with per-origin isolation.
+//!
+//! This example uses a fake handler that returns 500 Internal Server Error for
+//! requests to `https://failing.example.com` and 200 OK for requests to
+//! `https://healthy.example.com`.
+//!
+//! 1. We send many requests to the failing host to trip its circuit breaker.
+//! 2. Once the breaker is open, requests to the failing host are rejected
+//! immediately with a "circuit breaker open" error.
+//! 3. Requests to the healthy host continue to succeed, demonstrating that
+//! breaker state is tracked per-origin (scheme + authority).
+
+use std::time::Duration;
+
+use fetch::fake::{FakeDeps, FakeHandler};
+use fetch::{HttpClient, HttpResponseBuilder};
+use http::StatusCode;
+use ohno::ErrorExt;
+
+const FAILING_HOST: &str = "https://failing.example.com";
+const HEALTHY_HOST: &str = "https://healthy.example.com";
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ // Fake handler: 500 for the failing host, 200 for everything else.
+ let fake_handler = FakeHandler::from_sync_handler(|req| {
+ let status = if req.uri().host() == Some("failing.example.com") {
+ StatusCode::INTERNAL_SERVER_ERROR
+ } else {
+ StatusCode::OK
+ };
+
+ HttpResponseBuilder::new_fake().status(status).build()
+ });
+
+ let client = HttpClient::builder_fake(fake_handler, FakeDeps::default())
+ .standard_pipeline(|pipeline, _| {
+ pipeline
+ // Disable retries so we can observe the breaker directly.
+ .retry(|retry| retry.max_retry_attempts(0))
+ // Lower thresholds so the breaker trips quickly in this demo.
+ .breaker(|breaker| {
+ breaker
+ .min_throughput(5)
+ .failure_threshold(0.5)
+ .break_duration(Duration::from_secs(30))
+ })
+ })
+ .build();
+
+ // ---------------------------------------------------------------
+ // Step 1: Send requests to the failing host to trip the breaker.
+ // ---------------------------------------------------------------
+ println!("--- Sending requests to {FAILING_HOST} (expect 500s) ---");
+
+ for i in 1..=10 {
+ let result = client.get(FAILING_HOST).fetch().await;
+ match result {
+ Ok(resp) => println!(" request {i}: {}", resp.status()),
+ Err(e) => println!(" request {i}: error — {}", e.message()),
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Step 2: The breaker should now be open — next request is rejected
+ // immediately without reaching the handler.
+ // ---------------------------------------------------------------
+ println!("\n--- Breaker should be open for {FAILING_HOST} ---");
+
+ let result = client.get(FAILING_HOST).fetch().await;
+ match result {
+ Ok(resp) => println!(" unexpected success: {}", resp.status()),
+ Err(e) => println!(" rejected: {}", e.message()),
+ }
+
+ // ---------------------------------------------------------------
+ // Step 3: Requests to the healthy host still succeed — the breaker
+ // for a different origin is independent.
+ // ---------------------------------------------------------------
+ println!("\n--- Sending request to {HEALTHY_HOST} (should succeed) ---");
+
+ let response = client.get(HEALTHY_HOST).fetch().await?;
+ println!(" response: {}", response.status());
+
+ assert_eq!(
+ response.status(),
+ StatusCode::OK,
+ "healthy host should not be affected by the failing host's breaker"
+ );
+
+ println!("\nDone — circuit breaker isolation between origins is working.");
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_connection_scaling.rs b/crates/fetch/examples/http_client_connection_scaling.rs
new file mode 100644
index 000000000..588b5331e
--- /dev/null
+++ b/crates/fetch/examples/http_client_connection_scaling.rs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to create a simple HTTP client that sends multiple
+//! concurrent requests to example.com and how the underlying connection pool dynamically
+//! creates new connections to handle the burst of requests.
+
+use std::time::Duration;
+
+use fetch::HttpClient;
+use fetch::options::ConnectionPoolOptions;
+use fetch::tokio::TokioDeps;
+use tokio::spawn;
+use tracing::info;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+const CONCURRENT_REQUESTS: usize = 20;
+const URL: &str = "https://example.com";
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ let deps = TokioDeps::default();
+ let clock = deps.clock.clone();
+
+ let client = HttpClient::builder_tokio(deps)
+ .connection_pool_options(ConnectionPoolOptions::default().connection_idle_timeout(Duration::from_secs(5)))
+ .build();
+
+ let mut handles = vec![];
+
+ // This burst of requests creates additional connections.
+ for _ in 0..CONCURRENT_REQUESTS {
+ let client_clone = client.clone();
+ let handle = spawn(async move { client_clone.get(URL).fetch_text().await });
+
+ handles.push(handle);
+ }
+
+ // Wait for all requests to complete.
+ for handle in handles {
+ handle.await??;
+ }
+
+ info!(
+ "sent {} concurrent requests, the application will automatically stop after 10 seconds. \
+ In the logs you should see connections automatically closing after not being used anymore.",
+ CONCURRENT_REQUESTS
+ );
+
+ clock.delay(Duration::from_secs(10)).await;
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_custom.rs b/crates/fetch/examples/http_client_custom.rs
new file mode 100644
index 000000000..82b2dc2b1
--- /dev/null
+++ b/crates/fetch/examples/http_client_custom.rs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Plugs a custom `EchoHandler` into [`fetch::custom::create_builder`] as the transport
+//! handler. Every request's body is returned verbatim in the response.
+
+use bytesbuf::mem::GlobalPool;
+use fetch::custom::{CustomDeps, Isolation, create_builder};
+use fetch::{HttpRequest, HttpResponse, HttpResponseBuilder};
+use http::StatusCode;
+use http_extensions::HttpBodyBuilder;
+use layered::Service;
+use tick::Clock;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let deps = CustomDeps {
+ clock: Clock::new_tokio(),
+ global_pool: GlobalPool::new(),
+ extras: (),
+ };
+
+ let client = create_builder(
+ |cx| EchoHandler {
+ body_builder: cx.body_builder,
+ },
+ Isolation::Shared,
+ deps,
+ )
+ .insecure_allow_http()
+ .build();
+
+ let payload = "Hello, transport handler!";
+ let response = client.post("http://example.com/echo").text(payload).fetch_text().await?;
+
+ println!("status: {}", response.status());
+ let echoed = response.into_body();
+ println!("echoed body: {echoed}");
+ assert_eq!(echoed, payload);
+
+ Ok(())
+}
+
+/// Transport handler that echoes the request body back to the caller.
+struct EchoHandler {
+ body_builder: HttpBodyBuilder,
+}
+
+impl Service for EchoHandler {
+ type Out = http_extensions::Result;
+
+ async fn execute(&self, input: HttpRequest) -> Self::Out {
+ let echoed = input.into_body().into_bytes().await?;
+
+ HttpResponseBuilder::new(&self.body_builder)
+ .status(StatusCode::OK)
+ .bytes(echoed)
+ .build()
+ }
+}
diff --git a/crates/fetch/examples/http_client_custom_pipeline.rs b/crates/fetch/examples/http_client_custom_pipeline.rs
new file mode 100644
index 000000000..83c2dc5f6
--- /dev/null
+++ b/crates/fetch/examples/http_client_custom_pipeline.rs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to customize the HTTP client pipeline.
+//!
+//! It uses the fake handler because the customization APIs are identical compared to a real
+//! client, and it allows the example to run without the network access.
+
+use std::time::Duration;
+
+use fetch::HttpClient;
+use fetch::fake::FakeDeps;
+use fetch::resilience::timeout::HttpTimeoutLayerExt;
+use http::StatusCode;
+use layered::Stack;
+use seatbelt::timeout::Timeout;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default())
+ // Use custom pipeline rather than the default one.
+ // Here we add a timeout layer that wraps the dispatch handler.
+ //
+ // Because the fake builder is used, the dispatch handler transparently returns a
+ // fake 200 OK response.
+ .custom_pipeline(move |dispatch, context| {
+ let stack = (
+ Timeout::layer("my_timeout", context.resilience_context())
+ .http_timeout_error()
+ .timeout(Duration::from_secs(5)),
+ dispatch,
+ );
+
+ stack.into_service()
+ })
+ .build();
+
+ let response = client.get("https://example.com").fetch().await?;
+
+ println!("response, status code: {}", response.status());
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_customization.rs b/crates/fetch/examples/http_client_customization.rs
new file mode 100644
index 000000000..98a86ec0e
--- /dev/null
+++ b/crates/fetch/examples/http_client_customization.rs
@@ -0,0 +1,66 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to customize the HTTP client. Standard pipeline and resilience
+//! in particular.
+
+use std::time::Duration;
+
+use fetch::fake::{FakeDeps, FakeHandler};
+use fetch::resilience::HttpClone;
+use fetch::resilience::retry::HttpRetryLayerExt;
+use fetch::{HttpClient, HttpResponse, HttpResponseBuilder, StatusExt};
+use http::StatusCode;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ // Simulate issues with the request by using a fake handler.
+ let fake_handler = FakeHandler::from_sync_handler(|req| {
+ let status_code = match fastrand::u32(0..10) {
+ 0..5 => StatusCode::INTERNAL_SERVER_ERROR,
+ _ => StatusCode::OK,
+ };
+
+ HttpResponseBuilder::new_fake()
+ .status(status_code)
+ .text(format!("response from {}", req.uri()))
+ .build()
+ });
+
+ let client = HttpClient::builder_fake(fake_handler, FakeDeps::default())
+ .standard_pipeline(|pipeline, _context| {
+ pipeline
+ // Increase the total timeout for the pipeline.
+ .total_timeout(|timeout| timeout.timeout(Duration::from_mins(1)))
+ // Customize the retry behavior.
+ .retry(|retry| {
+ retry
+ .base_delay(Duration::ZERO)
+ .max_retry_attempts(50) // we can do many retries, this does not do any external IO
+ .http_clone(HttpClone::all()) // we clone all idempotent requests (PUT, DELETE, etc)
+ .http_recovery(|response: &HttpResponse| response.recovery())
+ })
+ // Decrease the attempt timeout.
+ .attempt_timeout(|timeout| timeout.timeout(Duration::from_secs(2)))
+ // Optionally, we could also customize the attempt_intercept callback.
+ .attempt_intercept(|intercept| {
+ intercept.modify_input(|req| {
+ // You can inspect/modify the request here.
+ println!("attempting request to {}", req.uri());
+ req
+ })
+ })
+ })
+ .build();
+
+ let text = client.get("https://example.com").fetch_text().await?.into_body();
+
+ println!("response: {text}");
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_fake.rs b/crates/fetch/examples/http_client_fake.rs
new file mode 100644
index 000000000..14be05c3f
--- /dev/null
+++ b/crates/fetch/examples/http_client_fake.rs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example shows how to use the `test-util` feature to mock the HTTP client with specific responses.
+
+use fetch::fake::FakeHandler;
+use fetch::{HttpClient, HttpResponseBuilder};
+use http::StatusCode;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let fake_handler = FakeHandler::from_sync_handler(|req| {
+ println!("fake handler called for request, url: {}", req.uri());
+
+ HttpResponseBuilder::new_fake()
+ .status(StatusCode::INTERNAL_SERVER_ERROR)
+ .text("fake text")
+ .build()
+ });
+
+ let client = HttpClient::new_fake(fake_handler);
+
+ let response = client.get("https://example.com").fetch_text().await?;
+
+ assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+ assert_eq!(response.into_body(), "fake text");
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_json.rs b/crates/fetch/examples/http_client_json.rs
new file mode 100644
index 000000000..0985ea4e3
--- /dev/null
+++ b/crates/fetch/examples/http_client_json.rs
@@ -0,0 +1,65 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to use the HTTP client with REST endpoints.
+
+use std::borrow::Cow;
+
+use fetch::fake::FakeDeps;
+use fetch::{BaseUri, HttpClient};
+use templated_uri::{EscapedString, templated};
+
+use crate::crates_io_mock::crates_io_fake_handler;
+
+#[path = "util/crates_io_mock.rs"]
+mod crates_io_mock;
+
+#[templated(template = "/api/v1/crates/{crate_name}", unredacted)]
+#[derive(Clone)]
+struct CrateUrl {
+ crate_name: EscapedString,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let crate_name = EscapedString::from_static("serde");
+
+ let client =
+ HttpClient::new_fake(crates_io_fake_handler(crate_name.to_string())).with_base_uri(BaseUri::from_static("https://crates.io/"));
+
+ let watch = FakeDeps::default().clock.stopwatch();
+
+ let mut response = client
+ .get(CrateUrl {
+ crate_name: crate_name.clone(),
+ })
+ .header("User-Agent", "http-client")
+ .fetch_json::()
+ .await?
+ .into_body();
+
+ let response = response.read()?;
+
+ println!(
+ "crate: {}, downloads: {}, took: {} ms, description: '{}'",
+ crate_name,
+ response.model.downloads,
+ watch.elapsed().as_millis(),
+ response.model.description,
+ );
+
+ Ok(())
+}
+
+#[derive(serde::Deserialize, Debug)]
+struct CrateResponse<'a> {
+ #[serde(rename = "crate", borrow)]
+ model: Crate<'a>,
+}
+
+#[derive(serde::Deserialize, Debug)]
+struct Crate<'a> {
+ downloads: u64,
+ #[serde(borrow)]
+ description: Cow<'a, str>,
+}
diff --git a/crates/fetch/examples/http_client_minimal_pipeline.rs b/crates/fetch/examples/http_client_minimal_pipeline.rs
new file mode 100644
index 000000000..0d04d9ac8
--- /dev/null
+++ b/crates/fetch/examples/http_client_minimal_pipeline.rs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to create an HTTP client with a minimal pipeline.
+//!
+//! A minimal pipeline is useful in scenarios where the standard pipeline is not required
+//! and the client needs to be as lightweight as possible.
+
+use fetch::HttpClient;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ // This client does not use any middleware, it's as lightweight as possible.
+ let client = HttpClient::builder_tokio(fetch::tokio::TokioDeps::default())
+ .minimal_pipeline()
+ .build();
+
+ let response = client.get("https://example.com").fetch().await?;
+
+ println!("response, status code: {}", response.status());
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_mtls.rs b/crates/fetch/examples/http_client_mtls.rs
new file mode 100644
index 000000000..25aa3f63d
--- /dev/null
+++ b/crates/fetch/examples/http_client_mtls.rs
@@ -0,0 +1,126 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Mutual TLS (`mTLS`) authentication with the fetch HTTP client using the rustls backend.
+//!
+//! It loads a client certificate and private key from `PEM` files, configures the HTTP client
+//! with a client identity, and performs a GET request to a user-specified URL.
+//!
+//! Note: The fetch crate does not follow redirects automatically, so this example
+//! manually follows 3xx redirects by reading the `Location` header.
+//!
+//! # Usage
+//!
+//! ```sh
+//! cargo run -p fetch --example http_client_mtls --features fetch/tokio,fetch/rustls -- --cert client.pem --key client-key.pem --url https://example.com/api
+//! ```
+//!
+//! You can generate a self-signed client certificate for testing with:
+//! ```sh
+//! openssl req -x509 -newkey rsa:2048 -keyout client-key.pem -out client.pem -days 365 -nodes -subj "/CN=test"
+//! ```
+
+use std::process;
+
+use argh::FromArgs;
+use fetch::HttpClient;
+use fetch::tls::{ClientIdentity, TlsOptions};
+use serde_json::Value;
+use tracing::info;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+/// Demonstrates mutual TLS (`mTLS`) authentication with the fetch HTTP client.
+#[derive(FromArgs)]
+struct Args {
+ /// path to the client certificate `PEM` file
+ #[argh(option)]
+ cert: String,
+
+ /// path to the client private key `PEM` file
+ #[argh(option)]
+ key: String,
+
+ /// target URL to send the GET request to
+ #[argh(option)]
+ url: String,
+}
+
+/// Maximum number of redirects to follow before giving up.
+const MAX_REDIRECTS: u32 = 10;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ // When run without any arguments (e.g. by the workspace example runner in CI),
+ // there is no certificate/key/URL to exercise, so exit successfully instead of
+ // letting `argh` fail with a missing-argument error (exit code 1).
+ if std::env::args_os().nth(1).is_none() {
+ info!("No arguments provided; nothing to do. See the module docs for usage.");
+ return Ok(());
+ }
+
+ let args: Args = argh::from_env();
+
+ info!("Loading client certificate from: {}", args.cert);
+ info!("Loading client private key from: {}", args.key);
+
+ let cert_pem = std::fs::read(&args.cert).expect("failed to read certificate PEM file");
+ let key_pem = std::fs::read(&args.key).expect("failed to read private key PEM file");
+
+ // Create the client identity for mTLS.
+ let identity = ClientIdentity::from_pem(&cert_pem, &key_pem).expect("failed to parse PEM identity");
+
+ info!("Client identity loaded successfully");
+
+ // Build the HTTP client with the client identity configured.
+ let client = HttpClient::builder_tokio(fetch::tokio::TokioDeps::default())
+ .tls_options(TlsOptions::builder_rustls().client_identity(identity).build())
+ .build();
+
+ let mut url = args.url;
+
+ // The fetch crate does not follow redirects automatically,
+ // so we follow 3xx redirects manually up to MAX_REDIRECTS times.
+ for redirect_count in 0..=MAX_REDIRECTS {
+ info!("Sending GET request to {url} ...");
+
+ let response = client.get(url.as_str()).fetch().await?;
+ let status = response.status();
+ info!("Response status: {status}");
+
+ if status.is_redirection() {
+ let location = response
+ .headers()
+ .get(http::header::LOCATION)
+ .expect("redirect response missing Location header")
+ .to_str()
+ .expect("Location header is not valid UTF-8");
+
+ info!("Following redirect to: {location}");
+ location.clone_into(&mut url);
+ // Consume the redirect response body before continuing.
+ drop(response.into_body());
+ continue;
+ }
+
+ // Not a redirect — pretty-print the JSON response body.
+ let body_text = response.into_body().into_text().await?;
+ let json: Value = serde_json::from_str(&body_text).expect("response is not valid JSON");
+ let pretty = serde_json::to_string_pretty(&json).expect("failed to pretty-print JSON");
+
+ println!("\n--- Response Body ---");
+ println!("{pretty}");
+ println!("--- End ---");
+
+ if redirect_count > 0 {
+ info!("Followed {redirect_count} redirect(s) to reach final response");
+ }
+ return Ok(());
+ }
+
+ eprintln!("Error: too many redirects (>{MAX_REDIRECTS})");
+ process::exit(1);
+}
diff --git a/crates/fetch/examples/http_client_native_tls.rs b/crates/fetch/examples/http_client_native_tls.rs
new file mode 100644
index 000000000..88ce113e6
--- /dev/null
+++ b/crates/fetch/examples/http_client_native_tls.rs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to enable native TLS support for the HTTP client.
+
+use fetch::HttpClient;
+use fetch::tls::TlsOptions;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let client = HttpClient::builder_tokio(fetch::tokio::TokioDeps::default())
+ .tls_options(TlsOptions::new_native_tls())
+ .build();
+
+ let response = client.get("https://example.com").fetch().await?;
+
+ println!("Request completed with status: {}", response.status());
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_native_tls_mtls.rs b/crates/fetch/examples/http_client_native_tls_mtls.rs
new file mode 100644
index 000000000..6548e402e
--- /dev/null
+++ b/crates/fetch/examples/http_client_native_tls_mtls.rs
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates mutual TLS (`mTLS`) authentication with the fetch HTTP client
+//! using the platform native TLS backend (`SChannel` on Windows, Security Framework on macOS,
+//! `OpenSSL` on Linux).
+//!
+//! It loads a client certificate and `PKCS#8` private key from `PEM` files, configures the HTTP
+//! client with a client identity, and performs a GET request to a user-specified URL.
+//!
+//! # Usage
+//!
+//! ```sh
+//! cargo run -p fetch --example http_client_native_tls_mtls --features fetch/native-tls,fetch/tokio -- --cert client.pem --key client-key.pem --url https://example.com/api
+//! ```
+//!
+//! You can generate a self-signed client certificate (`PEM` + `PKCS#8` key) for testing with:
+//! ```sh
+//! openssl req -x509 -newkey rsa:2048 -keyout client-key.pem -out client.pem -days 365 -nodes -subj "/CN=test"
+//! ```
+
+use std::process;
+
+use argh::FromArgs;
+use fetch::HttpClient;
+use fetch::tls::{ClientIdentity, TlsOptions};
+use fetch::tokio::TokioDeps;
+use serde_json::Value;
+use tracing::info;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+/// Demonstrates mutual TLS (`mTLS`) authentication with the fetch HTTP client
+/// using the platform native TLS backend.
+#[derive(FromArgs)]
+struct Args {
+ /// path to the client certificate `PEM` file
+ #[argh(option)]
+ cert: String,
+
+ /// path to the `PKCS#8` `PEM` private key file
+ #[argh(option)]
+ key: String,
+
+ /// target URL to send the GET request to
+ #[argh(option)]
+ url: String,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ // When run without any arguments (e.g. by the workspace example runner in CI),
+ // there is no certificate/key/URL to exercise, so exit successfully instead of
+ // letting `argh` fail with a missing-argument error (exit code 1).
+ if std::env::args_os().nth(1).is_none() {
+ info!("No arguments provided; nothing to do. See the module docs for usage.");
+ return Ok(());
+ }
+
+ let args: Args = argh::from_env();
+
+ if !args.url.starts_with("https://") {
+ eprintln!("Error: --url must use the https:// scheme (got '{}')", args.url);
+ process::exit(2);
+ }
+
+ let identity = {
+ info!("Loading client certificate from: {}", args.cert);
+ info!("Loading client private key from: {}", args.key);
+
+ let cert_pem = std::fs::read(&args.cert).expect("failed to read certificate PEM file");
+ let key_pem = std::fs::read(&args.key).expect("failed to read private key PEM file");
+
+ ClientIdentity::from_pem(&cert_pem, &key_pem).expect("failed to parse PKCS#8 PEM identity")
+ };
+
+ info!("Client identity loaded successfully");
+
+ // Build the HTTP client with the native TLS backend and the client identity.
+ let client = HttpClient::builder_tokio(TokioDeps::default())
+ .tls_options(TlsOptions::builder().client_identity(identity).build())
+ .build();
+
+ info!("Sending GET request to {} ...", args.url);
+
+ let response = client.get(args.url.as_str()).fetch().await?;
+ info!("Response status: {}", response.status());
+
+ let body_text = response.into_body().into_text().await?;
+ let json: Value = serde_json::from_str(&body_text).expect("response is not valid JSON");
+ let pretty = serde_json::to_string_pretty(&json).expect("failed to pretty-print JSON");
+
+ println!("\n--- Response Body ---");
+ println!("{pretty}");
+ println!("--- End ---");
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_pooling.rs b/crates/fetch/examples/http_client_pooling.rs
new file mode 100644
index 000000000..26a7928d4
--- /dev/null
+++ b/crates/fetch/examples/http_client_pooling.rs
@@ -0,0 +1,35 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! # HTTP Client Pooling Example
+//!
+//! By default, the HTTP client only supports a single HTTP/2 connection per host.
+//! This can become a bottleneck when making many concurrent requests to the same endpoint.
+//!
+//! This example demonstrates how to work around this limitation by using multiple connection
+//! pools. Each pool maintains its own HTTP/2 connection, allowing for better parallelism
+//! and throughput when making concurrent requests.
+
+use fetch::HttpClient;
+use fetch::fake::FakeDeps;
+use fetch::options::{ConnectionPoolOptions, PoolSelection};
+use http::StatusCode;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default())
+ .minimal_pipeline()
+ .connection_pool_options(ConnectionPoolOptions::default().multiple_pools(10, PoolSelection::round_robin()))
+ .build();
+
+ for _ in 0..1000 {
+ _ = client.get("https://example.com").fetch().await?;
+ }
+
+ println!("example finished");
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_resilience.rs b/crates/fetch/examples/http_client_resilience.rs
new file mode 100644
index 000000000..737be2742
--- /dev/null
+++ b/crates/fetch/examples/http_client_resilience.rs
@@ -0,0 +1,42 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! An example of how the resilience of HTTP client can be customized.
+
+use std::time::Duration;
+
+use fetch::fake::{FakeDeps, FakeHandler};
+use fetch::resilience::retry::HttpRetryLayerExt;
+use fetch::{HttpClient, HttpResponse, StatusExt};
+use http::StatusCode;
+use seatbelt::retry::Backoff;
+
+#[path = "util/utils.rs"]
+mod utils;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ let fake_handler =
+ FakeHandler::from_status_codes([StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR, StatusCode::OK]);
+
+ let client = HttpClient::builder_fake(fake_handler, FakeDeps::default())
+ .standard_pipeline(|pipeline, _| {
+ // customize the retry resilience middleware
+ pipeline.retry(|retry| {
+ retry
+ .http_recovery(|response: &HttpResponse| response.recovery())
+ .max_retry_attempts(4)
+ .base_delay(Duration::ZERO)
+ .backoff(Backoff::Constant)
+ })
+ })
+ .build();
+
+ let response = client.get("https://www.example.com").fetch().await?;
+
+ println!("response: {}", response.status());
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_streaming.rs b/crates/fetch/examples/http_client_streaming.rs
new file mode 100644
index 000000000..60fd1d97b
--- /dev/null
+++ b/crates/fetch/examples/http_client_streaming.rs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! This example demonstrates how to use the `HttpClient` to download a response in a streaming
+//! manner and write it incrementally to a file.
+
+use std::io::{BufWriter, Write};
+
+use fetch::HttpClient;
+use futures::TryStreamExt;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let client = HttpClient::new_tokio();
+
+ // Fetch a simple GET request and convert the response body into a stream.
+ let body = client.get("https://example.com").fetch().await?.into_body();
+ let mut stream = body.into_stream();
+
+ let mut file = BufWriter::new(std::fs::File::create("output.txt")?);
+
+ while let Some(mut chunk) = stream.try_next().await? {
+ let size = chunk.len();
+
+ // Process each chunk as it arrives.
+ std::io::copy(&mut chunk, &mut file)?;
+ println!("Chunk stored to a file, size: {size}");
+ }
+
+ file.flush()?;
+ println!("File download completed.");
+
+ std::fs::remove_file("output.txt")?; // Clean up the file after use.
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_telemetry.rs b/crates/fetch/examples/http_client_telemetry.rs
new file mode 100644
index 000000000..44a684f3d
--- /dev/null
+++ b/crates/fetch/examples/http_client_telemetry.rs
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! # HTTP Client Telemetry Example
+//!
+//! This example demonstrates how to configure and use OpenTelemetry metrics with the fetch HTTP client.
+//! It shows:
+//!
+//! 1. Creating a custom meter provider with console output
+//! 2. Configuring an HTTP client to use that meter provider
+//! 3. Making requests with telemetry collection
+//! 4. Classification of URI components for safe telemetry
+//! 5. Using a fake handler for testing without real network requests
+
+use data_privacy::{DataClass, Sensitive};
+use fetch::HttpClient;
+use fetch::fake::FakeDeps;
+use fetch::telemetry::TelemetryAttributes;
+use http::StatusCode;
+use opentelemetry::KeyValue;
+use opentelemetry_sdk::metrics::SdkMeterProvider;
+use templated_uri::{BaseUri, EscapedString, Uri, templated};
+
+const UNKNOWN: DataClass = DataClass::new("unknown", "unknown");
+
+#[path = "util/utils.rs"]
+mod utils;
+
+#[templated(template = "/path/to{/public,secret}")]
+#[derive(Clone)]
+struct ResourcePath {
+ #[unredacted]
+ public: EscapedString,
+ secret: Sensitive,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ utils::init_tracing();
+
+ // Create a custom OpenTelemetry meter provider that outputs metrics to stdout.
+ // In a real application, you would configure this to send metrics to your monitoring system.
+ let meter_provider = SdkMeterProvider::builder()
+ .with_periodic_exporter(opentelemetry_stdout::MetricExporter::default())
+ .build();
+
+ // Configure an HTTP client with our custom meter provider. The client will record metrics
+ // using this provider instead of the global one.
+ //
+ // Instead of making real HTTP requests, we use a fake handler that returns
+ // simulated responses after a short delay.
+ let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default())
+ .meter_provider(&meter_provider)
+ .build();
+
+ // Make 10 requests to generate metrics for a request made with simple URI input.
+ // The client will record telemetry for each request.
+ for _ in 0..10 {
+ _ = client
+ .get("https://example.com/path/to/resource")
+ // You can also attach telemetry attributes for dynamic enrichment.
+ .extension(TelemetryAttributes::from_iter([KeyValue::new("extra", "extra_value")]))
+ .fetch()
+ .await?
+ .into_body();
+ }
+
+ let resource_path = ResourcePath {
+ public: EscapedString::from_static("public_resource"),
+ secret: Sensitive::new(EscapedString::from_static("secret_resource"), UNKNOWN),
+ };
+
+ let target = Uri::default()
+ .with_base(BaseUri::from_static("https://example.com"))
+ .with_path_and_query(resource_path);
+
+ // Make 10 requests to generate metrics for a request made with a templated URI.
+ for _ in 0..10 {
+ _ = client.get(target.clone()).fetch().await?.into_body();
+ }
+
+ println!("All requests completed! Check the console output for metrics.");
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/http_client_tokio.rs b/crates/fetch/examples/http_client_tokio.rs
new file mode 100644
index 000000000..3c3559d8b
--- /dev/null
+++ b/crates/fetch/examples/http_client_tokio.rs
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Demonstrates basic usage of the HTTP client on the Tokio runtime.
+
+use fetch::HttpClient;
+
+#[tokio::main]
+async fn main() -> Result<(), ohno::AppError> {
+ let client = HttpClient::new_tokio();
+
+ let response = client.get("https://example.com").fetch().await?;
+ println!("Request completed with status: {}", response.status());
+
+ Ok(())
+}
diff --git a/crates/fetch/examples/util/crates_io_mock.rs b/crates/fetch/examples/util/crates_io_mock.rs
new file mode 100644
index 000000000..2b9363d92
--- /dev/null
+++ b/crates/fetch/examples/util/crates_io_mock.rs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Mocked replies from "Crates.io" api for a specific crate / crate query
+use http::StatusCode;
+use http_extensions::{FakeHandler, HttpError, HttpResponse, HttpResponseBuilder};
+use serde_json::{Value, json};
+
+fn json_response(value: &Value) -> Result {
+ HttpResponseBuilder::new_fake().status(StatusCode::OK).json(value).build()
+}
+
+pub(super) fn crates_io_fake_handler(crate_name: String) -> FakeHandler {
+ FakeHandler::from_sync_handler(move |request| {
+ let path = request.uri().path();
+ let query = request.uri().query();
+ if path == format!("/api/v1/crates/{crate_name}") {
+ return json_response(&json!({
+ "crate": {
+ "name": "serde",
+ "downloads": 1337,
+ "description": "This is a mocked serde crate crates.io output"
+ }
+ }
+ ));
+ }
+ if path == "/api/v1/crates" && query == Some(&format!("q={crate_name}")) {
+ return json_response(&json!({
+ "crates": [
+ {
+ "name": "serde",
+ "downloads": 1337,
+ "description": "This is a mocked serde crate crates.io output"
+ },
+ {
+ "name": "serde_json",
+ "downloads": 42,
+ "description": "This is a mocked serde json crate crates.io output"
+ }
+ ]
+ }));
+ }
+ HttpResponseBuilder::new_fake()
+ .status(StatusCode::NOT_FOUND)
+ .text("Resource not found")
+ .build()
+ })
+}
diff --git a/crates/fetch/examples/util/utils.rs b/crates/fetch/examples/util/utils.rs
new file mode 100644
index 000000000..07f1473c1
--- /dev/null
+++ b/crates/fetch/examples/util/utils.rs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::mem;
+
+use tracing_appender::non_blocking;
+use tracing_subscriber::fmt;
+
+#[expect(clippy::allow_attributes, reason = "detection depends on how/where it gets built")]
+#[allow(dead_code, reason = "this is path-imported, so dead code detection has false positives")]
+pub fn init_tracing() {
+ let (non_blocking, guard) = non_blocking(std::io::stdout());
+
+ fmt().with_writer(non_blocking).with_max_level(tracing::Level::DEBUG).init(); // sets stdout as a default output for logs generated by oxidizer
+
+ #[expect(clippy::mem_forget, reason = "intentional, to keep it alive for lifetime of the example process")]
+ // This is intentional, as the "guard" needs to be alive for the entire duration of the program.
+ // Forgetting the guard here allows us to simplify this signature, and the caller is not required
+ // to keep the guard alive.
+ mem::forget(guard);
+}
diff --git a/crates/fetch/favicon.ico b/crates/fetch/favicon.ico
new file mode 100644
index 000000000..ccae81142
--- /dev/null
+++ b/crates/fetch/favicon.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e48225cb42c8ac02df5dd4a9bdba29c1e8d10436cc27055d6a021bcb951d4145
+size 480683
diff --git a/crates/fetch/logo.png b/crates/fetch/logo.png
new file mode 100644
index 000000000..f2bd66915
--- /dev/null
+++ b/crates/fetch/logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:63ad64ebb0185d7cd153acc291989d72e3a0094603d8bfe781a220861de247f2
+size 17876
diff --git a/crates/fetch/src/_documentation/examples.rs b/crates/fetch/src/_documentation/examples.rs
new file mode 100644
index 000000000..86b89dc9f
--- /dev/null
+++ b/crates/fetch/src/_documentation/examples.rs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Runnable examples for the [`fetch`](crate) crate.
+//!
+//! The full, runnable source for every example lives in the
+//! [`crates/fetch/examples`](https://github.com/microsoft/oxidizer/tree/main/crates/fetch/examples)
+//! folder on GitHub. Each example is a standalone binary; the simplest way to
+//! run any of them is to enable every feature with `--all-features`.
+//!
+//! For example:
+//!
+//! ```sh
+//! cargo run -p fetch --all-features --example http_client_tokio
+//! ```
+//!
+//! # Getting started
+//!
+//! | Example | Description |
+//! |---------|-------------|
+//! | [`http_client_tokio`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_tokio.rs) | Minimal Tokio-runtime client that issues a couple of GET requests, including one from a spawned task. |
+//! | [`http_client_json`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_json.rs) | Deserializes a JSON response into a borrowed struct with [`fetch_json`](crate::HttpRequestBuilder::fetch_json) against a fake handler. |
+//! | [`http_client_streaming`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_streaming.rs) | Downloads a response incrementally as a [`Stream`](futures::Stream) and writes each chunk to a file. |
+//!
+//! # Configuration & pipelines
+//!
+//! | Example | Description |
+//! |---------|-------------|
+//! | [`http_client_minimal_pipeline`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_minimal_pipeline.rs) | Builds a lightweight client with [`minimal_pipeline`](crate::HttpClientBuilder::minimal_pipeline) (no middleware). |
+//! | [`http_client_advanced`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_advanced.rs) | Extensive configuration: connection keep-alive, pooling, HTTP/2 options, a custom rustls verifier, and resilience tuning. |
+//! | [`http_client_customization`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_customization.rs) | Customizes the standard pipeline's timeout, retry, and intercept layers. |
+//! | [`http_client_custom_pipeline`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_custom_pipeline.rs) | Replaces the standard pipeline with a fully custom layer stack via [`custom_pipeline`](crate::HttpClientBuilder::custom_pipeline). |
+//! | [`http_client_custom`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_custom.rs) | Plugs a custom transport handler (an echo handler) into [`fetch::custom::create_builder`](crate::custom). |
+//! | [`http_client_pooling`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_pooling.rs) | Uses multiple connection pools to work around the single-HTTP/2-connection-per-host limit. |
+//! | [`http_client_connection_scaling`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_connection_scaling.rs) | Fires a burst of concurrent requests to show the pool scaling connections up and down. |
+//! | [`http_client_api_with_templated_uri`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_api_with_templated_uri.rs) | Wraps a REST API in a typed client using templated URIs for multiple endpoints. |
+//! | [`http_client_app`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_app.rs) | Assembles an application with [`fundle`] dependency injection, telemetry, and a custom rustls verifier. |
+//!
+//! # Resilience
+//!
+//! | Example | Description |
+//! |---------|-------------|
+//! | [`http_client_resilience`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_resilience.rs) | Customizes retry behavior (attempts, backoff, recovery) against a fake handler that fails then succeeds. |
+//! | [`http_client_breaker`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_breaker.rs) | Demonstrates the per-origin circuit breaker tripping for a failing host while a healthy host keeps working. |
+//!
+//! # TLS & mutual TLS
+//!
+//! | Example | Description |
+//! |---------|-------------|
+//! | [`http_client_native_tls`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_native_tls.rs) | Switches the client to the platform native-TLS backend. |
+//! | [`http_client_mtls`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_mtls.rs) | Mutual TLS with the rustls backend using a `PEM` client identity, following redirects manually. |
+//! | [`http_client_native_tls_mtls`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_native_tls_mtls.rs) | Mutual TLS with the native-TLS backend using a `PEM` client identity. |
+//!
+//! # Telemetry & testing
+//!
+//! | Example | Description |
+//! |---------|-------------|
+//! | [`http_client_telemetry`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_telemetry.rs) | Wires a custom OpenTelemetry meter provider and shows URI-component classification for safe metrics. |
+//! | [`http_client_fake`](https://github.com/microsoft/oxidizer/blob/main/crates/fetch/examples/http_client_fake.rs) | Mocks the transport with a [`FakeHandler`](crate::fake::FakeHandler) returning canned responses. |
+//!
+//! [`fundle`]: https://docs.rs/fundle
diff --git a/crates/fetch/src/_documentation/mod.rs b/crates/fetch/src/_documentation/mod.rs
new file mode 100644
index 000000000..dd5b57772
--- /dev/null
+++ b/crates/fetch/src/_documentation/mod.rs
@@ -0,0 +1,9 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Longer-form documentation for [`fetch`](crate).
+
+pub mod examples;
+pub mod telemetry;
+
+pub use http_extensions::_documentation::recipes as http_recipes;
diff --git a/crates/fetch/src/_documentation/telemetry.rs b/crates/fetch/src/_documentation/telemetry.rs
new file mode 100644
index 000000000..35fe1f5ca
--- /dev/null
+++ b/crates/fetch/src/_documentation/telemetry.rs
@@ -0,0 +1,117 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+//! Telemetry emitted by the [`fetch`](crate) crate.
+//!
+//! All metrics are recorded under the **`fetch`** OpenTelemetry
+//! [`Meter`](opentelemetry::metrics::Meter). The meter is obtained from either
+//! the global [`MeterProvider`](opentelemetry::metrics::MeterProvider) or from
+//! a custom provider supplied via
+//! [`HttpClientBuilder::meter_provider`](crate::HttpClientBuilder::meter_provider).
+//!
+//! # Metrics
+//!
+//! | Metric | Instrument | Unit | Emitted when |
+//! |--------|-----------|------|--------------|
+//! | [`http.client.request.duration`](#httpclientrequestduration) | `Histogram` | `s` | Every HTTP request completes (success **or** failure) |
+//! | [`http.client.connection.setup.duration`](#httpclientconnectionsetupduration) | `Histogram` | `s` | A TCP/TLS connection attempt finishes (success **or** failure) |
+//! | [`http.client.connection.duration`](#httpclientconnectionduration) | `Histogram` | `s` | A connection is closed (the underlying stream is dropped) |
+//!
+//! ---
+//!
+//! ## `http.client.request.duration`
+//!
+//! Measures the total wall-clock time of an HTTP request from the moment
+//! the request enters [`Metrics`](crate::handlers::Metrics)
+//! until a response (or error) is returned. Follows the
+//! [OpenTelemetry `http.client.request.duration`](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration)
+//! semantic convention.
+//!
+//! **Histogram boundaries (seconds):** `0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0`
+//!
+//! ### Attributes
+//!
+//! | Attribute | Required | Description | Sample value |
+//! |-----------|----------|-------------|--------------|
+//! | `http.request.method` | always | HTTP method of the request | `"GET"` |
+//! | `server.address` | always | Hostname (authority) of the target server | `"api.example.com"` |
+//! | `server.port` | when derivable | Port number, inferred from scheme when absent (`443` for HTTPS, `80` for HTTP) | `443` |
+//! | `url.scheme` | always | URI scheme | `"https"` |
+//! | `url.template` | optional | URL template or label when the request was built from a [`templated`](templated_uri::templated) URI | `"/api/v1/crates/{crate_name}"` |
+//! | `network.protocol.name` | on success | Network protocol name | `"http"` |
+//! | `network.protocol.version` | on success | Negotiated HTTP version of the response | `"1.1"` |
+//! | `http.response.status_code` | on success | HTTP status code of the response | `200` |
+//! | `error.type` | on failure | A short, metrics-friendly label classifying the error | `"io"` |
+//!
+//! > **Custom attributes.** Any [`TelemetryAttributes`](crate::telemetry::TelemetryAttributes)
+//! > attached to the request or response extensions are merged into the
+//! > attribute set. This allows callers to inject domain-specific dimensions.
+//!
+//! ---
+//!
+//! ## `http.client.connection.setup.duration`
+//!
+//! Measures the time it takes to establish a new connection (TCP + TLS
+//! handshake). Recorded once per connection attempt — on both success and
+//! failure.
+//!
+//! This metric is only available when the Hyper-based transport is in use
+//! (the default for the Tokio runtime).
+//!
+//! **Histogram boundaries (seconds):** `0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, 25.0, 50.0`
+//!
+//! ### Attributes
+//!
+//! | Attribute | Required | Description | Sample value |
+//! |-----------|----------|-------------|--------------|
+//! | `server.address` | always | Hostname of the target server | `"api.example.com"` |
+//! | `server.port` | always | Port number of the target server | `443` |
+//! | `url.scheme` | always | URI scheme | `"https"` |
+//! | `network.protocol.version` | on success | Negotiated protocol version (`"2"` for HTTP/2, `"1"` for HTTP/1) | `"2"` |
+//! | `error.type` | on failure | Label classifying the failure | `"timeout"` |
+//!
+//! ---
+//!
+//! ## `http.client.connection.duration`
+//!
+//! Measures the total lifetime of a connection — from the moment it was
+//! successfully established until the underlying stream is dropped (closed).
+//! Useful for understanding connection reuse and pool behavior.
+//!
+//! This metric is only available when the Hyper-based transport is in use.
+//!
+//! **Histogram boundaries (seconds):** `0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0`
+//!
+//! ### Attributes
+//!
+//! | Attribute | Required | Description | Sample value |
+//! |-----------|----------|-------------|--------------|
+//! | `server.address` | always | Hostname of the target server | `"api.example.com"` |
+//! | `server.port` | always | Port number of the target server | `443` |
+//! | `url.scheme` | always | URI scheme | `"https"` |
+//! | `network.protocol.version` | always | Negotiated protocol version (`"2"` for HTTP/2, `"1"` for HTTP/1) | `"2"` |
+//!
+//! ---
+//!
+//! # Error labels
+//!
+//! The `error.type` attribute is a dot-separated label chain built by walking
+//! the error's `source()` chain outermost-first, pinpointing *where* the
+//! failure occurred:
+//!
+//! ```text
+//! "request_hyper.connect.timed_out"
+//! ^^^^^^^^^^^^^^ ^^^^^^^ ^^^^^^^^^
+//! transport phase IO error kind
+//! ```
+//!
+//! ## Examples
+//!
+//! | `error.type` | What happened |
+//! |--------------|---------------|
+//! | `request_hyper.connect.timed_out` | TCP/TLS handshake timed out |
+//! | `request_hyper.connect.connection_refused` | Server refused the connection |
+//! | `request_hyper.connect.other` | TLS or unclassified connection error |
+//! | `scheme_not_allowed` | HTTP scheme blocked before reaching the network |
+//! | `content_encoding_unsupported` | Response used an encoding the client cannot decode |
+//! | `abandoned` | Caller dropped the future (e.g. outer timeout) |
diff --git a/crates/fetch/src/client.rs b/crates/fetch/src/client.rs
new file mode 100644
index 000000000..a8c7da881
--- /dev/null
+++ b/crates/fetch/src/client.rs
@@ -0,0 +1,629 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use std::future::ready;
+use std::sync::Arc;
+use std::time::Duration;
+
+use bytesbuf::BytesBuf;
+use bytesbuf::mem::{HasMemory, Memory, MemoryShared};
+use futures::FutureExt;
+use futures::future::Either;
+use http::Method;
+use http_extensions::routing::{BaseUriConflict, Router, RouterContext};
+use http_extensions::timeout::ResponseTimeout;
+use http_extensions::{HttpRequestBuilder, HttpRequestBuilderExt};
+use layered::Service;
+use templated_uri::{BaseUri, Uri};
+use thread_aware::{PerCore, ThreadAware};
+use tick::{Clock, FutureExt as TimeoutExt};
+
+use crate::pipeline::Pipeline;
+use crate::{HttpBodyBuilder, HttpError, HttpRequest, HttpResponse, Result};
+
+/// A runtime-agnostic HTTP client for sending HTTP requests.
+///
+/// `HttpClient` provides a high-level, fluent API for common HTTP operations over a
+/// configurable transport. It runs on the Tokio runtime by default, but any runtime
+/// and transport can be plugged in (see the [`custom`](crate::custom) module).
+///
+/// > **Tip**: Cloning the client is cheap and results in instances that share the underlying connection
+/// > pool and configuration.
+///
+/// # Examples
+///
+/// ```
+/// # use http::header::USER_AGENT;
+/// # use fetch::HttpClient;
+/// # async fn example(client: &HttpClient) -> Result<(), Box> {
+/// // Make a GET request
+/// let response = client
+/// .get("https://example.com")
+/// .header(USER_AGENT, "MyApp/1.0")
+/// .fetch()
+/// .await?;
+///
+/// println!("Status: {}", response.status());
+/// # Ok(())
+/// # }
+/// ```
+///
+/// See [crate-level][`crate`] documentation for more details on available configuration options
+/// and advanced usage scenarios.
+#[derive(Debug, Clone, ThreadAware)]
+pub struct HttpClient {
+ pipeline: HttpClientPipeline,
+ body_builder: HttpBodyBuilder,
+ clock: Clock,
+ #[thread_aware(skip)]
+ router: Router,
+}
+
+impl HttpClient {
+ /// Creates a request builder with the specified method and URI.
+ ///
+ /// This is the most flexible way to build requests. For common HTTP methods like
+ /// GET or POST, you can use the dedicated helper methods instead.
+ ///
+ /// # Performance tip
+ ///
+ /// While you can provide strings for the method and URI, using pre-created
+ /// [`Method`] and [`Uri`] instances is more efficient as it avoids parsing overhead.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use http::header::USER_AGENT;
+ /// use fetch::HttpClient;
+ /// # async fn example(client: &HttpClient) -> Result<(), Box> {
+ /// // Using strings (convenient but with parsing overhead)
+ /// let response = client
+ /// .request("GET", "https://example.com/api")
+ /// .fetch()
+ /// .await?;
+ ///
+ /// // Using pre-parsed values (more efficient) and additional customization
+ /// // before fetching the response.
+ /// let method = http::Method::GET;
+ /// let uri = "https://example.com/api".parse::()?;
+ /// let response = client
+ /// .request(method, uri)
+ /// .header(USER_AGENT, "MyApp/1.0")
+ /// .fetch()
+ /// .await?;
+ ///
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn request(
+ &self,
+ method: impl TryInto>,
+ uri: impl TryInto>,
+ ) -> HttpRequestBuilder<'_, Self> {
+ self.request_builder().method(method).uri(uri)
+ }
+
+ /// Creates a GET request to the specified URI.
+ ///
+ /// This is a convenient shortcut for `request(Method::GET, uri)`.
+ ///
+ /// # Performance tip
+ ///
+ /// While you can provide a string for the URI, using a pre-created [`Uri`]
+ /// instance is more efficient as it avoids parsing overhead.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use fetch::{HttpClient, HttpResponse, Response};
+ /// # async fn example(client: &HttpClient) -> Result<(), Box> {
+ /// // Basic GET request
+ /// let response: HttpResponse = client.get("https://example.com").fetch().await?;
+ ///
+ /// // GET with additional customization
+ /// let response: HttpResponse = client
+ /// .get("https://api.example.com/users")
+ /// .header("X-API-Key", "my-key")
+ /// .fetch()
+ /// .await?;
+ ///
+ /// // Get response as text
+ /// let body: Response = client.get("https://example.com").fetch_text().await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn get(&self, uri: impl TryInto>) -> HttpRequestBuilder<'_, Self> {
+ self.request(Method::GET, uri)
+ }
+
+ /// Creates a POST request to the specified URI.
+ ///
+ /// This is a convenient shortcut for `request(Method::POST, uri)`.
+ ///
+ /// # Performance tip
+ ///
+ /// While you can provide a string for the URI, using a pre-created [`Uri`]
+ /// instance is more efficient as it avoids parsing overhead.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use http::header::USER_AGENT;
+ /// # use fetch::HttpClient;
+ /// # async fn example(client: &HttpClient) -> Result<(), Box> {
+ ///
+ /// // Simple POST without a body
+ /// let response = client.post("https://api.example.com/users").fetch().await?;
+ ///
+ /// // POST with text body and additional customization
+ /// let response = client
+ /// .post("https://api.example.com/users")
+ /// .header(USER_AGENT, "MyApp/1.0")
+ /// .text("my-text")
+ /// .fetch()
+ /// .await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn post(&self, uri: impl TryInto>) -> HttpRequestBuilder<'_, Self> {
+ self.request(Method::POST, uri)
+ }
+
+ /// Creates a DELETE request to the specified URI.
+ ///
+ /// This is a convenient shortcut for `request(Method::DELETE, uri)`.
+ ///
+ /// # Performance tip
+ ///
+ /// While you can provide a string for the URI, using a pre-created [`Uri`]
+ /// instance is more efficient as it avoids parsing overhead.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use fetch::HttpClient;
+ /// # async fn example(client: &HttpClient) -> Result<(), Box> {
+ /// // Delete a resource
+ /// let response = client
+ /// .delete("https://api.example.com/users/123")
+ /// .header("Authorization", "Bearer token")
+ /// .fetch()
+ /// .await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn delete(&self, uri: impl TryInto>) -> HttpRequestBuilder<'_, Self> {
+ self.request(Method::DELETE, uri)
+ }
+
+ /// Creates a HEAD request to the specified URI.
+ ///
+ /// This is a convenient shortcut for `request(Method::HEAD, uri)`. HEAD requests are
+ /// similar to GET but return only headers without a body, useful for checking if a
+ /// resource exists or has been modified.
+ ///
+ /// # Performance tip
+ ///
+ /// While you can provide a string for the URI, using a pre-created [`Uri`]
+ /// instance is more efficient as it avoids parsing overhead.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use fetch::HttpClient;
+ /// # async fn example(client: &HttpClient) -> Result<(), Box> {
+ /// // Check if a resource exists without downloading it
+ /// let response = client
+ /// .head("https://example.com/large-file.zip")
+ /// .fetch()
+ /// .await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn head(&self, uri: impl TryInto>) -> HttpRequestBuilder<'_, Self> {
+ self.request(Method::HEAD, uri)
+ }
+
+ /// Creates a PUT request to the specified URI.
+ ///
+ /// This is a convenient shortcut for `request(Method::PUT, uri)`.
+ ///
+ /// # Performance tip
+ ///
+ /// While you can provide a string for the URI, using a pre-created [`Uri`]
+ /// instance is more efficient as it avoids parsing overhead.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use http::header::USER_AGENT;
+ /// # use fetch::HttpClient;
+ /// # async fn example(client: &HttpClient) -> Result<(), Box> {
+ ///
+ /// // Simple PUT without a body
+ /// let response = client.put("https://api.example.com/users").fetch().await?;
+ ///
+ /// // PUT with text body and additional customization
+ /// let response = client
+ /// .put("https://api.example.com/users")
+ /// .header(USER_AGENT, "MyApp/1.0")
+ /// .text("my-text")
+ /// .fetch()
+ /// .await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn put(&self, uri: impl TryInto>) -> HttpRequestBuilder<'_, Self> {
+ self.request(Method::PUT, uri)
+ }
+
+ /// Creates a PATCH request to the specified URI.
+ ///
+ /// This is a convenient shortcut for `request(Method::PATCH, uri)`. PATCH requests are used
+ /// for partial updates to resources, modifying only the specified fields rather than
+ /// replacing the entire resource.
+ ///
+ /// # Performance tip
+ ///
+ /// While you can provide a string for the URI, using a pre-created [`Uri`]
+ /// instance is more efficient as it avoids parsing overhead.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use http::header::USER_AGENT;
+ /// # use fetch::HttpClient;
+ /// # async fn example(client: &HttpClient) -> Result<(), Box> {
+ /// // Partially update a resource with JSON payload
+ /// let response = client
+ /// .patch("https://api.example.com/users/123")
+ /// .header(USER_AGENT, "MyApp/1.0")
+ /// .text("some content")
+ /// .fetch()
+ /// .await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub fn patch(&self, uri: impl TryInto) -> HttpRequestBuilder<'_, Self> {
+ self.request(Method::PATCH, uri)
+ }
+
+ pub(super) fn new(pipeline: HttpClientPipeline, body_builder: HttpBodyBuilder, clock: Clock, router: Router) -> Self {
+ Self {
+ pipeline,
+ body_builder,
+ clock,
+ router,
+ }
+ }
+
+ /// Returns a new `HttpClient` that uses the given base URI for all requests.
+ ///
+ /// The new client shares this client's pipeline and configuration; only the
+ /// base URI differs. The original client is left unchanged.
+ #[must_use]
+ pub fn with_base_uri(&self, base_uri: BaseUri) -> Self {
+ Self {
+ pipeline: self.pipeline.clone(),
+ body_builder: self.body_builder.clone(),
+ clock: self.clock.clone(),
+ // Preserve historical semantics: the client's base URI overrides any endpoint
+ // already present on the request URI.
+ router: Router::fixed(base_uri).conflict_policy(BaseUriConflict::UseRouted),
+ }
+ }
+
+ pub(super) fn pipeline(&self) -> &Pipeline {
+ match &self.pipeline {
+ HttpClientPipeline::Shared(p) => p,
+ HttpClientPipeline::Isolated(p) => p,
+ }
+ }
+}
+
+impl AsRef for HttpClient {
+ fn as_ref(&self) -> &HttpBodyBuilder {
+ &self.body_builder
+ }
+}
+
+impl Memory for HttpClient {
+ fn reserve(&self, min_bytes: usize) -> BytesBuf {
+ self.body_builder.memory().reserve(min_bytes)
+ }
+}
+
+impl HasMemory for HttpClient {
+ fn memory(&self) -> impl MemoryShared {
+ self.body_builder.clone()
+ }
+}
+
+impl Service for HttpClient {
+ type Out = Result;
+
+ fn execute(&self, mut input: HttpRequest) -> impl Future