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 Logo + +# Fetch + +[![crate.io](https://img.shields.io/crates/v/fetch.svg)](https://crates.io/crates/fetch) +[![docs.rs](https://docs.rs/fetch/badge.svg)](https://docs.rs/fetch) +[![MSRV](https://img.shields.io/crates/msrv/fetch)](https://crates.io/crates/fetch) +[![CI](https://github.com/microsoft/oxidizer/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/oxidizer/actions/workflows/main.yml) +[![Coverage](https://codecov.io/gh/microsoft/oxidizer/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/oxidizer) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) +This crate was developed as part of the Oxidizer project + +
+ +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> + Send { + let timeout = input + .extensions() + .get::() + .map_or_else(|| Duration::MAX, ResponseTimeout::duration); + + // Make the router available to downstream layers (retry, hedging) via request + // extensions so they can re-resolve the URI against an alternative endpoint on each + // attempt. Attaching router only make sense if it can provide alternative endpoints. + if self.router.has_alternatives() { + input.extensions_mut().insert(self.router.clone()); + } + + match self.router.resolve_request_uri(RouterContext::default(), &mut input) { + Err(e) => Either::Left(ready(Err(e))), + Ok(()) => Either::Right( + self.pipeline() + .execute(input) + .timeout(&self.clock, timeout) + .map(move |outcome| match outcome { + Ok(inner) => inner, + Err(_) => Err(HttpError::timeout(timeout)), + }), + ), + } + } +} + +#[derive(ThreadAware, Clone, Debug)] +pub(super) enum HttpClientPipeline { + Shared(#[thread_aware(skip)] Arc), + Isolated(thread_aware::Arc), +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use http::{Request, StatusCode, Uri}; + use http_extensions::FakeHandler; + use ohno::ErrorExt; + use seatbelt::{Recovery, RecoveryKind}; + use static_assertions::assert_impl_all; + + use super::*; + use crate::HttpResponseBuilder; + use crate::error_labels::collect_error_labels; + use crate::fake::FakeDeps; + + #[cfg_attr(miri, ignore)] + #[test] + fn assert_send() { + assert_impl_all!(HttpClient: Send, Sync, Clone, ThreadAware); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn assert_fetch_future_not_large() { + let client = HttpClient::new_fake(StatusCode::OK); + + let future = client.get("http://example.com").fetch(); + let size = size_of_val(&future); + + // last verified future size 784 + assert!(size < 2000, "future size is too large, size: {size}"); + + println!("size of future: {size}"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn http_not_allowed_ensure_rejected() { + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()).build(); + + let err = client.get("http://example.com").fetch_text().await.unwrap_err(); + + assert_eq!( + err.message(), + "unable to communicate with 'http://example.com', because the 'http' scheme is not allowed by this HTTP client" + ); + assert_eq!(collect_error_labels(&err), "scheme_not_allowed"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_post_method() { + test_method(super::HttpClient::post, Method::POST).await; + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_delete_method() { + test_method(super::HttpClient::delete, Method::DELETE).await; + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_head_method() { + test_method(super::HttpClient::head, Method::HEAD).await; + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_put_method() { + test_method(super::HttpClient::put, Method::PUT).await; + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_patch_method() { + test_method(super::HttpClient::patch, Method::PATCH).await; + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_request_method_with_string_params() { + let fake = FakeHandler::from_sync_handler(|request| { + assert_eq!(request.method(), http::Method::DELETE); + assert_eq!(request.uri().to_string(), "https://example.com/path"); + HttpResponseBuilder::new_fake().status(StatusCode::IM_A_TEAPOT).build() + }); + let client = HttpClient::new_fake(fake); + + let response = client.request("DELETE", "https://example.com/path").fetch().await.unwrap(); + + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn fetch_fails_when_router_uri_resolution_conflicts() { + use http_extensions::routing::BaseUriConflict; + + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .router(Router::fixed(BaseUri::from_static("https://api.example.com")).conflict_policy(BaseUriConflict::Fail)) + .build(); + + // The request targets a different absolute base URI than the router's fixed base URI. + // With a `Fail` conflict policy the router rejects resolution in `execute`, short-circuiting + // before the request ever reaches the pipeline. + let err = client.get("https://existing.example.com/items").fetch().await.unwrap_err(); + + assert_eq!(collect_error_labels(&err), "uri_conflict"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_request_handler_implementation() { + let client = HttpClient::new_fake(StatusCode::ACCEPTED); + + // Create a test request + let request = Request::builder() + .method(http::Method::GET) + .uri("https://example.com") + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + // Test the RequestHandler implementation + let response = client.execute(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::ACCEPTED); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_minimal_pipeline() { + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .minimal_pipeline() + .build(); + + let response = client.get("https://example.com").fetch().await.unwrap(); + assert!(matches!(client.pipeline(), Pipeline::Minimal(_))); + assert_eq!(response.status(), StatusCode::OK); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_has_memory() { + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .minimal_pipeline() + .build(); + + let memory = client.memory(); + let sb = memory.reserve(123_456); + assert!(sb.capacity() >= 123_456); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_memory() { + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .minimal_pipeline() + .build(); + + let sb = client.reserve(123_456); + assert!(sb.capacity() >= 123_456); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_custom_pipeline() { + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .custom_pipeline(|_root, _ctx| FakeHandler::from(StatusCode::IM_A_TEAPOT)) + .build(); + + let response = client.get("https://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + } + + // Test error handling with invalid URIs + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_invalid_uri_handling() { + let client = HttpClient::new_fake(StatusCode::OK); + + let error = client.get("not a valid uri").fetch().await.unwrap_err(); + + assert_eq!(error.recovery().kind(), RecoveryKind::Never); + assert_eq!(collect_error_labels(&error), "uri_invalid"); + assert!( + error.to_string().starts_with("invalid uri character"), + "Unexpected error message: {error}" + ); + } + + async fn test_method(callback: impl Fn(&HttpClient, Uri) -> HttpRequestBuilder<'_, HttpClient>, method: Method) { + let uri = Uri::from_static("https://example.com/test"); + let uri_cloned = uri.clone(); + + let client = HttpClient::new_fake(FakeHandler::from_sync_handler(move |request| { + assert_eq!(request.method(), method); + assert_eq!(request.uri().path(), "/test"); + HttpResponseBuilder::new_fake().build() + })); + + callback(&client, uri_cloned).fetch().await.unwrap(); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn multiple_pools_creates_pooled_dispatch_handler() { + use crate::handlers::DispatchMode; + use crate::options::{ConnectionPoolOptions, PoolSelection}; + use crate::pipeline::Pipeline; + + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .minimal_pipeline() + .connection_pool_options( + ConnectionPoolOptions::default().multiple_pools(3, PoolSelection::saturating(PoolSelection::DEFAULT_REQUESTS_PER_CLIENT)), + ) + .build(); + + let Pipeline::Minimal(dispatch) = client.pipeline() else { + panic!("Expected minimal pipeline"); + }; + + let DispatchMode::Pooled { transports, .. } = &dispatch.mode else { + panic!("Expected pooled dispatch handler mode"); + }; + + assert_eq!(transports.len(), 3); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn multiple_pools_with_count_one_creates_single_dispatch_handler() { + use crate::handlers::DispatchMode; + use crate::options::{ConnectionPoolOptions, PoolSelection}; + use crate::pipeline::Pipeline; + + let mut pools = ConnectionPoolOptions::default(); + pools.multiple_pools = Some((1, PoolSelection::saturating(PoolSelection::DEFAULT_REQUESTS_PER_CLIENT))); + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .minimal_pipeline() + .connection_pool_options(pools) + .build(); + + let Pipeline::Minimal(dispatch) = client.pipeline() else { + panic!("Expected minimal pipeline"); + }; + + assert!( + matches!(&dispatch.mode, DispatchMode::Single(_)), + "Expected single dispatch handler mode for pool_count=1" + ); + } +} diff --git a/crates/fetch/src/client_builder.rs b/crates/fetch/src/client_builder.rs new file mode 100644 index 000000000..8ab17177f --- /dev/null +++ b/crates/fetch/src/client_builder.rs @@ -0,0 +1,736 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::borrow::Cow; +use std::fmt::Debug; +use std::time::Duration; + +use data_privacy::RedactionEngine; +use http_extensions::routing::{BaseUriConflict, Router}; +use http_extensions::{HttpBodyOptions, HttpRequest, HttpResponse}; +use opentelemetry::metrics::{Meter, MeterProvider}; +use seatbelt::ResilienceContext; +use thread_aware::ThreadAware; + +use crate::client::HttpClientPipeline; +use crate::constants::DEFAULT_HTTP_CLIENT_NAME; +use crate::custom::{Isolation, Transport}; +use crate::handlers::{Dispatch, DispatchMode}; +use crate::options::{ClientOptions, ConnectionKeepAlive, ConnectionPoolOptions, Http2Options, PoolIndex, RequestFilter}; +use crate::pipeline::{CustomPipelineFactory, Pipeline, PipelineBuilder, PipelineContext, StandardRequestPipeline}; +use crate::resilience::HttpResilienceContext; +use crate::telemetry::Metering; +use crate::tls::TlsOptions; +use crate::{BaseUri, RequestHandler}; + +/// Builder for creating and configuring an HTTP client. +/// +/// This builder follows the builder pattern, allowing you to customize +/// various aspects of the [`HttpClient`](super::HttpClient) before creating it. +/// Each configuration method returns `self` for method chaining. +/// +/// By default, the builder is configured to use a standard pipeline that includes +/// resilience features and observability (logging, metrics). +/// This behavior can be modified with methods like [`minimal_pipeline`](Self::minimal_pipeline) +/// or [`custom_pipeline`](Self::custom_pipeline). +/// +/// # Construction +/// +/// The builder instance is created using the `HttpClient::builder_tokio` method, or +/// the free-standing [`custom::create_builder`][crate::custom::create_builder] function +/// for a custom transport. +#[derive(Debug, Clone)] +#[must_use] +pub struct HttpClientBuilder { + pub(crate) options: ClientOptions, + pipeline_builder: PipelineBuilder, + metering: Metering, + transport: Transport, + resilience_context: HttpResilienceContext, +} + +impl HttpClientBuilder { + pub(super) fn new(transport: Transport) -> Self { + let clock = transport.clock().clone(); + + Self { + options: ClientOptions::default(), + pipeline_builder: PipelineBuilder::default(), + metering: Metering::Global, + transport, + resilience_context: HttpResilienceContext::new(&clock).name(DEFAULT_HTTP_CLIENT_NAME).use_logs(), + } + } + + /// Sets the name for the HTTP client. + /// + /// The name is used in logging and metrics to identify the HTTP client instance. The name should + /// follow the `snake_case` convention. By default, the client is named "`http_client`". + pub fn name(mut self, name: impl Into>) -> Self { + self.resilience_context = self.resilience_context.name(name); + self + } + + /// Allows insecure HTTP connections. + /// + /// By default, the client only permits HTTPS connections. This method enables + /// both HTTP and HTTPS requests. Use this for testing or internal networks only. + /// + /// # Security + /// + /// HTTP connections are unencrypted and can be intercepted. Use with caution! + pub fn insecure_allow_http(mut self) -> Self { + self.options.transport.request_filter = RequestFilter::HttpAndHttps; + self + } + + /// Sets the connection timeout duration. + /// + /// This sets how long to wait for a connection to be established before giving up. + /// If not specified, a default value of 30 seconds will be used. + pub const fn connect_timeout(mut self, timeout: Duration) -> Self { + self.options.transport.connect_timeout = timeout; + self + } + + /// Configures how HTTP connections are kept alive. + /// + /// Keep-alive maintains open or idle connections, reducing latency for subsequent requests + /// by avoiding the overhead of establishing new TCP connections. + /// + /// By default, this value is set to [`ConnectionKeepAlive::disabled`]. + pub fn connection_keep_alive(mut self, mode: ConnectionKeepAlive) -> Self { + self.options.transport.connection_keep_alive = mode; + self + } + + /// Sets the body options applied to every response produced by this client. + /// + /// [`HttpBodyOptions`] controls body-level policies such as the buffer + /// limit (maximum memory used when buffering via + /// [`HttpBody::into_buffered`](crate::HttpBody::into_buffered)) and the + /// idle timeout between body frames. + /// + /// By default, [`HttpBodyOptions::default()`] is used. + /// + /// # Example + /// + /// ``` + /// # #[cfg(feature = "test-util")] + /// # { + /// # use http_extensions::HttpBodyOptions; + /// # use std::time::Duration; + /// # use fetch::HttpClient; + /// # use fetch::fake::FakeDeps; + /// # use http::StatusCode; + /// # let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()); + /// let options = HttpBodyOptions::default() + /// .buffer_limit(4 * 1024 * 1024) + /// .timeout(Duration::from_secs(30)); + /// + /// let client = builder.response_body_options(options).build(); + /// # } + /// ``` + pub const fn response_body_options(mut self, options: HttpBodyOptions) -> Self { + self.options.response_body_options = options; + self + } + + /// Enables the minimal pipeline mode for the client. + /// + /// In this mode, the client uses only the [`Dispatch`] handler directly without any middleware, + /// giving you complete control but without features like logging or metrics. This is useful + /// when you need a streamlined client with minimal overhead. + pub fn minimal_pipeline(mut self) -> Self { + self.pipeline_builder = PipelineBuilder::Minimal; + self + } + + /// Configures the client to use a custom request pipeline instead of the default standard pipeline. + /// + /// By default, the HTTP client uses a standard pipeline that includes common middleware + /// for handling requests and responses. This method allows you to replace that pipeline + /// with your own implementation, giving you complete control over request processing. + /// + /// The factory function receives: + /// - A [`Dispatch`] handler that handles the actual HTTP communication. + /// - A [`PipelineContext`] with additional context information. + /// + /// In your callback, you can provide your own stack of middleware with the dispatch handler at the bottom. + /// Each middleware can add functionality like logging, authentication, caching, or custom + /// request/response transformations. The middleware forms a chain where each one can process + /// the request before passing it to the next handler in the chain. + /// + /// # Examples + /// + /// ```rust + /// # use fetch::*; + /// # use fetch::handlers::*; + /// # use fetch::resilience::retry::{HttpRetry, HttpRetryLayerExt}; + /// # use layered::Stack; + /// fn configure_builder(mut builder: HttpClientBuilder) -> HttpClientBuilder { + /// builder.custom_pipeline(move |dispatch, ctx| { + /// let stack = ( + /// Logging::layer(ctx.clock(), ctx.redaction_engine()), + /// HttpRetry::layer("my_retry", ctx.resilience_context()) + /// .http_configure_defaults() + /// .max_retry_attempts(1), + /// dispatch, + /// ); + /// stack.into_service() + /// }) + /// } + /// ``` + pub fn custom_pipeline(mut self, factory: F) -> Self + where + F: Fn(Dispatch, PipelineContext) -> R + Send + Sync + 'static, + R: RequestHandler + 'static, + { + self.pipeline_builder = PipelineBuilder::Custom(CustomPipelineFactory::new(factory)); + self + } + + /// Sets TLS options for this client. + /// + /// Use `TlsOptions::builder_rustls()` for the rustls backend, + /// or `TlsOptions::builder_native_tls()` for the platform native TLS backend. + /// The rustls backend also supports mutual TLS (`mTLS`) via the builder's + /// `client_identity` method. + /// + /// # Example + /// + /// ```rust,no_run + /// # #[cfg(feature = "rustls")] + /// # { + /// # use fetch::tls::TlsOptions; + /// # use fetch::HttpClientBuilder; + /// # fn example(builder: HttpClientBuilder) { + /// let client = builder + /// .tls_options(TlsOptions::builder_rustls().build()) + /// .build(); + /// # } + /// # } + /// ``` + pub fn tls_options(mut self, tls_options: TlsOptions) -> Self { + self.options.tls = tls_options; + self + } + + /// Sets the supported HTTP versions for the client. + /// + /// The default is HTTP/1.1 and HTTP/2. This method allows you to change which + /// HTTP protocol versions the client will use when connecting to servers. + pub fn supported_http_versions(mut self, versions: &[http::Version]) -> Self { + self.options.transport.supported_http_versions = versions.to_vec(); + self + } + + /// Sets a custom OpenTelemetry meter provider for the client. + /// + /// The given [`MeterProvider`] is used to collect this client's metrics. By + /// default, the client uses the global meter provider; use this method to + /// override it for this client instance. + /// + /// # Performance + /// + /// For thread-isolated runtimes, prefer a per-thread meter provider to avoid + /// the lock contention that a global meter provider can cause. + /// + /// [`MeterProvider`]: https://docs.rs/opentelemetry/latest/opentelemetry/metrics/trait.MeterProvider.html + #[cfg_attr(test, mutants::skip)] // FIXME: mutants remove resilience context and other fields, which we can't really assert on + pub fn meter_provider(self, meter_provider: &dyn MeterProvider) -> Self { + // Update the metering at all relevant places + Self { + metering: Metering::custom(meter_provider), + resilience_context: self.resilience_context.use_metrics(meter_provider), + ..self + } + } + + /// Configures the standard pipeline with custom settings. + /// + /// This method allows you to customize the standard pipeline (which includes resilience + /// features and observability) by providing a configuration function that receives + /// the current pipeline and returns a modified version. + /// + /// See [`StandardRequestPipeline`] for more details on the defaults. + /// + /// # Multiple Calls + /// + /// Multiple consecutive calls to this method are additive - each call receives the + /// pipeline configured by the previous call. However, if you switch to a different + /// pipeline type (e.g., [`minimal_pipeline`](Self::minimal_pipeline)) and then call + /// this method again, it will receive a fresh default [`StandardRequestPipeline`] rather than + /// the previously configured one. + /// + /// # Example + /// + /// ```rust + /// # use std::time::Duration; + /// # use fetch::{HttpClient, HttpClientBuilder}; + /// # fn configure_builder(mut builder: HttpClientBuilder) { + /// let client = builder + /// .standard_pipeline(|pipeline, _context| { + /// // Change the attempt timeout to 5 seconds + /// pipeline.attempt_timeout(|timeout| timeout.timeout(Duration::from_secs(5))) + /// }) + /// .build(); + /// # } + /// ``` + pub fn standard_pipeline(self, configure: F) -> Self + where + F: Fn(StandardRequestPipeline, PipelineContext) -> StandardRequestPipeline + Send + Sync + 'static, + { + Self { + pipeline_builder: self.pipeline_builder.configure_standard(configure), + ..self + } + } + + /// Sets the base URI for the client. + /// + /// This setting overrides any endpoint set in the [`Uri`](templated_uri::Uri) you pass to the + /// request methods, leading to three possible scenarios: + /// + /// - `HttpClientBuilder::base_uri` is set: the [`BaseUri`] on the request's [`Uri`](templated_uri::Uri) is ignored and the client uses the provided [`BaseUri`] instead. + /// - `HttpClientBuilder::base_uri` is not set, but the request [`Uri`](templated_uri::Uri) has a [`BaseUri`]: the client uses the [`BaseUri`] from the request's [`Uri`](templated_uri::Uri). + /// - No endpoint is set on either side: the request fails with a validation [`HttpError`](crate::HttpError). + /// + /// ```rust + /// # #[cfg(feature = "test-util")] + /// # { + /// # use http::StatusCode; + /// # use fetch::fake::FakeHandler; + /// # use fetch::HttpClient; + /// # use fetch::HttpResponseBuilder; + /// # use fetch::fake::FakeDeps; + /// # use templated_uri::BaseUri; + /// # async fn example() -> Result<(), Box> { + /// let client = HttpClient::builder_fake(FakeHandler::default(), FakeDeps::default()) + /// .base_uri(BaseUri::from_static("https://example.com")) + /// .build(); + /// + /// let response = client.get("/foo/bar").fetch().await?; + /// # Ok(()) + /// # } + /// # } + /// ``` + pub fn base_uri(self, base_uri: impl Into) -> Self { + // Preserve historical semantics: the client's base URI overrides any endpoint + // already present on the request URI. + self.router(Router::fixed(base_uri.into()).conflict_policy(BaseUriConflict::UseRouted)) + } + + /// Configures the [`Router`] used to resolve the destination [`BaseUri`] for each request. + /// + /// A router can expose multiple alternative endpoints (e.g. a primary and one or more + /// fallback endpoints). When the configured router has alternatives, the standard pipeline + /// automatically enables retry/hedging on connection-unavailable errors so subsequent + /// attempts can target a different endpoint. + /// + /// This setting and [`HttpClientBuilder::base_uri`] are mutually exclusive shortcuts for the + /// same underlying configuration; whichever is called last wins. Calling `base_uri(uri)` is + /// equivalent to `router(Router::fixed(uri))`. + /// + /// # Examples + /// + /// ```rust + /// # fn main() { + /// # #[cfg(feature = "test-util")] { + /// # use fetch::HttpClient; + /// # use fetch::fake::FakeHandler; + /// # use fetch::fake::FakeDeps; + /// # use http_extensions::routing::Router; + /// # use templated_uri::BaseUri; + /// let client = HttpClient::builder_fake(FakeHandler::default(), FakeDeps::default()) + /// .router(Router::fallback( + /// BaseUri::from_static("https://primary.example.com/"), + /// BaseUri::from_static("https://secondary.example.com/"), + /// )) + /// .build(); + /// # } + /// # } + /// ``` + pub fn router(mut self, router: Router) -> Self { + self.options.router = router; + self + } + + /// Configures HTTP/2 options for the client. + /// + /// This method allows you to customize HTTP/2-specific settings for connections created + /// by the client. These settings only apply when the client negotiates HTTP/2 connections. + /// + /// # Examples + /// + /// ```rust + /// # use fetch::HttpClient; + /// # use fetch::options::Http2Options; + /// # fn configure_builder(mut builder: fetch::HttpClientBuilder) { + /// let client = builder + /// .http2_options(Http2Options::default().initial_max_send_streams(100)) + /// .build(); + /// # } + /// ``` + pub fn http2_options(mut self, options: Http2Options) -> Self { + self.options.transport.http_2 = options; + self + } + + /// Configures connection pool options for the client. + /// + /// This method allows you to configure how the client manages its connection pool. + /// The connection pool reuses existing connections to reduce the overhead and latency + /// of establishing new connections for each request. + /// + /// # Examples + /// + /// ```rust + /// # use std::time::Duration; + /// # use fetch::HttpClient; + /// # use fetch::options::ConnectionPoolOptions; + /// # fn configure_builder(mut builder: fetch::HttpClientBuilder) { + /// let client = builder + /// .connection_pool_options( + /// ConnectionPoolOptions::default() + /// .max_connections(50) + /// .connection_idle_timeout(Duration::from_secs(300)), + /// ) + /// .build(); + /// # } + /// ``` + pub fn connection_pool_options(mut self, options: ConnectionPoolOptions) -> Self { + self.options.transport.connection_pool = options; + self + } + + /// Sets the redaction engine for the client. + /// + /// The [`RedactionEngine`] is used to redact sensitive information from requests and responses. + /// This is particularly useful for logging and telemetry, where you want to avoid exposing + /// sensitive data such as authentication tokens, personal information, or other confidential + /// information. + pub fn redaction_engine(mut self, redaction_engine: &RedactionEngine) -> Self { + self.options.redaction_engine = redaction_engine.clone(); + self + } + + /// Builds the configured [`HttpClient`](crate::HttpClient). + /// + /// This finalizes all configuration and returns a ready-to-use client that + /// reflects every setting applied to this builder. + #[must_use] + pub fn build(self) -> crate::HttpClient { + let clock = self.transport.clock().clone(); + let router = self.options.router.clone(); + let aware = Aware { + transport: self.transport, + pipeline: self.pipeline_builder, + options: self.options, + resilience_context: self.resilience_context, + metering: self.metering, + }; + let body_builder = aware.transport.create_body_builder(&aware.options); + let pipeline = match aware.transport.isolation() { + Isolation::Isolated => HttpClientPipeline::Isolated(thread_aware::Arc::new_with(aware, Aware::into_pipeline)), + Isolation::Shared => HttpClientPipeline::Shared(std::sync::Arc::new(aware.into_pipeline())), + }; + + crate::HttpClient::new(pipeline, body_builder, clock, router) + } +} + +#[derive(Debug, Clone, ThreadAware)] +struct Aware { + #[thread_aware(skip)] + metering: Metering, + pipeline: PipelineBuilder, + #[thread_aware(skip)] + options: ClientOptions, + transport: Transport, + resilience_context: ResilienceContext>, +} + +impl Aware { + fn into_pipeline(self) -> Pipeline { + let meter: Meter = self.metering.into(); + let dispatch = create_dispatch_handler(&meter, self.options.clone(), &self.transport); + let body_builder = self.transport.create_body_builder(&self.options); + + self.pipeline.build( + dispatch, + self.resilience_context, + self.options.redaction_engine, + &meter, + body_builder, + self.transport.clock().clone(), + self.options.router, + ) + } +} + +fn create_dispatch_handler(meter: &Meter, options: ClientOptions, transport: &Transport) -> Dispatch { + let mode = match options.transport.connection_pool.multiple_pools.clone() { + Some((pool_count, selection)) if pool_count > 1 => { + let transports = (0..pool_count) + .map(|index| transport.create_transport_handler(options.clone(), meter.clone(), PoolIndex::new(index))) + .collect::>(); + + DispatchMode::pooled(transports, selection) + } + _ => DispatchMode::single(transport.create_transport_handler(options.clone(), meter.clone(), PoolIndex::new(0))), + }; + + Dispatch::new(mode, options.transport.request_filter) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use http::StatusCode; + use http_extensions::{HttpBodyBuilder, HttpBodyOptions}; + + use crate::fake::FakeDeps; + use crate::options::{ConnectionIdleTimeout, ConnectionPoolOptions, Http2Options}; + use crate::telemetry::Metering; + use crate::{HttpClient, HttpClientBuilder}; + + static_assertions::assert_impl_all!(HttpClientBuilder: Send, Sync, Clone); + + #[test] + fn standard_pipeline_customization_applied() { + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .standard_pipeline(|pipeline, _context| pipeline.retry(|retry| retry.max_retry_attempts(5))) + .build(); + + let dbg = client.pipeline().dbg_string_for_custom_pipeline(); + assert!(dbg.contains("max_attempts: 6")); + } + + #[test] + fn connection_keep_alive_sets_option() { + use std::time::Duration; + + use crate::options::ConnectionKeepAlive; + + let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()).connection_keep_alive( + ConnectionKeepAlive::active_connections(Duration::from_secs(15), Duration::from_secs(5)), + ); + + assert!(matches!( + builder.options.transport.connection_keep_alive, + ConnectionKeepAlive::ActiveConnections { interval, timeout } + if interval == Duration::from_secs(15) && timeout == Duration::from_secs(5) + )); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn tls_options_are_stored_without_breaking_the_pipeline() { + use crate::tls::TlsOptions; + + // The fake transport ignores TLS, so a custom `TlsOptions` must be accepted and + // stored without affecting request handling. + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .tls_options(TlsOptions::default()) + .build(); + + let response = client.get("https://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn redaction_engine_sets_option() { + use data_privacy::simple_redactor::{SimpleRedactor, SimpleRedactorMode}; + use data_privacy::{RedactedToString, RedactionEngine}; + use templated_uri::{PathAndQuery, Uri}; + + let engine = RedactionEngine::builder() + .add_class_redactor(Uri::DATA_CLASS, SimpleRedactor::with_mode(SimpleRedactorMode::Passthrough)) + .build(); + + let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()).redaction_engine(&engine); + + // The stored engine must be the configured one: a passthrough redactor leaves the + // path untouched. + let redacted = PathAndQuery::from_static("/path").to_redacted_string(&builder.options.redaction_engine); + assert_eq!(redacted, "/path"); + } + + #[test] + fn standard_pipeline_after_minimal_creates_new_pipeline() { + let client = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .standard_pipeline(|pipeline, _context| pipeline.retry(|retry| retry.max_retry_attempts(3))) + .minimal_pipeline() + .standard_pipeline(|pipeline, _context| pipeline) + .build(); + + let dbg = client.pipeline().dbg_string_for_custom_pipeline(); + assert!(dbg.contains("max_attempts: 4")); + } + + #[test] + fn response_body_options() { + let custom = HttpBodyOptions::default().buffer_limit(1234); + + let mut builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()); + assert_eq!(builder.options.response_body_options, HttpBodyOptions::default()); + + builder = builder.response_body_options(custom); + assert_eq!(custom, builder.options.response_body_options); + + let client = builder.build(); + let builder: &HttpBodyBuilder = client.as_ref(); + + assert!(format!("{builder:?}").contains("1234")); + } + + #[test] + fn test_http2_options_configuration() { + let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .http2_options(Http2Options::default().initial_max_send_streams(100).adaptive_window(true)); + assert_eq!(builder.options.transport.http_2.initial_max_send_streams, Some(100)); + + assert!(builder.options.transport.http_2.adaptive_window); + } + + #[test] + fn test_connection_pool_options_configuration() { + use std::time::Duration; + + let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()).connection_pool_options( + ConnectionPoolOptions::default() + .max_connections(50) + .connection_idle_timeout(Duration::from_mins(5)), + ); + + assert_eq!(builder.options.transport.connection_pool.max_connections, 50); + match builder.options.transport.connection_pool.connection_idle_timeout { + ConnectionIdleTimeout::Limited(duration) => { + assert_eq!(duration, Duration::from_mins(5)); + } + ConnectionIdleTimeout::Unlimited => panic!("Expected Limited variant"), + } + } + + #[test] + fn test_http2_and_connection_pool_options_chaining() { + use std::time::Duration; + + let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .http2_options(Http2Options::default().initial_max_send_streams(200)) + .connection_pool_options( + ConnectionPoolOptions::default() + .max_connections(25) + .connection_idle_timeout(Duration::from_mins(2)), + ); + + // Verify HTTP/2 options + assert_eq!(builder.options.transport.http_2.initial_max_send_streams, Some(200)); + + // Verify connection pool options + assert_eq!(builder.options.transport.connection_pool.max_connections, 25); + match builder.options.transport.connection_pool.connection_idle_timeout { + ConnectionIdleTimeout::Limited(duration) => { + assert_eq!(duration, Duration::from_mins(2)); + } + ConnectionIdleTimeout::Unlimited => panic!("Expected Limited variant"), + } + } + + #[test] + fn defaults_ok() { + // Verify that the builder can be created with default settings + let _builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()); + } + + #[test] + fn name_ok() { + // Verify that the name method can be called without panic + let _builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()).name("custom_client"); + } + + #[test] + fn test_clone_produces_isolated_instances() { + use std::time::Duration; + + // Create a base builder with some initial configuration + let builder_1 = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()) + .name("base_client") + .connect_timeout(Duration::from_secs(10)) + .response_body_options(HttpBodyOptions::default().buffer_limit(1000)); + + // Clone the builder + let mut builder_2 = builder_1.clone(); + + // Verify initial state is the same + assert_eq!( + builder_1.options.transport.connect_timeout, + builder_2.options.transport.connect_timeout + ); + assert_eq!(builder_1.options.response_body_options, builder_2.options.response_body_options); + + // Modify the cloned builder + builder_2 = builder_2 + .name("cloned_client") + .connect_timeout(Duration::from_secs(30)) + .response_body_options(HttpBodyOptions::default().buffer_limit(5000)) + .http2_options(Http2Options::default().initial_max_send_streams(100)) + .connection_pool_options( + ConnectionPoolOptions::default() + .max_connections(50) + .connection_idle_timeout(Duration::from_mins(5)), + ); + + // Verify that builder_1 remains unchanged + assert_eq!(builder_1.options.transport.connect_timeout, Duration::from_secs(10)); + assert_eq!( + builder_1.options.response_body_options, + HttpBodyOptions::default().buffer_limit(1000) + ); + assert_eq!(builder_1.options.transport.http_2.initial_max_send_streams, None); + assert_eq!(builder_1.options.transport.connection_pool.max_connections, usize::MAX); + + // Verify that builder_2 has the new values + assert_eq!(builder_2.options.transport.connect_timeout, Duration::from_secs(30)); + assert_eq!( + builder_2.options.response_body_options, + HttpBodyOptions::default().buffer_limit(5000) + ); + assert_eq!(builder_2.options.transport.http_2.initial_max_send_streams, Some(100)); + assert_eq!(builder_2.options.transport.connection_pool.max_connections, 50); + match builder_2.options.transport.connection_pool.connection_idle_timeout { + ConnectionIdleTimeout::Limited(duration) => { + assert_eq!(duration, Duration::from_mins(5)); + } + ConnectionIdleTimeout::Unlimited => panic!("Expected Limited variant"), + } + } + + #[test] + fn multiple_pools_sets_options() { + use crate::options::{ConnectionPoolOptions, PoolSelection}; + + let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()).connection_pool_options( + ConnectionPoolOptions::default().multiple_pools(5, PoolSelection::saturating(PoolSelection::DEFAULT_REQUESTS_PER_CLIENT)), + ); + + let multiple_pools = builder.options.transport.connection_pool.multiple_pools; + let Some((pool_count, _selection)) = multiple_pools else { + panic!("expected multiple pools to be configured"); + }; + assert_eq!(pool_count, 5); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn meter_provider_updates_all_fields() { + let provider = opentelemetry_sdk::metrics::SdkMeterProvider::default(); + + let builder = HttpClient::builder_fake(StatusCode::OK, FakeDeps::default()); + assert!(matches!(builder.metering, Metering::Global)); + + let builder = builder.meter_provider(&provider); + assert!(matches!(builder.metering, Metering::Custom(_))); + } +} diff --git a/crates/fetch/src/constants.rs b/crates/fetch/src/constants.rs new file mode 100644 index 000000000..ea4bcab2f --- /dev/null +++ b/crates/fetch/src/constants.rs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +pub(crate) const DEFAULT_HTTP_CLIENT_NAME: &str = "http_client"; diff --git a/crates/fetch/src/custom.rs b/crates/fetch/src/custom.rs new file mode 100644 index 000000000..fe488d1f7 --- /dev/null +++ b/crates/fetch/src/custom.rs @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Custom transport-handler entry points for [`HttpClient`]. +//! +//! Every [`HttpClient`] ultimately dispatches requests through a *transport +//! handler* — the leaf of the request pipeline that actually performs I/O. +//! This module exposes the types needed to supply your own, while the bundled +//! transports (the Tokio transport and the test fakes) reuse the same machinery +//! internally. +//! +//! The free-standing [`create_builder`] function is the entry point: it returns +//! an [`HttpClientBuilder`] so the pipeline (middleware, options, …) can be +//! tailored before [`HttpClientBuilder::build`] is called. + +use std::fmt::Debug; +use std::sync::Arc; + +use bytesbuf::mem::GlobalPool; +use http_extensions::{HttpBodyBuilder, RequestHandler}; +use opentelemetry::metrics::Meter; +use thread_aware::{PerCore, ThreadAware, unaware}; +use tick::Clock; + +use crate::handlers::TransportHandler; +use crate::options::{ClientOptions, PoolIndex, TransportOptions}; +use crate::tls::TlsOptions; +use crate::{HttpClient, HttpClientBuilder}; + +/// Threading model required by a custom transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ThreadAware)] +pub enum Isolation { + /// Each core owns its own pipeline; the factory is invoked once per core. + Isolated, + /// A single pipeline is shared across all cores. + Shared, +} + +/// Runtime-agnostic dependencies required by a custom-transport [`HttpClient`]. +/// +/// The caller is responsible for supplying a suitable [`Clock`] (e.g. `Clock::new_tokio()` +/// or a controlled clock for tests), because this type does not assume any specific runtime. +/// +/// The `Extras` type parameter (defaulting to `()`) lets the caller thread additional +/// thread-aware dependencies through to [`CustomContext::extras`] — for example a +/// connection pool, credential provider, or runtime handle. +#[derive(Debug, Clone, ThreadAware)] +pub struct CustomDeps +where + Extras: ThreadAware + Send + Sync + Clone + 'static, +{ + /// Clock for timing operations and timeouts. + pub clock: Clock, + /// Memory pool for usage-neutral memory allocations. + pub global_pool: GlobalPool, + /// Extra dependencies forwarded verbatim to [`CustomContext::extras`]. + pub extras: Extras, +} + +/// Per-pool-slot context handed to a user-supplied transport factory. +/// +/// The client constructs one [`CustomContext`] each time it needs a new transport handler +/// (typically once per connection pool slot, per core). `Extras` mirrors the same +/// parameter on [`CustomDeps`]. +#[derive(Debug)] +#[non_exhaustive] +pub struct CustomContext { + /// Builder for assembling HTTP response bodies, backed by the client's memory pool. + pub body_builder: HttpBodyBuilder, + /// Clock for timing operations and timeouts inside the handler. + pub clock: Clock, + /// Index of the connection pool slot this handler will service. + pub pool_index: PoolIndex, + /// Caller-supplied extras, cloned from [`CustomDeps::extras`]. + pub extras: Extras, + + /// Transport-level options configured on the client. + /// + /// Custom transports can honor these knobs (connect timeout, keep-alive, + /// supported HTTP versions, connection-pool sizing, ...) when establishing + /// connections. + pub options: TransportOptions, + + /// TLS configuration declared on the client. + /// + /// A custom transport that terminates `https://` connections itself can read + /// this to honor the caller's certificate, `ALPN`, and backend preferences. + /// The bundled Tokio transport consumes it to build its TLS connector; custom + /// transports that only serve `http://` may ignore it. + pub tls: TlsOptions, + + /// Telemetry meter the client records its metrics against. + /// + /// A custom transport can use this same [`Meter`] to emit its own instruments + /// so that transport-level metrics share the client's meter scope. + pub meter: Meter, +} + +/// Creates a builder for an HTTP client backed by a custom transport handler. +/// +/// `factory` is invoked lazily, once per pool slot, with a [`CustomContext`] for that +/// slot, and must return a [`RequestHandler`] that becomes the transport stage of the +/// pipeline. The full request pipeline (resilience, telemetry, logging, ...) +/// is layered on top by the builder. +/// +/// `isolation` selects the threading model the underlying transport requires; see +/// [`Isolation`]. +/// +/// The `Extras` parameter on [`CustomDeps`] / [`CustomContext`] plumbs additional +/// thread-aware dependencies through to `factory` without resorting to globals. +/// Leave it defaulted to `()` when no extras are needed. +/// +/// Because the handler is the transport stage, the caller is responsible for TLS: +/// if `https://` URIs are expected, the handler must negotiate TLS itself. +/// Otherwise pair the builder with [`HttpClientBuilder::insecure_allow_http`] and +/// only issue `http://` requests. +/// +/// # Examples +/// +/// ``` +/// # use fetch::custom::{create_builder, CustomContext, CustomDeps, Isolation}; +/// # use fetch::{HttpBodyBuilder, HttpError, HttpRequest, HttpResponse, HttpResponseBuilder}; +/// # use http::StatusCode; +/// # use layered::Service; +/// /// Transport handler that ignores the request and returns a canned `200 OK`. +/// struct MyTransportHandler { +/// body_builder: HttpBodyBuilder, +/// } +/// +/// impl Service for MyTransportHandler { +/// type Out = fetch::Result; +/// +/// async fn execute(&self, _request: HttpRequest) -> Self::Out { +/// HttpResponseBuilder::new(&self.body_builder) +/// .status(StatusCode::OK) +/// .build() +/// } +/// } +/// +/// # async fn example(deps: CustomDeps) -> Result<(), HttpError> { +/// let client = create_builder( +/// |ctx: CustomContext| MyTransportHandler { +/// body_builder: ctx.body_builder, +/// }, +/// Isolation::Shared, +/// deps, +/// ) +/// .insecure_allow_http() +/// .build(); +/// +/// let response = client.get("http://example.com").fetch().await?; +/// assert_eq!(response.status(), StatusCode::OK); +/// # Ok(()) +/// # } +/// ``` +pub fn create_builder(factory: F, isolation: Isolation, deps: impl Into>) -> HttpClientBuilder +where + F: Fn(CustomContext) -> R + Send + Sync + 'static, + R: RequestHandler + 'static, + Extras: ThreadAware + Send + Sync + Clone + 'static, +{ + // Type-erase the user-supplied handler into `TransportHandler` once, then + // delegate to the in-crate path shared with the bundled transports. + HttpClient::builder_custom_internal(move |cx| TransportHandler::new(factory(cx)), isolation, deps.into()) +} + +impl HttpClient { + /// In-crate variant of [`create_builder`] used by the bundled + /// Tokio transport. The factory produces a pre-erased [`TransportHandler`], avoiding a + /// redundant boxing step for transports that branch over multiple concrete handler + /// types. The `tls`/`meter` fields on [`CustomContext`] are populated unconditionally + /// for the bundled transports; user-supplied factories may read or ignore them. + pub(crate) fn builder_custom_internal(factory: F, isolation: Isolation, deps: CustomDeps) -> HttpClientBuilder + where + F: Fn(CustomContext) -> TransportHandler + Send + Sync + 'static, + Extras: ThreadAware + Send + Sync + Clone + 'static, + { + // The factory is shared across cores via `Arc`. The original `CustomDeps` is + // carried alongside it so its `extras` are cloned into a fresh `CustomContext` + // for every handler the per-core transport builds. + let factory = Arc::new(factory); + + let transport = Transport { + clock: deps.clock.clone(), + global_pool: deps.global_pool.clone(), + isolation, + inner: thread_aware::Arc::new_with((deps, unaware(factory)), |(deps, factory)| { + Arc::new(move |options, meter, pool_index| { + let context = CustomContext { + body_builder: create_body_builder(&deps.global_pool, &deps.clock, &options), + clock: deps.clock.clone(), + pool_index, + extras: deps.extras.clone(), + options: options.transport.clone(), + tls: options.tls.clone(), + meter, + }; + factory.0(context) + }) + }), + }; + + HttpClientBuilder::new(transport) + } +} + +type TransportFn = Arc TransportHandler + Send + Sync>; + +#[derive(Clone, ThreadAware)] +pub(crate) struct Transport { + inner: thread_aware::Arc, + clock: Clock, + global_pool: GlobalPool, + isolation: Isolation, +} + +impl Transport { + pub fn create_transport_handler(&self, options: ClientOptions, meter: Meter, index: PoolIndex) -> TransportHandler { + self.inner.as_ref()(options, meter, index) + } + + pub fn clock(&self) -> &Clock { + &self.clock + } + + pub fn isolation(&self) -> Isolation { + self.isolation + } + + pub fn create_body_builder(&self, options: &ClientOptions) -> HttpBodyBuilder { + create_body_builder(&self.global_pool, &self.clock, options) + } +} + +impl Debug for Transport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(std::any::type_name::()).finish() + } +} + +pub(crate) fn create_body_builder(pool: &GlobalPool, clock: &Clock, options: &ClientOptions) -> HttpBodyBuilder { + HttpBodyBuilder::new(pool.clone(), clock).with_options(options.response_body_options) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use http::StatusCode; + use http_extensions::FakeHandler; + use thread_aware::unaware; + + use super::{CustomContext, CustomDeps, Isolation, create_builder}; + use crate::HttpResponseBuilder; + use crate::fake::FakeDeps; + use crate::pipeline::Pipeline; + + #[mutants::skip] + fn custom_deps() -> CustomDeps { + CustomDeps { + clock: FakeDeps::default().clock, + global_pool: bytesbuf::mem::GlobalPool::new(), + extras: (), + } + } + + #[mutants::skip] + fn ok_factory(_ctx: CustomContext) -> FakeHandler { + FakeHandler::from_sync_handler(|_req| HttpResponseBuilder::new_fake().status(StatusCode::OK).build()) + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn create_builder_serves_requests_through_custom_pipeline() { + // `create_builder` exposes the full builder so callers can tweak the pipeline + // (here: switch to the minimal pipeline) before driving a real request. + let client = create_builder(ok_factory, Isolation::Shared, custom_deps()) + .insecure_allow_http() + .minimal_pipeline() + .build(); + + assert!(matches!(client.pipeline(), Pipeline::Minimal(_))); + + let response = client.get("http://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn isolated_runtime_uses_per_core_handler() { + // `Isolation::Isolated` is the right choice for thread-per-core transports; + // it must still serve requests correctly when there is only one core in play. + let client = create_builder(ok_factory, Isolation::Isolated, custom_deps()) + .insecure_allow_http() + .build(); + + let response = client.get("http://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn extras_are_forwarded_to_factory() { + // `Arc` is not `ThreadAware`-deriveable, but it is safe to share + // across threads, so wrap it in `unaware` to use as extras. + let counter = Arc::new(AtomicUsize::new(0)); + let deps = CustomDeps { + clock: FakeDeps::default().clock, + global_pool: bytesbuf::mem::GlobalPool::new(), + extras: unaware(Arc::clone(&counter)), + }; + + let client = create_builder( + |ctx: CustomContext>>| { + // Touching `extras` during factory invocation proves the value travels + // all the way through the transport plumbing. + ctx.extras.fetch_add(1, Ordering::Relaxed); + FakeHandler::from_sync_handler(|_req| HttpResponseBuilder::new_fake().status(StatusCode::OK).build()) + }, + Isolation::Shared, + deps, + ) + .insecure_allow_http() + .build(); + + let response = client.get("http://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + // The factory must have been called at least once to build the per-slot handler. + assert!(counter.load(Ordering::Relaxed) >= 1); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn transport_has_type_name_debug_representation() { + // `Transport` holds non-Debug closures, so its Debug impl falls back to the type + // name. The builder embeds the transport, so formatting it exercises that impl. + let builder = create_builder(ok_factory, Isolation::Shared, custom_deps()); + + assert!(format!("{builder:?}").contains("Transport")); + } +} diff --git a/crates/fetch/src/error_labels.rs b/crates/fetch/src/error_labels.rs new file mode 100644 index 000000000..8c10e516d --- /dev/null +++ b/crates/fetch/src/error_labels.rs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![allow(dead_code, reason = "this code is cheap and adding build conditions just complicates it")] + +//! Centralized [`ErrorLabel`] constants for all errors in this crate. + +use http_extensions::HttpError; +use ohno::{ErrorLabel, Labeled}; + +// Connection errors +pub(crate) const LABEL_CONNECT: ErrorLabel = ErrorLabel::from_static("connect"); + +// Request errors +pub(crate) const LABEL_REQUEST_HYPER: ErrorLabel = ErrorLabel::from_static("request_hyper"); + +// Handler errors +pub(crate) const LABEL_ABANDONED: ErrorLabel = ErrorLabel::from_static("abandoned"); + +// Validation errors (granular replacements for generic "validation") +pub(crate) const LABEL_CONTENT_ENCODING_INVALID: ErrorLabel = ErrorLabel::from_static("content_encoding_invalid"); +pub(crate) const LABEL_CONTENT_ENCODING_UNSUPPORTED: ErrorLabel = ErrorLabel::from_static("content_encoding_unsupported"); +pub(crate) const LABEL_URI_ORIGIN_MISSING: ErrorLabel = ErrorLabel::from_static("uri_origin_missing"); +pub(crate) const LABEL_SCHEME_NOT_ALLOWED: ErrorLabel = ErrorLabel::from_static("scheme_not_allowed"); +pub(crate) const LABEL_HTTP_VERSION_UNSUPPORTED: ErrorLabel = ErrorLabel::from_static("http_version_unsupported"); +pub(crate) const LABEL_TLS: ErrorLabel = ErrorLabel::from_static("tls"); + +pub(crate) fn collect_error_labels(error: &(dyn std::error::Error + 'static)) -> ErrorLabel { + ErrorLabel::from_error_chain(error, resolve_error_label) +} + +fn resolve_error_label(error: &(dyn std::error::Error + 'static)) -> Option { + if let Some(err) = error.downcast_ref::() { + return Some(err.label().clone()); + } + + if let Some(err) = error.downcast_ref::() { + return Some(resolve_io_error_label(err)); + } + + None +} + +fn resolve_io_error_label(error: &std::io::Error) -> ErrorLabel { + let Some(err) = error.get_ref() else { + return error.kind().into(); + }; + + if let Some(err) = err.downcast_ref::() { + return resolve_io_error_label(err); + } + + #[cfg(any(feature = "rustls", test))] + if err.downcast_ref::().is_some() { + return LABEL_TLS; + } + + #[cfg(any(feature = "native-tls", test))] + if err.downcast_ref::().is_some() { + return LABEL_TLS; + } + + error.kind().into() +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::io::{self, ErrorKind}; + + use seatbelt::RecoveryInfo; + + use super::*; + + #[derive(Debug)] + struct DummyError; + + impl std::fmt::Display for DummyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("dummy") + } + } + + impl std::error::Error for DummyError {} + + #[test] + fn resolves_http_error_label() { + let err = HttpError::other("boom", RecoveryInfo::never(), LABEL_SCHEME_NOT_ALLOWED); + + let label = resolve_error_label(&err).expect("HttpError should resolve to a label"); + + assert_eq!(label, LABEL_SCHEME_NOT_ALLOWED); + } + + #[test] + fn resolves_io_error_kind_label() { + let err = io::Error::from(ErrorKind::ConnectionRefused); + + let label = resolve_error_label(&err).expect("io::Error should resolve to a label"); + + assert_eq!(label, ErrorLabel::from(ErrorKind::ConnectionRefused)); + } + + #[test] + fn resolves_unknown_error_to_none() { + let err = DummyError; + + assert!(resolve_error_label(&err).is_none()); + } + + #[test] + fn resolves_io_error_with_inner_kind() { + // io::Error without a custom inner falls back to its kind. + let err = io::Error::from(ErrorKind::TimedOut); + + let label = resolve_io_error_label(&err); + + assert_eq!(label, ErrorLabel::from(ErrorKind::TimedOut)); + } + + #[test] + fn resolves_io_error_with_unrelated_inner_uses_outer_kind() { + // An io::Error wrapping an arbitrary error falls back to the outer kind. + let err = io::Error::new(ErrorKind::PermissionDenied, DummyError); + + let label = resolve_io_error_label(&err); + + assert_eq!(label, ErrorLabel::from(ErrorKind::PermissionDenied)); + } + + #[test] + fn unwraps_nested_io_error() { + // An io::Error nested inside another io::Error is unwrapped so the + // innermost kind drives the resulting label. + let inner = io::Error::from(ErrorKind::ConnectionReset); + let outer = io::Error::other(inner); + + let label = resolve_io_error_label(&outer); + + assert_eq!(label, ErrorLabel::from(ErrorKind::ConnectionReset)); + } + + #[test] + fn detects_rustls_error_as_tls_label() { + let rustls_err = rustls::Error::General("handshake failure".to_owned()); + let io_err = io::Error::other(rustls_err); + + let label = resolve_io_error_label(&io_err); + + assert_eq!(label, LABEL_TLS); + } + + #[test] + fn detects_nested_rustls_error_as_tls_label() { + // A rustls error nested under multiple io::Error layers should still + // be detected via the recursive unwrap. + let rustls_err = rustls::Error::General("handshake failure".to_owned()); + let inner = io::Error::other(rustls_err); + let outer = io::Error::other(inner); + + let label = resolve_io_error_label(&outer); + + assert_eq!(label, LABEL_TLS); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn detects_native_tls_error_as_tls_label() { + // Trigger a native_tls::Error via a failed pkcs12 parse. + let Err(native_err) = native_tls::Identity::from_pkcs12(&[], "") else { + panic!("empty pkcs12 blob must fail to parse") + }; + let io_err = io::Error::other(native_err); + + let label = resolve_io_error_label(&io_err); + + assert_eq!(label, LABEL_TLS); + } + + #[test] + fn collect_error_labels_walks_chain() { + // The chain combines the outer HttpError label with the inner io::Error label. + let io_err = io::Error::from(ErrorKind::ConnectionRefused); + let http_err = HttpError::other(io_err, RecoveryInfo::retry(), LABEL_CONNECT); + + let label = collect_error_labels(&http_err); + + let expected = ErrorLabel::from_parts([LABEL_CONNECT, ErrorLabel::from(ErrorKind::ConnectionRefused)]); + assert_eq!(label, expected); + } +} diff --git a/crates/fetch/src/fake.rs b/crates/fetch/src/fake.rs new file mode 100644 index 000000000..18ee9212b --- /dev/null +++ b/crates/fetch/src/fake.rs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Test-only constructors for [`HttpClient`]. +//! +//! These factory methods produce HTTP clients backed by a fake handler so tests can exercise +//! request flows without making real network calls. They are gated behind the `test-util` +//! feature (and unconditionally available in test builds). +//! +//! The [`FakeHandler`] used to produce canned responses is re-exported here for convenience. + +#[doc(no_inline)] +pub use http_extensions::FakeHandler; +use thread_aware::ThreadAware; +use tick::Clock; + +use crate::custom::{CustomContext, CustomDeps, Isolation}; +use crate::handlers::TransportHandler; +use crate::{HttpClient, HttpClientBuilder}; + +/// Configuration dependencies for fake/test HTTP operations. +/// +/// Minimal configuration used in testing environments where only basic +/// clock functionality is needed. +#[derive(Debug, Clone, ThreadAware)] +pub struct FakeDeps { + /// Clock for testing time-based operations. + pub clock: Clock, +} + +impl Default for FakeDeps { + fn default() -> Self { + Self { + clock: tick::ClockControl::new().into(), + } + } +} + +impl From<&Clock> for FakeDeps { + #[cfg_attr(test, mutants::skip)] // Mutations using a wrong clock will easily lead to timeouts. + fn from(clock: &Clock) -> Self { + Self { clock: clock.clone() } + } +} + +impl HttpClient { + /// Creates a builder for a test-friendly HTTP client with a fake handler. + /// + /// Unlike [`HttpClient::new_fake`], this method returns a builder that can be further + /// customized before constructing the client. Use this when you need more control + /// over the client's configuration in test scenarios. + /// + /// # Examples + /// + /// ``` + /// # use std::time::Duration; + /// # use fetch::HttpClient; + /// # use http::StatusCode; + /// # use fetch::fake::FakeDeps; + /// async fn example() -> Result<(), Box> { + /// let client = HttpClient::builder_fake(StatusCode::NOT_FOUND, FakeDeps::default()) + /// .connect_timeout(Duration::from_millis(100)) + /// .insecure_allow_http() + /// .build(); + /// + /// let response = client.get("http://test-url.local").fetch().await?; + /// assert_eq!(response.status(), StatusCode::NOT_FOUND); + /// # Ok(()) + /// # } + /// ``` + /// + /// Available only when compiled with the `test-util` feature. + pub fn builder_fake(handler: impl Into, deps: impl Into) -> HttpClientBuilder { + let deps = deps.into(); + let handler = handler.into(); + + // Re-layer on top of the in-crate `builder_custom_internal` path. The + // `FakeHandler` travels through `CustomDeps::extras` and is cloned + // into a fresh `TransportHandler` for every connection pool slot. + Self::builder_custom_internal( + move |cx: CustomContext| TransportHandler::new(cx.extras), + Isolation::Shared, + CustomDeps { + clock: deps.clock, + global_pool: bytesbuf::mem::GlobalPool::new(), + extras: handler, + }, + ) + } + + /// Creates a test-friendly HTTP client that uses mock responses. + /// + /// This factory method provides a convenient way to create a client for testing without + /// making real network requests. It automatically configures the builder with test-friendly + /// defaults like allowing HTTP and using a minimal pipeline. + /// + /// # Examples + /// + /// ``` + /// # use fetch::HttpClient; + /// # use http::StatusCode; + /// # async fn example() -> Result<(), Box> { + /// // Create a client that always returns a specific status code + /// let client = HttpClient::new_fake(StatusCode::OK); + /// + /// // Now you can use this client in tests without real network requests + /// let response = client.get("https://example.com").fetch().await?; + /// assert_eq!(response.status(), StatusCode::OK); + /// # Ok(()) + /// # } + /// ``` + /// + /// Available only when compiled with the `test-util` feature. + pub fn new_fake(handler: impl Into) -> Self { + Self::builder_fake(handler, FakeDeps::default()) + .insecure_allow_http() + .minimal_pipeline() + .build() + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::time::Duration; + + use http::StatusCode; + use http_extensions::FakeHandler; + + use super::FakeDeps; + use crate::HttpClient; + use crate::pipeline::Pipeline; + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn ctor_fake_ok() { + let client = HttpClient::new_fake(StatusCode::INTERNAL_SERVER_ERROR); + + let response = client.get("http://example.com").fetch().await.unwrap(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(matches!(client.pipeline(), Pipeline::Minimal(_))); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_builder_fake_with_custom_options() { + let client = HttpClient::builder_fake(StatusCode::IM_A_TEAPOT, FakeDeps::default()) + .connect_timeout(Duration::from_millis(100)) + .insecure_allow_http() + .minimal_pipeline() + .build(); + + let response = client.get("http://test-url.local").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn fake_builder_no_clock() { + let _client = HttpClient::builder_fake(FakeHandler::never_completes(), FakeDeps::default()) + .custom_pipeline(|root, ctx| { + let dbg = format!("{:?}", ctx.clock()); + assert!(dbg.contains("kind: \"controlled\"")); + root + }) + .build(); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn fake_builder_custom_clock() { + let clock = tick::ClockControl::new().auto_advance(Duration::from_secs(2)).to_clock(); + + let _client = HttpClient::builder_fake(FakeHandler::never_completes(), &clock) + .custom_pipeline(|root, ctx| { + let dbg = format!("{:?}", ctx.clock()); + assert!(dbg.contains("kind: \"controlled\"")); + root + }) + .build(); + } +} diff --git a/crates/fetch/src/handlers/buffering.rs b/crates/fetch/src/handlers/buffering.rs new file mode 100644 index 000000000..a6f23ce0b --- /dev/null +++ b/crates/fetch/src/handlers/buffering.rs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fmt::Debug; + +use layered::{Layer, Service}; + +use crate::{HttpRequest, HttpResponse, RequestHandler, Result}; + +/// Buffers the entire HTTP response body into memory. +/// +/// Wraps any [`RequestHandler`] to transparently buffer response bodies. +/// After the inner handler produces a response, this handler calls +/// [`HttpBody::into_buffered`][crate::HttpBody::into_buffered] to load the full body into memory, +/// freeing the underlying network connection. +/// +/// This is useful when downstream consumers need to read the body +/// multiple times (e.g. for cloning, retries, or inspecting the payload) +/// or when you want to release the connection back to the pool as early +/// as possible. +#[derive(Debug)] +pub struct Buffering { + inner: T, +} + +/// Layer for creating [`Buffering`] instances. +#[derive(Debug, Clone, Copy)] +pub struct BufferingLayer; + +impl Buffering<()> { + /// Creates a new buffering handler layer. + #[must_use] + pub fn layer() -> BufferingLayer { + BufferingLayer + } +} + +impl Layer for BufferingLayer { + type Service = Buffering; + + fn layer(&self, inner: S) -> Self::Service { + Buffering { inner } + } +} + +impl Service for Buffering { + type Out = Result; + + async fn execute(&self, input: HttpRequest) -> Result { + let response = self.inner.execute(input).await?; + + let (parts, body) = response.into_parts(); + let body = body.into_buffered().await?; + + Ok(HttpResponse::from_parts(parts, body)) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use http::{StatusCode, Uri}; + use http_extensions::{FakeHandler, HttpBodyBuilder, HttpRequestBuilder}; + + use super::*; + use crate::error_labels::collect_error_labels; + use crate::handlers::Dispatch; + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn buffers_response_body() { + let inner = Dispatch::new_fake(FakeHandler::from(StatusCode::OK)); + let handler = Buffering { inner }; + + let request = HttpRequestBuilder::new_fake().uri("https://example.com/path").build().unwrap(); + + let response = handler.execute(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // A buffered body should be cloneable. + assert!(response.body().try_clone().is_some()); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn propagates_inner_handler_error() { + let inner = Dispatch::new_fake(FakeHandler::never_completes()); + let handler = Buffering { inner }; + + // Request without scheme/authority triggers a validation error in Dispatch. + let request = http::Request::get(Uri::from_static("/no-authority")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + let result: Result = handler.execute(request).await; + let error = result.unwrap_err(); + assert_eq!(collect_error_labels(&error), "uri_origin_missing"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn layer_constructs_handler() { + let layer = Buffering::layer(); + let inner = Dispatch::new_fake(FakeHandler::from(StatusCode::NO_CONTENT)); + let handler = layer.layer(inner); + + let request = HttpRequestBuilder::new_fake().uri("https://example.com/test").build().unwrap(); + + let response = handler.execute(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } +} diff --git a/crates/fetch/src/handlers/dispatch.rs b/crates/fetch/src/handlers/dispatch.rs new file mode 100644 index 000000000..aff6e86a5 --- /dev/null +++ b/crates/fetch/src/handlers/dispatch.rs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::future::ready; +use std::sync::Arc; + +use futures::TryFutureExt; +use futures::future::Either; +use http::uri::Scheme; +use layered::Service; +use seatbelt::retry::Attempt; + +use crate::handlers::TransportHandler; +use crate::options::{PoolIndex, PoolSelection, RequestFilter}; +use crate::{HttpError, HttpRequest, HttpResponse, Result}; + +/// The final handler responsible for sending HTTP requests to the network. +/// +/// `Dispatch` sits at the end of the handler chain and performs: +/// - Final validation of request endpoints +/// - Security filtering based on URL schemes (HTTP vs HTTPS) +/// - Actual network dispatch via the underlying transport +/// +/// Think of it as the gateway between your application and the network - all requests +/// must pass through here before hitting the wire. +/// +/// # Construction +/// +/// `Dispatch` is an internal implementation detail and cannot be created manually. +/// It's instantiated and managed by the `HttpClient` which configures it with the +/// appropriate transport and security settings. Users should interact with the `HttpClient` +/// rather than trying to use this handler directly. +/// +/// # Testing +/// +/// When the `HttpClient` is created by calling the `HttpClient::new_fake` method, the `Dispatch` doesn't actually send +/// requests to the network. Instead, it delegates the response handling to the `FakeHandler`, +/// which allows for deterministic testing without real network calls. This makes it easy +/// to write tests that verify your code's behavior without relying on external services. +#[derive(Debug)] +pub struct Dispatch { + pub(crate) mode: DispatchMode, + request_filter: RequestFilter, +} + +impl Dispatch { + pub(crate) fn new(mode: DispatchMode, request_filter: RequestFilter) -> Self { + Self { mode, request_filter } + } + + #[cfg(test)] + pub(crate) fn new_fake(handler: impl Into) -> Self { + Self::new( + DispatchMode::single(TransportHandler::new(handler.into())), + RequestFilter::HttpAndHttps, + ) + } +} + +/// Boxed pool-selection strategy. +/// +/// Built from [`PoolSelection::into_selector`]; given the pool of transports it +/// returns the transport to use and its [`PoolIndex`]. The closure owns its +/// selection state (e.g. a round-robin counter), so it is `Send + Sync`. +type PoolSelector = dyn for<'a> Fn(&'a [TransportHandler]) -> (&'a TransportHandler, PoolIndex) + Send + Sync; + +pub(crate) enum DispatchMode { + Single(TransportHandler), + Pooled { + transports: Arc<[TransportHandler]>, + selector: Box, + }, +} + +impl std::fmt::Debug for DispatchMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Single(transport) => f.debug_tuple("Single").field(transport).finish(), + Self::Pooled { transports, .. } => f.debug_struct("Pooled").field("transports", transports).finish_non_exhaustive(), + } + } +} + +impl DispatchMode { + pub fn single(transport: TransportHandler) -> Self { + Self::Single(transport) + } + + pub fn pooled(transports: Vec, selection: PoolSelection) -> Self { + Self::Pooled { + transports: Arc::from(transports), + selector: Box::new(selection.into_selector::()), + } + } +} + +impl Service for Dispatch { + type Out = Result; + + fn execute(&self, input: HttpRequest) -> impl Future + Send { + // Preserve the attempt information from the request so it can be + // forwarded to the response after dispatch. + let attempt = input.extensions().get::().copied(); + + if let Err(err) = validate(&self.request_filter, &input) { + return Either::Right(ready(Err(err))); + } + + // Select the transport synchronously *before* entering the async block. + // Performing the mode/pool dispatch out here keeps the resulting future + // small: it only has to carry a single `&TransportHandler`, the inner + // future, and a couple of `Copy` extension values - instead of the + // whole `&Dispatch` plus the state of both match arms. + let transport = match &self.mode { + DispatchMode::Single(transport) => transport, + DispatchMode::Pooled { transports, selector } => { + // If the request carries a `PoolIndex` extension (set by the + // retry/pooling layer), use it to pin the request to a + // specific transport in the pool. Otherwise, fall back to the + // configured selection strategy (e.g. round-robin). + // + // The pool index of the actually-selected pool is exposed to + // callers on the response via + // [`ConnectionInfo::pool_index`](crate::telemetry::ConnectionInfo::pool_index). + input + .extensions() + .get::() + .and_then(|idx| transports.get(idx.index())) + .unwrap_or_else(|| selector(transports).0) + } + }; + + Either::Left(transport.execute(input).map_ok(move |mut res| { + // Forward the attempt information to the response if present. This + // allows inspecting the attempt used to get this response. In + // healthy scenarios this is always the first attempt, but in + // degraded scenarios it may be higher. + if let Some(attempt) = attempt { + res.extensions_mut().insert(attempt); + } + + res + })) + } +} + +#[cfg_attr(test, mutants::skip)] // causes test timeouts +fn validate(filter: &RequestFilter, input: &HttpRequest) -> crate::Result<()> { + // Ensure the request has a scheme and authority set + let (Some(scheme), Some(authority)) = (input.uri().scheme(), input.uri().authority()) else { + return Err(HttpError::other( + "request must have scheme and authority set", + seatbelt::RecoveryInfo::never(), + crate::error_labels::LABEL_URI_ORIGIN_MISSING, + )); + }; + + if !is_allowed(filter, scheme) { + return Err(HttpError::other( + format!( + "unable to communicate with '{scheme}://{authority}', because the '{scheme}' scheme is not allowed by this HTTP client" + ), + seatbelt::RecoveryInfo::never(), + crate::error_labels::LABEL_SCHEME_NOT_ALLOWED, + )); + } + + Ok(()) +} + +fn is_allowed(filter: &RequestFilter, scheme: &Scheme) -> bool { + match filter { + RequestFilter::Https => scheme == &Scheme::HTTPS, + RequestFilter::HttpAndHttps => true, + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use http::{Request, StatusCode, Uri}; + use http_extensions::FakeHandler; + use ohno::ErrorExt; + use seatbelt::{Recovery, RecoveryKind}; + + use super::*; + use crate::HttpBodyBuilder; + use crate::error_labels::collect_error_labels; + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn no_endpoint_error() { + let handler = Dispatch::new( + DispatchMode::single(TransportHandler::new(FakeHandler::never_completes())), + RequestFilter::Https, + ); + + let uri = Uri::from_static("/relative-path"); + let request = Request::get(uri).body(HttpBodyBuilder::new_fake().empty()).unwrap(); + + let error = handler.execute(request).await.unwrap_err(); + + assert_eq!(error.recovery().kind(), RecoveryKind::Never); + assert_eq!(collect_error_labels(&error), "uri_origin_missing"); + assert_eq!(error.message(), "request must have scheme and authority set"); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn validate_scheme_ensure_http_rejected() { + let handler = Dispatch::new( + DispatchMode::single(TransportHandler::new(FakeHandler::from_status_codes([StatusCode::OK]))), + RequestFilter::Https, + ); + + let request = Request::get(Uri::from_static("http://dummy.org/relative-path")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + let error = handler.execute(request).await.unwrap_err(); + + assert_eq!(error.recovery().kind(), RecoveryKind::Never); + assert_eq!(collect_error_labels(&error), "scheme_not_allowed"); + assert_eq!( + error.message(), + "unable to communicate with 'http://dummy.org', because the 'http' scheme is not allowed by this HTTP client" + ); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn validate_scheme_ensure_https_accepted() { + let handler = Dispatch::new( + DispatchMode::single(TransportHandler::new(FakeHandler::default())), + RequestFilter::Https, + ); + + let request = Request::get(Uri::from_static("https://dummy.org/relative-path")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + let _result = handler.execute(request).await.unwrap(); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn validate_scheme_ensure_http_accepted() { + let handler = Dispatch::new( + DispatchMode::single(TransportHandler::new(FakeHandler::default())), + RequestFilter::HttpAndHttps, + ); + + let request = Request::get(Uri::from_static("http://dummy.org/relative-path")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + let _result = handler.execute(request).await.unwrap(); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn forward_attempt_number() { + let handler = Dispatch::new( + DispatchMode::single(TransportHandler::new(FakeHandler::from(StatusCode::OK))), + RequestFilter::HttpAndHttps, + ); + + let request = Request::get(Uri::from_static("http://dummy.org/relative-path")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + let response = handler.execute(request).await.unwrap(); + assert!(response.extensions().get::().is_none()); + + let mut request = Request::get(Uri::from_static("http://dummy.org/relative-path")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + request.extensions_mut().insert(Attempt::new(4, false)); + + let response = handler.execute(request).await.unwrap(); + assert_eq!(response.extensions().get::().copied().unwrap(), Attempt::new(4, false)); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn pool_index_selects_specific_pool() { + let handler = Dispatch::new( + DispatchMode::pooled( + vec![ + TransportHandler::new(FakeHandler::from(StatusCode::OK)), + TransportHandler::new(FakeHandler::from(StatusCode::ACCEPTED)), + ], + PoolSelection::saturating(PoolSelection::DEFAULT_REQUESTS_PER_CLIENT), + ), + RequestFilter::HttpAndHttps, + ); + + let mut request = Request::get(Uri::from_static("http://dummy.org/path")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + request.extensions_mut().insert(PoolIndex::new(1)); + + let response = handler.execute(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::ACCEPTED); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn pool_index_out_of_bounds_falls_back_to_strategy() { + let handler = Dispatch::new( + DispatchMode::pooled( + vec![ + TransportHandler::new(FakeHandler::from(StatusCode::OK)), + TransportHandler::new(FakeHandler::from(StatusCode::ACCEPTED)), + ], + PoolSelection::saturating(PoolSelection::DEFAULT_REQUESTS_PER_CLIENT), + ), + RequestFilter::HttpAndHttps, + ); + + let mut request = Request::get(Uri::from_static("http://dummy.org/path")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + request.extensions_mut().insert(PoolIndex::new(99)); + + // Out-of-bounds falls back to strategy, which selects index 0 first + let response = handler.execute(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn pooled_dispatch_mode_has_debug_representation() { + let mode = DispatchMode::pooled( + vec![ + TransportHandler::new(FakeHandler::from(StatusCode::OK)), + TransportHandler::new(FakeHandler::from(StatusCode::ACCEPTED)), + ], + PoolSelection::saturating(PoolSelection::DEFAULT_REQUESTS_PER_CLIENT), + ); + + insta::assert_debug_snapshot!(mode); + } +} diff --git a/crates/fetch/src/handlers/logging.rs b/crates/fetch/src/handlers/logging.rs new file mode 100644 index 000000000..04c6ec179 --- /dev/null +++ b/crates/fetch/src/handlers/logging.rs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fmt::Debug; + +use data_privacy::{RedactedToString, RedactionEngine}; +use http::uri::Authority; +use http_extensions::UriTemplateLabel; +use layered::{Layer, Service}; +use ohno::ErrorExt; +use tick::Clock; +use tracing::{Level, event}; + +use crate::error_labels::collect_error_labels; +use crate::{HttpRequest, HttpResponse, RequestExt, RequestHandler, Result}; + +/// Logs HTTP requests and responses with timing information. +/// +/// Wraps any `RequestHandler` to add logging. It tracks how long requests take +/// and logs details using tracing events. Successful responses appear at DEBUG level, +/// while errors show up at WARN level. +/// +/// Emits these events: +/// +/// - `http.response.complete`: When a response comes back successfully. +/// - `http.response.error`: When something goes wrong during the request. +#[derive(Debug)] +pub struct Logging { + inner: T, + clock: Clock, + redaction_engine: RedactionEngine, +} + +impl Logging<()> { + /// Creates a new logging handler layer with the provided clock. + #[must_use] + pub fn layer(clock: &Clock, redaction_engine: &RedactionEngine) -> LoggingLayer { + LoggingLayer { + clock: clock.clone(), + redaction_engine: redaction_engine.clone(), + } + } +} + +/// [`Layer`] that wraps a handler with request/response logging. +#[derive(Debug)] +pub struct LoggingLayer { + clock: Clock, + redaction_engine: RedactionEngine, +} + +impl Layer for LoggingLayer { + type Service = Logging; + + /// Creates a new layer that wraps the given service with logging. + /// + /// This layer will log requests and responses using the provided clock for timing. + fn layer(&self, inner: S) -> Self::Service { + Logging { + inner, + clock: self.clock.clone(), + redaction_engine: self.redaction_engine.clone(), + } + } +} + +impl Service for Logging { + type Out = Result; + + fn execute(&self, input: HttpRequest) -> impl Future> + Send { + let watch = self.clock.stopwatch(); + let url = input.uri().clone(); + let method = input.method().clone(); + let template = input.uri_template_label().map(UriTemplateLabel::into_cow); + let redacted_path_and_query = redacted_path_and_query(&input, &self.redaction_engine); + + async move { + match self.inner.execute(input).await { + Ok(response) => { + event!( + name: "http.response.complete", + Level::DEBUG, + http.request.method = method.as_str(), + server.address = url.authority().map(Authority::host), + server.port = url.port_u16(), + http.response.status_code = response.status().as_u16(), + network.protocol.version = ?response.version(), + url.scheme = url.scheme_str(), + url.path.template = template.as_deref(), + url.path.redacted = redacted_path_and_query, + http.client.request.duration = watch.elapsed().as_secs_f32(), + "HTTP response received successfully", + ); + + Ok(response) + } + Err(err) => { + event!( + name: "http.response.error", + Level::WARN, + http.request.method = method.as_str(), + server.address = url.authority().map(Authority::host), + error.type = %collect_error_labels(&err), + exception.message = err.message(), + url.scheme = url.scheme_str(), + url.path.template = template.as_deref(), + url.path.redacted = redacted_path_and_query, + http.client.request.duration = watch.elapsed().as_secs_f32(), + "HTTP response failed", + ); + + Err(err) + } + } + } + } +} + +fn redacted_path_and_query(request: &HttpRequest, engine: &RedactionEngine) -> Option { + match request.path_and_query() { + Some(path_and_query) => Some(path_and_query.to_redacted_string(engine)), + None => request + .uri() + .path_and_query() + .map(|v| templated_uri::PathAndQuery::from(v.clone()).to_redacted_string(engine)), + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use data_privacy::simple_redactor::{SimpleRedactor, SimpleRedactorMode}; + use http::{Request, StatusCode}; + use http_extensions::{FakeHandler, HttpBodyBuilder, HttpRequestBuilder}; + use templated_uri::Uri; + use testing_aids::LogCapture; + + use super::*; + use crate::handlers::Dispatch; + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn execute_logs_and_returns_successful_response() { + let capture = LogCapture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let clock = Clock::new_frozen(); + let layer = Logging::layer(&clock, &RedactionEngine::default()); + let handler = layer.layer(Dispatch::new_fake(FakeHandler::from(StatusCode::OK))); + + let request = HttpRequestBuilder::new_fake() + .uri("https://example.com:123/path?query=value") + .build() + .unwrap(); + + let response = handler.execute(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let output = capture.output(); + assert!(output.contains("DEBUG"), "expected DEBUG level, got:\n{output}"); + capture.assert_contains("HTTP response received successfully"); + capture.assert_contains("http.request.method=\"GET\""); + capture.assert_contains("server.address=\"example.com\""); + capture.assert_contains("server.port=123"); + capture.assert_contains("http.response.status_code=200"); + capture.assert_contains("network.protocol.version=HTTP/1.1"); + capture.assert_contains("url.scheme=\"https\""); + capture.assert_contains("url.path.template=\"/path?query=value\""); + // The default redaction engine redacts the path and query to an empty string. + capture.assert_contains("url.path.redacted=\"\""); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn execute_logs_and_propagates_inner_error() { + let capture = LogCapture::new(); + let _guard = tracing::subscriber::set_default(capture.subscriber()); + + let clock = Clock::new_frozen(); + let layer = Logging::layer(&clock, &RedactionEngine::default()); + let handler = layer.layer(Dispatch::new_fake(FakeHandler::never_completes())); + + // Request without scheme/authority triggers a validation error in Dispatch. + let request = Request::get(http::Uri::from_static("/no-authority")) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + + let error = handler.execute(request).await.unwrap_err(); + assert_eq!(collect_error_labels(&error), "uri_origin_missing"); + + let output = capture.output(); + assert!(output.contains("WARN"), "expected WARN level, got:\n{output}"); + capture.assert_contains("HTTP response failed"); + capture.assert_contains("http.request.method=\"GET\""); + capture.assert_contains("error.type=uri_origin_missing"); + capture.assert_contains("exception.message=\"request must have scheme and authority set\""); + capture.assert_contains("url.path.redacted=\"\""); + } + + #[test] + fn redacted_path_and_query_when_templated_path_and_query_attached() { + let engine = RedactionEngine::builder() + .add_class_redactor(Uri::DATA_CLASS, SimpleRedactor::with_mode(SimpleRedactorMode::Passthrough)) + .build(); + + let request = HttpRequestBuilder::new_fake() + .uri("https://example.com/path?query=value") + .build() + .unwrap(); + + let redacted = redacted_path_and_query(&request, &engine); + assert_eq!(redacted, Some("/path?query=value".to_string())); + } + + #[test] + fn redacted_path_and_query_when_templated_path_and_query_not_attached() { + let engine = RedactionEngine::builder() + .add_class_redactor(Uri::DATA_CLASS, SimpleRedactor::with_mode(SimpleRedactorMode::Passthrough)) + .build(); + + let request = Request::builder() + .uri("https://example.com/path?query=value") + .body(HttpBodyBuilder::new_fake().text("abc")) + .unwrap(); + + let redacted = redacted_path_and_query(&request, &engine); + assert_eq!(redacted, Some("/path?query=value".to_string())); + } +} diff --git a/crates/fetch/src/handlers/metrics.rs b/crates/fetch/src/handlers/metrics.rs new file mode 100644 index 000000000..3e92fd06b --- /dev/null +++ b/crates/fetch/src/handlers/metrics.rs @@ -0,0 +1,596 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fmt::{self, Debug}; +use std::sync::Arc; +use std::time::Duration; + +use futures::FutureExt; +use layered::{Layer, Service}; +use opentelemetry::KeyValue; +use opentelemetry::metrics::{Histogram, Meter, MeterProvider}; +use opentelemetry_semantic_conventions::attribute::SERVER_PORT; +use opentelemetry_semantic_conventions::metric::HTTP_CLIENT_REQUEST_DURATION; +use opentelemetry_semantic_conventions::trace::{ + ERROR_TYPE, HTTP_REQUEST_METHOD, HTTP_RESPONSE_STATUS_CODE, NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION, SERVER_ADDRESS, + URL_SCHEME, URL_TEMPLATE, +}; +use seatbelt::RecoveryInfo; +use tick::Clock; + +use crate::error_labels::{LABEL_ABANDONED, collect_error_labels}; +use crate::telemetry::{ + Metering, TelemetryAttributes, http_method_name, network_protocol_name, network_protocol_version, server_port, url_scheme_or, +}; +use crate::{HttpError, HttpRequest, HttpResponse, RequestExt, RequestHandler, Result}; + +type CallbackType = Arc, &[KeyValue]) + Send + Sync>; +type RequestEnricherFn = Arc; +type ResponseEnricherFn = Arc) + Send + Sync>; + +/// Callback invoked after metric attributes have been collected, allowing +/// callers to observe the reported attributes alongside the request result. +#[derive(Clone)] +struct OnRecordCallback(CallbackType); + +impl Debug for OnRecordCallback { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("OnRecordCallback(..)") + } +} + +/// Callback that adds caller-provided attributes derived from the inbound +/// [`HttpRequest`] before the request is dispatched. +#[derive(Clone)] +struct RequestEnricher(RequestEnricherFn); + +impl Debug for RequestEnricher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RequestEnricher(..)") + } +} + +/// Callback that adds caller-provided attributes derived from the request +/// outcome before metrics are recorded. +#[derive(Clone)] +struct ResponseEnricher(ResponseEnricherFn); + +impl Debug for ResponseEnricher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ResponseEnricher(..)") + } +} + +/// Request handler that automatically collects HTTP metrics. +/// +/// Simply drop this handler in front of any existing [`RequestHandler`] to +/// automatically gather OpenTelemetry-compatible metrics for all HTTP requests. +/// +/// # Metrics Collected +/// +/// * **Meter name**: `fetch` +/// * **Duration**: [`http.client.request.duration`](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpclientrequestduration) - How long requests take in seconds +#[derive(Debug)] +pub struct Metrics { + inner: T, + clock: Clock, + request_duration: Histogram, + on_record: Option, + enrich_from_request: Option, + enrich_from_response: Option, +} + +/// Layer that wraps a service with [`Metrics`]. +/// +/// Use [`MetricsLayer::on_record`] to register a callback that is +/// invoked every time a metric is recorded. This lets callers observe the +/// final set of attributes alongside the request result without needing to +/// instrument the histogram themselves. +#[derive(Debug)] +pub struct MetricsLayer { + clock: Clock, + meter: Option, + on_record: Option, + enrich_from_request: Option, + enrich_from_response: Option, +} + +impl MetricsLayer { + /// Sets the [`Meter`] used to create the request-duration histogram when the + /// layer is built. + /// + /// When no meter is configured, the global meter provider is used. + #[must_use] + pub fn meter(mut self, meter: Meter) -> Self { + self.meter = Some(meter); + self + } + + /// Sets the meter from the given [`MeterProvider`], creating the `fetch` + /// meter used to record the request-duration histogram when the layer is + /// built. + /// + /// When no meter is configured, the global meter provider is used. + #[must_use] + pub fn meter_provider(mut self, meter_provider: &dyn MeterProvider) -> Self { + self.meter = Some(Metering::custom(meter_provider).into()); + self + } + + /// Registers a callback that is invoked each time a request metric is + /// recorded, receiving the request duration, the result, and the + /// collected [`KeyValue`] attributes. + #[must_use] + pub fn on_record(mut self, callback: impl Fn(Duration, &Result, &[KeyValue]) + Send + Sync + 'static) -> Self { + self.on_record = Some(OnRecordCallback(Arc::new(callback))); + self + } + + /// Registers a closure that enriches the metric [`TelemetryAttributes`] + /// using information from the outgoing [`HttpRequest`]. + /// + /// The closure is invoked once per request, after the built-in request + /// attributes have been collected and before the request is dispatched. + /// Any attributes pushed to the provided `TelemetryAttributes` are merged + /// into the final set of metric attributes for that request. + #[must_use] + pub fn enrich_from_request(mut self, enricher: impl Fn(&mut TelemetryAttributes, &HttpRequest) + Send + Sync + 'static) -> Self { + self.enrich_from_request = Some(RequestEnricher(Arc::new(enricher))); + self + } + + /// Registers a closure that enriches the metric [`TelemetryAttributes`] + /// using information from the request outcome. + /// + /// The closure is invoked once per request, after the built-in response + /// or error attributes have been collected and before the histogram is + /// recorded. Any attributes pushed to the provided `TelemetryAttributes` + /// are merged into the final set of metric attributes for that request. + #[must_use] + pub fn enrich_from_response( + mut self, + enricher: impl Fn(&mut TelemetryAttributes, &Result) + Send + Sync + 'static, + ) -> Self { + self.enrich_from_response = Some(ResponseEnricher(Arc::new(enricher))); + self + } +} + +impl Layer for MetricsLayer { + type Service = Metrics; + + /// Wraps the given service with a [`Metrics`] handler. + /// + /// The resulting handler records request metrics against the configured meter. + fn layer(&self, inner: S) -> Self::Service { + let meter = self.meter.clone().unwrap_or_else(|| Metering::Global.into()); + Metrics { + inner, + clock: self.clock.clone(), + request_duration: build_request_duration(&meter), + on_record: self.on_record.clone(), + enrich_from_request: self.enrich_from_request.clone(), + enrich_from_response: self.enrich_from_response.clone(), + } + } +} + +impl Metrics<()> { + /// Creates a [`Layer`] that records request metrics using the given clock. + /// + /// By default the global meter provider is used to create the request-duration + /// histogram when the layer is built. Use [`MetricsLayer::meter`] or + /// [`MetricsLayer::meter_provider`] to record metrics against a custom meter. + #[must_use] + pub fn layer(clock: &Clock) -> MetricsLayer { + MetricsLayer { + clock: clock.clone(), + meter: None, + on_record: None, + enrich_from_request: None, + enrich_from_response: None, + } + } +} + +/// Builds the request-duration histogram recorded by [`Metrics`]. +fn build_request_duration(meter: &Meter) -> Histogram { + meter + .f64_histogram(HTTP_CLIENT_REQUEST_DURATION) + .with_description("Duration of HTTP client requests.") + .with_unit("s") + .with_boundaries(vec![ + 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, + ]) + .build() +} + +impl Service for Metrics { + type Out = Result; + + fn execute(&self, input: HttpRequest) -> impl Future> + Send { + let watch = self.clock.stopwatch(); + let mut attributes = TelemetryAttributes::default(); + + fill_request_attributes(&mut attributes, &input, self.enrich_from_request.as_ref()); + + let mut guard = MetricsDropGuard { + watch, + attributes, + request_duration: &self.request_duration, + on_record: &self.on_record, + enrich_from_response: &self.enrich_from_response, + already_recorded: false, + }; + + self.inner.execute(input).inspect(move |r| guard.record(r)) + } +} + +fn fill_response_attributes(attributes: &mut TelemetryAttributes, response: &HttpResponse) { + attributes.push(KeyValue::new(NETWORK_PROTOCOL_NAME, network_protocol_name())); + attributes.push(KeyValue::new( + NETWORK_PROTOCOL_VERSION, + network_protocol_version(response.version()), + )); + attributes.push(KeyValue::new(HTTP_RESPONSE_STATUS_CODE, i64::from(response.status().as_u16()))); + + if let Some(values) = response.extensions().get::() { + attributes.extend(values.values().iter().cloned()); + } +} + +fn fill_request_attributes(attributes: &mut TelemetryAttributes, request: &HttpRequest, enricher: Option<&RequestEnricher>) { + attributes.push(KeyValue::new(HTTP_REQUEST_METHOD, http_method_name(request.method()))); + + if let Some(val) = request.uri().authority() { + attributes.push(KeyValue::new(SERVER_ADDRESS, val.host().to_string())); + } + + if let Some(val) = server_port(request.uri()) { + attributes.push(KeyValue::new(SERVER_PORT, val)); + } + + attributes.push(KeyValue::new(URL_SCHEME, url_scheme_or(request.uri().scheme()))); + + if let Some(template) = request.uri_template_label() { + attributes.push(KeyValue::new(URL_TEMPLATE, template.into_cow())); + } + + if let Some(values) = request.extensions().get::() { + attributes.extend(values.values().iter().cloned()); + } + + if let Some(enricher) = enricher { + (enricher.0)(attributes, request); + } +} + +fn fill_error_attributes(attributes: &mut TelemetryAttributes, error: &HttpError) { + attributes.push(KeyValue::new(ERROR_TYPE, collect_error_labels(error).into_cow())); +} + +/// Drop guard that ensures metrics are recorded even when the request future +/// is cancelled. +struct MetricsDropGuard<'a> { + watch: tick::Stopwatch, + attributes: TelemetryAttributes, + request_duration: &'a Histogram, + on_record: &'a Option, + enrich_from_response: &'a Option, + already_recorded: bool, +} + +impl MetricsDropGuard<'_> { + /// Disarm the guard and record metrics with the actual request outcome. + fn record(&mut self, result: &Result) { + self.already_recorded = true; + + match result { + Ok(response) => fill_response_attributes(&mut self.attributes, response), + Err(err) => fill_error_attributes(&mut self.attributes, err), + } + + if let Some(enricher) = self.enrich_from_response { + (enricher.0)(&mut self.attributes, result); + } + + let elapsed = self.watch.elapsed(); + + if let Some(on_record) = self.on_record { + (on_record.0)(elapsed, result, self.attributes.values()); + } + + self.request_duration.record(elapsed.as_secs_f64(), self.attributes.values()); + } +} + +impl Drop for MetricsDropGuard<'_> { + fn drop(&mut self) { + if self.already_recorded { + return; + } + + self.record(&Err(HttpError::other( + "the future has been dropped", + RecoveryInfo::never(), + LABEL_ABANDONED, + ))); + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::task::Poll; + + use futures::executor::block_on; + use http::{Request, StatusCode, Version}; + use http_extensions::{FakeHandler, HttpRequestBuilder}; + use opentelemetry_sdk::metrics::SdkMeterProvider; + use templated_uri::{EscapedString, templated}; + + use super::*; + use crate::{HttpBodyBuilder, HttpResponseBuilder}; + + fn test_layer() -> MetricsLayer { + let provider = SdkMeterProvider::builder().build(); + Metrics::layer(&Clock::new_frozen()).meter_provider(&provider) + } + + fn test_request() -> HttpRequest { + Request::get("https://example.com/test") + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap() + } + + /// Collects attributes into a deterministic, snapshot-friendly representation. + /// + /// The result is sorted by key so the snapshot output is stable regardless of + /// the underlying insertion order. + #[mutants::skip] + fn sorted_attrs(attributes: &[KeyValue]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = attributes + .iter() + .map(|kv| (kv.key.as_str().to_owned(), kv.value.as_str().into_owned())) + .collect(); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + pairs + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_fill_request_attributes() { + let request = Request::get("https://example.com/test?query=value") + .extension(TelemetryAttributes::from_iter([KeyValue::new("extra", "extra_val")])) + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + let mut attributes = TelemetryAttributes::new(); + + fill_request_attributes(&mut attributes, &request, None); + + insta::assert_debug_snapshot!(sorted_attrs(attributes.values())); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_fill_request_attributes_with_template() { + let request = HttpRequestBuilder::new_fake() + .get(CrateUrl { crate_name: "abc".into() }) + .build() + .unwrap(); + + let mut attributes = TelemetryAttributes::new(); + + fill_request_attributes(&mut attributes, &request, None); + + insta::assert_debug_snapshot!(sorted_attrs(attributes.values())); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_fill_request_attributes_with_template_label() { + let request = HttpRequestBuilder::new_fake() + .get(CrateUrl2 { crate_name: "abc".into() }) + .build() + .unwrap(); + + let mut attributes = TelemetryAttributes::new(); + + fill_request_attributes(&mut attributes, &request, None); + + insta::assert_debug_snapshot!(sorted_attrs(attributes.values())); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_fill_response_attributes() { + let extra = TelemetryAttributes::from_iter([KeyValue::new("extra", "extra_val")]); + + let response = HttpResponseBuilder::new_fake() + .status(StatusCode::OK) + .version(Version::HTTP_11) + .extension(extra) + .build() + .unwrap(); + let mut attributes = TelemetryAttributes::new(); + + fill_response_attributes(&mut attributes, &response); + + insta::assert_debug_snapshot!(sorted_attrs(attributes.values())); + } + + #[test] + fn many_attributes_ok() { + let extra = (0..1000) + .map(|v| KeyValue::new(v.to_string(), v.to_string())) + .collect::(); + + let response = HttpResponseBuilder::new_fake() + .status(StatusCode::OK) + .version(Version::HTTP_11) + .extension(extra) + .build() + .unwrap(); + let mut attributes = TelemetryAttributes::new(); + + fill_response_attributes(&mut attributes, &response); + + assert!(attributes.values().len() > 1000); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_fill_error_attributes() { + // Create an error + let error = HttpError::from(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused")); + + let mut attributes = TelemetryAttributes::new(); + + fill_error_attributes(&mut attributes, &error); + + insta::assert_debug_snapshot!(sorted_attrs(attributes.values())); + } + + #[templated(template = "/api/v1/crates/{crate_name}", unredacted)] + #[derive(Clone)] + struct CrateUrl { + crate_name: EscapedString, + } + + #[templated(template = "/api/v1/crates/{crate_name}", label = "crates_api", unredacted)] + struct CrateUrl2 { + crate_name: EscapedString, + } + + #[cfg_attr(miri, ignore)] + #[test] + fn test_fill_request_attributes_with_url_template_label_extension() { + use http_extensions::UriTemplateLabel; + + let mut request = Request::get("https://example.com/api/users/123") + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + request.extensions_mut().insert(UriTemplateLabel::new("/api/users/{id}")); + + let mut attributes = TelemetryAttributes::new(); + + fill_request_attributes(&mut attributes, &request, None); + + insta::assert_debug_snapshot!(sorted_attrs(attributes.values())); + } + + #[cfg_attr(miri, ignore)] // insta snapshots are not supported under Miri. + #[test] + fn callbacks_have_compact_debug_representation() { + let on_record = OnRecordCallback(Arc::new(|_duration, _result, _attrs| {})); + let request_enricher = RequestEnricher(Arc::new(|_attrs, _request| {})); + let response_enricher = ResponseEnricher(Arc::new(|_attrs, _result| {})); + + insta::assert_debug_snapshot!("on_record", on_record); + insta::assert_debug_snapshot!("request_enricher", request_enricher); + insta::assert_debug_snapshot!("response_enricher", response_enricher); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn on_record_is_called() { + let called = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&called); + + let handler = test_layer() + .on_record(move |_duration, _result, _attrs| { + flag.store(true, Ordering::Relaxed); + }) + .layer(FakeHandler::from(StatusCode::OK)); + + block_on(Service::execute(&handler, test_request())).unwrap(); + + assert!(called.load(Ordering::Relaxed)); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn no_callback_by_default() { + let handler = test_layer().layer(FakeHandler::from(StatusCode::OK)); + + let result = block_on(Service::execute(&handler, test_request())); + result.unwrap(); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn enrich_from_request_and_response_add_attributes() { + let recorded_attrs = Arc::new(std::sync::Mutex::new(Vec::::new())); + let attrs_clone = Arc::clone(&recorded_attrs); + + let handler = test_layer() + .enrich_from_request(|attrs, request| { + attrs.push(KeyValue::new("request.method", request.method().as_str().to_owned())); + attrs.push(KeyValue::new("request.custom", "req_val")); + }) + .enrich_from_response(|attrs, result| { + let status = result.as_ref().map_or(-1, |r| i64::from(r.status().as_u16())); + attrs.push(KeyValue::new("response.status", status)); + attrs.push(KeyValue::new("response.is_err", result.is_err())); + }) + .on_record(move |_duration, _result, attrs| { + attrs_clone.lock().unwrap().extend(attrs.iter().cloned()); + }) + .layer(FakeHandler::from(StatusCode::OK)); + + block_on(Service::execute(&handler, test_request())).unwrap(); + + let attrs = recorded_attrs.lock().unwrap(); + insta::assert_debug_snapshot!(sorted_attrs(&attrs)); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn abandoned_future_records_abandoned_error_type() { + let recorded_attrs = Arc::new(std::sync::Mutex::new(Vec::::new())); + let attrs_clone = Arc::clone(&recorded_attrs); + + let handler = test_layer() + .on_record(move |_duration, _result, attrs| { + attrs_clone.lock().unwrap().extend(attrs.iter().cloned()); + }) + .layer(FakeHandler::from_async_handler(|_req| async { + // This future will never complete because it pends forever. + std::future::pending::>().await + })); + + // Poll the future once so the guard is created, then drop it. + let mut future = Box::pin(Service::execute(&handler, test_request())); + + // Should be pending because the inner handler never resolves. + let waker = futures::task::noop_waker(); + let mut cx = std::task::Context::from_waker(&waker); + assert!(matches!(future.as_mut().poll(&mut cx), Poll::Pending)); + + // Drop the future, triggering the MetricsDropGuard. + drop(future); + + let attrs = recorded_attrs.lock().unwrap(); + insta::assert_debug_snapshot!(sorted_attrs(&attrs)); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn completed_future_does_not_record_abandoned() { + let recorded_attrs = Arc::new(std::sync::Mutex::new(Vec::::new())); + let attrs_clone = Arc::clone(&recorded_attrs); + + let handler = test_layer() + .on_record(move |_duration, _result, attrs| { + attrs_clone.lock().unwrap().extend(attrs.iter().cloned()); + }) + .layer(FakeHandler::from(StatusCode::OK)); + + block_on(Service::execute(&handler, test_request())).unwrap(); + + let attrs = recorded_attrs.lock().unwrap(); + insta::assert_debug_snapshot!(sorted_attrs(&attrs)); + } +} diff --git a/crates/fetch/src/handlers/mod.rs b/crates/fetch/src/handlers/mod.rs new file mode 100644 index 000000000..e3199097d --- /dev/null +++ b/crates/fetch/src/handlers/mod.rs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Composable handlers that process HTTP requests as they flow through the pipeline. +//! +//! Each handler adds one specific behavior — buffering, metrics, logging, or +//! network dispatch — and wraps the next handler in the chain. Stacking them +//! builds the request-processing pipeline used by an +//! [`HttpClient`](crate::HttpClient). +//! +//! See [`RequestHandler`][super::RequestHandler] for how handlers work and how to +//! write your own. +//! +//! # Available Handlers +//! +//! - [`Buffering`]: buffers the entire response body into memory. +//! - [`Metrics`]: collects performance data for monitoring. +//! - [`Logging`]: adds structured request/response logging. +//! - [`Dispatch`]: sends requests to the network (managed by the `HttpClient`). + +mod dispatch; +pub use dispatch::Dispatch; +pub(crate) use dispatch::DispatchMode; + +mod metrics; +pub use metrics::{Metrics, MetricsLayer}; + +mod logging; +pub use logging::{Logging, LoggingLayer}; + +mod buffering; +pub use buffering::{Buffering, BufferingLayer}; + +mod transport; +pub(crate) use transport::TransportHandler; diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__dispatch__tests__pooled_dispatch_mode_has_debug_representation.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__dispatch__tests__pooled_dispatch_mode_has_debug_representation.snap new file mode 100644 index 000000000..6bda6d45c --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__dispatch__tests__pooled_dispatch_mode_has_debug_representation.snap @@ -0,0 +1,15 @@ +--- +source: crates/fetch/src/handlers/dispatch.rs +expression: mode +--- +Pooled { + transports: [ + TransportHandler( + DynamicService, + ), + TransportHandler( + DynamicService, + ), + ], + .. +} diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__abandoned_future_records_abandoned_error_type.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__abandoned_future_records_abandoned_error_type.snap new file mode 100644 index 000000000..122c3b134 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__abandoned_future_records_abandoned_error_type.snap @@ -0,0 +1,26 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(&attrs) +--- +[ + ( + "error.type", + "abandoned", + ), + ( + "http.request.method", + "GET", + ), + ( + "server.address", + "example.com", + ), + ( + "server.port", + "443", + ), + ( + "url.scheme", + "https", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__completed_future_does_not_record_abandoned.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__completed_future_does_not_record_abandoned.snap new file mode 100644 index 000000000..affaed77a --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__completed_future_does_not_record_abandoned.snap @@ -0,0 +1,34 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(&attrs) +--- +[ + ( + "http.request.method", + "GET", + ), + ( + "http.response.status_code", + "200", + ), + ( + "network.protocol.name", + "http", + ), + ( + "network.protocol.version", + "1.1", + ), + ( + "server.address", + "example.com", + ), + ( + "server.port", + "443", + ), + ( + "url.scheme", + "https", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__enrich_from_request_and_response_add_attributes.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__enrich_from_request_and_response_add_attributes.snap new file mode 100644 index 000000000..cb386c5b7 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__enrich_from_request_and_response_add_attributes.snap @@ -0,0 +1,50 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(&attrs) +--- +[ + ( + "http.request.method", + "GET", + ), + ( + "http.response.status_code", + "200", + ), + ( + "network.protocol.name", + "http", + ), + ( + "network.protocol.version", + "1.1", + ), + ( + "request.custom", + "req_val", + ), + ( + "request.method", + "GET", + ), + ( + "response.is_err", + "false", + ), + ( + "response.status", + "200", + ), + ( + "server.address", + "example.com", + ), + ( + "server.port", + "443", + ), + ( + "url.scheme", + "https", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_error_attributes.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_error_attributes.snap new file mode 100644 index 000000000..2fb30cbbf --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_error_attributes.snap @@ -0,0 +1,10 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(attributes.values()) +--- +[ + ( + "error.type", + "io.connection_refused", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes.snap new file mode 100644 index 000000000..eab289699 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes.snap @@ -0,0 +1,26 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(attributes.values()) +--- +[ + ( + "extra", + "extra_val", + ), + ( + "http.request.method", + "GET", + ), + ( + "server.address", + "example.com", + ), + ( + "server.port", + "443", + ), + ( + "url.scheme", + "https", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_template.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_template.snap new file mode 100644 index 000000000..6ee757b17 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_template.snap @@ -0,0 +1,18 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(attributes.values()) +--- +[ + ( + "http.request.method", + "GET", + ), + ( + "url.scheme", + "_OTHER", + ), + ( + "url.template", + "/api/v1/crates/{crate_name}", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_template_label.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_template_label.snap new file mode 100644 index 000000000..3968b05a7 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_template_label.snap @@ -0,0 +1,18 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(attributes.values()) +--- +[ + ( + "http.request.method", + "GET", + ), + ( + "url.scheme", + "_OTHER", + ), + ( + "url.template", + "crates_api", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_url_template_label_extension.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_url_template_label_extension.snap new file mode 100644 index 000000000..8c27756a8 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_request_attributes_with_url_template_label_extension.snap @@ -0,0 +1,26 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(attributes.values()) +--- +[ + ( + "http.request.method", + "GET", + ), + ( + "server.address", + "example.com", + ), + ( + "server.port", + "443", + ), + ( + "url.scheme", + "https", + ), + ( + "url.template", + "/api/users/{id}", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_response_attributes.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_response_attributes.snap new file mode 100644 index 000000000..e3405ea60 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__fill_response_attributes.snap @@ -0,0 +1,22 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: sorted_attrs(attributes.values()) +--- +[ + ( + "extra", + "extra_val", + ), + ( + "http.response.status_code", + "200", + ), + ( + "network.protocol.name", + "http", + ), + ( + "network.protocol.version", + "1.1", + ), +] diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__on_record.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__on_record.snap new file mode 100644 index 000000000..79348b1f2 --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__on_record.snap @@ -0,0 +1,5 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: on_record +--- +OnRecordCallback(..) diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__request_enricher.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__request_enricher.snap new file mode 100644 index 000000000..1007b4aed --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__request_enricher.snap @@ -0,0 +1,5 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: request_enricher +--- +RequestEnricher(..) diff --git a/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__response_enricher.snap b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__response_enricher.snap new file mode 100644 index 000000000..87573f3ba --- /dev/null +++ b/crates/fetch/src/handlers/snapshots/fetch__handlers__metrics__tests__response_enricher.snap @@ -0,0 +1,5 @@ +--- +source: crates/fetch/src/handlers/metrics.rs +expression: response_enricher +--- +ResponseEnricher(..) diff --git a/crates/fetch/src/handlers/transport.rs b/crates/fetch/src/handlers/transport.rs new file mode 100644 index 000000000..4186ce235 --- /dev/null +++ b/crates/fetch/src/handlers/transport.rs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use http_extensions::{HttpRequest, HttpResponse, RequestHandler, Result}; +use layered::{DynamicService, DynamicServiceExt, Service}; + +/// Type-erased transport that performs the actual I/O for a request. +/// +/// All upper layers in the fetch pipeline (logging, metrics, retries, +/// buffering, ...) eventually delegate to a transport, which turns an +/// [`HttpRequest`] into bytes on the wire and the response bytes back into an +/// [`HttpResponse`]. Concrete transports include HTTP/1.1 or HTTP/2 over plain +/// TCP (`http://`) or TLS (`https://`), and in-process fakes for tests. +#[derive(Debug)] +pub(crate) struct TransportHandler(pub DynamicService>); + +impl TransportHandler { + pub fn new(handler: H) -> Self { + Self(handler.into_dynamic()) + } +} + +impl Service for TransportHandler { + type Out = Result; + + fn execute(&self, input: HttpRequest) -> impl Future + Send { + self.0.execute(input) + } +} diff --git a/crates/fetch/src/lib.rs b/crates/fetch/src/lib.rs new file mode 100644 index 000000000..ec4bc60a9 --- /dev/null +++ b/crates/fetch/src/lib.rs @@ -0,0 +1,839 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))] +#![cfg_attr( + not(feature = "json"), + allow( + rustdoc::broken_intra_doc_links, + reason = "json feature disabled, intra-doc links to json types will be broken" + ) +)] + +//! 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`] 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`] 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`](https://docs.rs/reqwest/). +//! +//! 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`] module and [`custom::create_builder`] for a worked example. +//! +//! ## How does it compare to `reqwest`? +//! +//! By default both `fetch` and [`reqwest`](https://docs.rs/reqwest/) are built on top of the powerful +//! [`hyper`](https://docs.rs/hyper/) 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`] module.) +//! +//! ```rust,no_run +//! # #[cfg(all(feature = "tokio", any(feature = "rustls", feature = "native-tls")))] +//! # { +//! 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`] 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`] and [`HttpClient::post`] for common operations, and the builder pattern to customize +//! your requests. +//! +//! ## GET Requests +//! +//! ```rust +//! # use fetch::{HttpClient, HttpError, HttpResponse}; +//! # async fn example(client: &HttpClient) -> Result<(), HttpError> { +//! // Simple GET request +//! let response: HttpResponse = client +//! .get("https://www.example.com") +//! .fetch() // Fetch executes the request and returns a response +//! .await?; +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! ## POST Requests +//! +//! ```rust +//! # use fetch::HttpClient; +//! # async fn example(client: &HttpClient) -> Result<(), Box> { +//! // 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?; +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Handling Complex Requests +//! +//! The client supports all standard HTTP methods through dedicated methods like [`HttpClient::put`], +//! [`HttpClient::delete`], and more. For anything else, use [`HttpClient::request`] with any HTTP method: +//! +//! ```rust +//! # use fetch::{HttpClient, HttpError}; +//! # use http::Method; +//! # async fn example(client: &HttpClient) -> Result<(), HttpError> { +//! // Using a custom method +//! let response = client +//! .request(Method::PATCH, "https://api.example.com/items/42") +//! .fetch() +//! .await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! You can customize requests with headers, specific HTTP versions, or by attaching bodies: +//! +//! ```rust +//! # use fetch::{HttpClient, HttpError}; +//! # use http::{header, Version}; +//! # async fn example(client: &HttpClient) -> Result<(), HttpError> { +//! 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?; +//! # Ok(()) +//! # } +//! ``` +//! +//! All these methods return a [`HttpRequestBuilder`] 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`] builder method. +//! This allows you to set a [`BaseUri`] 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 +//! # #[cfg(feature = "test-util")] +//! # { +//! # use http::StatusCode; +//! # use fetch::fake::FakeHandler; +//! # use fetch::HttpClientBuilder; +//! # use fetch::HttpResponseBuilder; +//! # use fetch::fake::FakeDeps; +//! # use templated_uri::BaseUri; +//! # async fn example(builder: HttpClientBuilder) -> Result<(), Box> { +//! 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` +//! # Ok(()) +//! # } +//! # } +//! ``` +//! +//! # Handling Responses +//! +//! When you call [`HttpRequestBuilder::fetch`], you get an [`HttpResponse`] with everything about the response - +//! the body, status code, headers, and more. Under the hood, `HttpResponse` is just a type alias for +//! [`Response`]. +//! +//! Here's what you can do with a response: +//! +//! - Check if it worked: [`HttpResponse::ensure_success`] returns an error if the status isn't `2xx`. +//! - Look at status codes: [`HttpResponse::status`] gives you the HTTP status. +//! - Read headers: [`HttpResponse::headers`] lets you access the response headers. +//! - Get the body: [`HttpResponse::into_body`] gives you just the response body. +//! - Process the data: Convert the body to different formats using methods like [`HttpBody::into_text`], +//! [`HttpBody::into_bytes`], or when the `json` feature is enabled, [`HttpBody::into_json`]. +//! +//! ```rust +//! # use fetch::{HttpBody, HttpClient, HttpResponse, StatusExt}; +//! # async fn example(client: &HttpClient) -> Result<(), Box> { +//! // 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); +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Specialized Fetch Methods +//! +//! Instead of calling [`HttpRequestBuilder::fetch`] and then converting the response body separately, use these +//! convenient shortcut methods: +//! +//! - [`fetch_text`][crate::HttpRequestBuilder::fetch_text]: Gets the response body as a string in one step. +//! - [`fetch_bytes`][crate::HttpRequestBuilder::fetch_bytes]: Gets the body as a memory-efficient `BytesView`. +//! - [`fetch_json`][crate::HttpRequestBuilder::fetch_json]: Gets the response body as zero-copy JSON (requires `json` feature). +//! - [`fetch_json_owned`][crate::HttpRequestBuilder::fetch_json_owned]: 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`] type directly. They return a [`Response`] 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 +//! # use http::Response; +//! # use fetch::{HttpClient, StatusExt}; +//! # use serde_json::json; +//! # async fn example(client: &HttpClient) -> Result<(), Box> { +//! // 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 +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! # URL Handling +//! The HTTP client uses the [`templated_uri`] crate for +//! URL handling, which provides a powerful and flexible way to work with URIs. +//! +//! You can use the [`Uri`] 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](https://datatracker.ietf.org/doc/html/rfc6570) level 3, +//! which means you can use it to easily template more complex paths and queries as well. +//! +//! You can also use the [`Uri`] 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`] will log the used URL template as +//! `url.path.template` +//! +//! For example, you can create a [`Uri`] with a templated path like this: +//! +//! ```rust +//! # use fetch::HttpClient; +//! 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 +//! } +//! +//! # async fn example(client: &HttpClient) -> Result<(), Box> { +//! 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?; +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! ## 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`](templated_uri::PathAndQueryTemplate) +//! structs as classified, allowing you to use classified types from `data_privacy` in your URL templates. +//! +//! ``` +//! 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`][crate::HttpRequestBuilder::json] serializes any Rust type to JSON. +//! - **Receive zero-copy JSON**: [`HttpRequestBuilder::fetch_json`][crate::HttpRequestBuilder::fetch_json] returns a +//! [`Json`][crate::Json] 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`][crate::HttpRequestBuilder::fetch_json_owned] +//! 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`][crate::HttpBody::into_json] 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`][crate::Json] 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 +//! # use fetch::{HttpClient, Response, StatusExt}; +//! # #[cfg(feature = "json")] +//! # use fetch::Json; +//! # use serde::{Deserialize, Serialize}; +//! # use std::borrow::Cow; +//! # #[cfg(feature = "json")] +//! # async fn example(client: &HttpClient) -> Result<(), Box> { +//! // 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); +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! 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 +//! # use fetch::{HttpClient, Response, StatusExt}; +//! # use serde::{Deserialize, Serialize}; +//! # #[cfg(feature = "json")] +//! # async fn example(client: &HttpClient) -> Result<(), Box> { +//! // 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); +//! +//! # Ok(()) +//! # } +//! ``` +//! +//! 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`]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`] +//! 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`](https://docs.rs/reqwest/). To supply your own transport, see the +//! [`custom`] module and [`custom::create_builder`]. +//! +//! ## 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`][crate::pipeline::StandardRequestPipeline] and [`HttpClientBuilder::standard_pipeline`] +//! 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`] method for +//! more details and examples. +//! +//! ### Minimal Pipeline +//! +//! For maximum flexibility, you can use the minimal pipeline that includes only the +//! essential [`Dispatch`][crate::handlers::Dispatch] handler that actually sends requests to the network. +//! This gives you a clean slate to build on: +//! +//! ```rust +//! # use fetch::HttpClientBuilder; +//! # fn example(mut builder: HttpClientBuilder) -> Result<(), Box> { +//! // 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); +//! # Ok(()) +//! # } +//! # struct MyHttpWrapper; +//! # impl MyHttpWrapper { fn new(_: T) -> Self { Self } } +//! ``` +//! +//! 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`] 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 +//! # #[cfg(feature = "test-util")] +//! # fn example() { +//! # use fetch::{HttpClient, HttpResponseBuilder}; +//! # use fetch::fake::FakeHandler; +//! # use http::StatusCode; +//! +//! // 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`] 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`] crate. +//! +//! You can use [`BytesView`] just like other byte buffer types because it implements the same +//! interfaces ([`Buf`] and [`BufMut`]) as the [`bytes`] crate: +//! +//! ```rust +//! # use fetch::HttpClient; +//! # use bytesbuf::{BytesView}; +//! # use bytes::Buf; +//! # +//! # async fn example(client: &HttpClient) -> Result<(), Box> { +//! // 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); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! This lets your app handle large files, streaming media, or other big data without +//! wasting memory or hurting performance. +//! +//! [`BytesView`]: bytesbuf::BytesView +//! [`BytesBuf`]: bytesbuf::BytesBuf +//! [`Buf`]: bytes::Buf +//! [`BufMut`]: bytes::BufMut +//! [`bytes`]: https://docs.rs/bytes +//! +//! # Performance Best Practices +//! +//! Follow these tips for the best performance: +//! +//! ```rust,no_run +//! # use fetch::HttpClient; +//! # use http::Uri; +//! # #[cfg(all(feature = "tokio", any(feature = "rustls", feature = "native-tls")))] +//! # async fn example() -> Result<(), Box> { +//! // 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); +//! # Ok(()) +//! # } +//! # fn process_binary_data(bytes: bytesbuf::BytesView) {} +//! ``` +//! +//! In detail: +//! +//! - **Reuse your client**: Creating an [`HttpClient`] 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`]s once and +//! reuse them to skip the parsing overhead. +//! - **Work with raw [`BytesView`]**: Converting between formats (like [`BytesView`] to `String`) creates +//! allocations and copies data. When handling binary data or large responses, work with [`BytesView`] 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`] crate. These extensions are defined in the [`http_extensions`] crate +//! and re-exported here for convenience. +//! +//! # Resilience +//! +//! The HTTP client has built-in resilience features powered by the [`seatbelt`] 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`][`layered::Service`] 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][crate::pipeline::StandardRequestPipeline] with sensible defaults. +//! +//! # TLS Support +//! +//! The HTTP client supports two TLS backends for making HTTPS requests: +//! +//! - **`rustls`** (default): Uses [`rustls`](https://docs.rs/rustls) with the +//! [`aws-lc-rs`](https://docs.rs/aws-lc-rs) 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`](https://docs.rs/rustls-platform-verifier), +//! 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,no_run +//! # #[cfg(all(feature = "tokio", any(feature = "rustls", feature = "native-tls")))] +//! # { +//! 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()`](tls::TlsOptions::builder_rustls) 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. +#[doc(inline)] +pub use ::http::{Extensions, HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Version}; +#[doc(inline)] +pub use http_extensions::routing; +#[doc(inline)] +pub use seatbelt::{Recovery, RecoveryInfo}; +#[doc(inline)] +pub use templated_uri::{BasePath, BaseUri, Origin, Uri}; + +/// Re-exports of the [`http`](https://docs.rs/http) crate's submodules. +/// +/// These are grouped here to keep the `fetch` crate root uncluttered. The most +/// commonly used `http` types (such as [`HeaderMap`], [`Method`], [`StatusCode`], +/// and [`Version`]) are re-exported directly at the crate root. +pub mod http { + #[doc(inline)] + pub use ::http::{header, method, request, response, status, version}; +} + +pub(crate) mod constants; + +mod error_labels; + +pub mod tls; + +mod client_builder; +pub use client_builder::HttpClientBuilder; + +pub mod options; + +mod client; +pub use client::HttpClient; + +#[cfg(any(feature = "test-util", test))] +pub mod fake; + +pub mod custom; + +#[cfg(all(feature = "tokio", any(feature = "rustls", feature = "native-tls")))] +pub mod tokio; + +pub mod handlers; + +pub mod telemetry; + +#[doc(inline)] +pub use http_extensions::{ + HeaderMapExt, HeaderValueExt, HttpBody, HttpBodyBuilder, HttpError, HttpRequest, HttpRequestBuilder, HttpRequestExt, HttpResponse, + HttpResponseBuilder, RequestExt, RequestHandler, ResponseExt, Result, StatusExt, +}; +#[cfg(any(feature = "json", test))] +pub use http_extensions::{Json, JsonError}; + +pub mod resilience; + +pub mod pipeline; + +/// Longer-form documentation for [`fetch`](crate). +pub mod _documentation; diff --git a/crates/fetch/src/options/mod.rs b/crates/fetch/src/options/mod.rs new file mode 100644 index 000000000..00353b60a --- /dev/null +++ b/crates/fetch/src/options/mod.rs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Configuration options for HTTP client behavior. +//! +//! The transport-level option types are defined in the [`fetch_options`] crate +//! and re-exported here. `ClientOptions` bundles those transport options +//! together with the response-body, routing, redaction, and TLS configuration +//! owned by the `fetch` request pipeline. + +use data_privacy::RedactionEngine; +pub use fetch_options::{ + ConnectionIdleTimeout, ConnectionKeepAlive, ConnectionLifetime, ConnectionPoolOptions, Http2Options, PoolIndex, PoolSelection, + RequestFilter, TransportOptions, +}; +pub use http_extensions::HttpBodyOptions; +use http_extensions::routing::Router; + +use crate::tls::TlsOptions; + +/// Aggregated configuration for an [`HttpClient`](crate::HttpClient). +/// +/// Transport-level knobs live in [`TransportOptions`]; the remaining fields are +/// owned by the request pipeline rather than the transport. +#[derive(Debug, Clone, Default)] +pub(crate) struct ClientOptions { + /// Transport-level configuration handed to transport handlers. + pub transport: TransportOptions, + /// Body-level policies (idle timeout, buffer limit) applied to every response body. + pub response_body_options: HttpBodyOptions, + /// Base-URI rewriting rules. + pub router: Router, + /// Redaction engine used for logging and telemetry. + pub redaction_engine: RedactionEngine, + /// TLS configuration used by the bundled transports. + pub tls: TlsOptions, +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[cfg_attr(miri, ignore)] + #[test] + fn client_options_default_uses_https_filter() { + let options = ClientOptions::default(); + assert!(matches!(options.transport.request_filter, RequestFilter::Https)); + } +} diff --git a/crates/fetch/src/pipeline/builder.rs b/crates/fetch/src/pipeline/builder.rs new file mode 100644 index 000000000..b428488a9 --- /dev/null +++ b/crates/fetch/src/pipeline/builder.rs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use data_privacy::RedactionEngine; +use futures::future::Either; +use http_extensions::HttpBodyBuilder; +use http_extensions::routing::Router; +use layered::{DynamicService, DynamicServiceExt, Service, Stack}; +use opentelemetry::metrics::Meter; +use thread_aware::ThreadAware; +use tick::Clock; + +use crate::handlers::Dispatch; +use crate::pipeline::StandardRequestPipeline; +use crate::pipeline::custom::CustomPipelineFactory; +use crate::pipeline::pipeline_context::PipelineContext; +use crate::pipeline::standard::{ConfigureStandardPipeline, RecoveryMode}; +use crate::resilience::HttpResilienceContext; +use crate::{HttpRequest, HttpResponse}; + +#[derive(Debug, Clone, ThreadAware)] +pub(crate) enum PipelineBuilder { + StandardPipeline(ConfigureStandardPipeline), + Minimal, + Custom(CustomPipelineFactory), +} + +impl Default for PipelineBuilder { + fn default() -> Self { + Self::StandardPipeline(ConfigureStandardPipeline::default()) + } +} + +#[derive(Debug)] +pub(crate) enum Pipeline { + Minimal(Box), + Custom { + #[cfg(test)] + debug: String, + #[cfg(test)] + standard_pipeline: bool, + pipeline: DynamicService>, + }, +} + +#[cfg(test)] +impl Pipeline { + pub fn dbg_string_for_custom_pipeline(&self) -> &str { + match self { + Self::Minimal(_) => panic!("must be custom pipeline"), + Self::Custom { debug, .. } => debug, + } + } + + pub fn is_standard(&self) -> bool { + match self { + Self::Minimal(_) => false, + Self::Custom { standard_pipeline, .. } => *standard_pipeline, + } + } +} + +impl Service for Pipeline { + type Out = crate::Result; + + fn execute(&self, input: HttpRequest) -> impl Future> + Send { + match &self { + Self::Minimal(handler) => Either::Left(handler.execute(input)), + Self::Custom { pipeline, .. } => Either::Right(pipeline.execute(input)), + } + } +} + +impl PipelineBuilder { + pub(crate) fn configure_standard(self, configure: F) -> Self + where + F: Fn(StandardRequestPipeline, PipelineContext) -> StandardRequestPipeline + Send + Sync + 'static, + { + match self { + Self::StandardPipeline(pipeline) => Self::StandardPipeline(pipeline.combine(configure)), + _ => Self::StandardPipeline(ConfigureStandardPipeline::new(configure)), + } + } + + #[expect( + clippy::too_many_arguments, + reason = "all parameters are required dependencies for assembling the pipeline" + )] + pub(crate) fn build( + self, + dispatch_handler: Dispatch, + resilience_context: HttpResilienceContext, + redaction_engine: RedactionEngine, + meter: &Meter, + body_builder: HttpBodyBuilder, + clock: Clock, + router: Router, + ) -> Pipeline { + match self { + Self::StandardPipeline(configure) => { + let context = PipelineContext::new(resilience_context, meter, redaction_engine.clone(), body_builder, clock, router); + let standard = configure.create(context, &redaction_engine); + + match standard.recovery_mode { + RecoveryMode::Retry => { + let service = ( + standard.total_timeout, + standard.retry, + standard.breaker, + standard.attempt_timeout, + standard.attempt_intercept, + standard.attempt_logs, + standard.attempt_metrics, + dispatch_handler, + ) + .into_service(); + + Pipeline::Custom { + #[cfg(test)] + debug: format!("{service:?}"), + #[cfg(test)] + standard_pipeline: true, + pipeline: service.into_dynamic(), + } + } + RecoveryMode::Hedging => { + let service = ( + standard.total_timeout, + standard.hedging, + standard.breaker, + standard.attempt_timeout, + standard.attempt_intercept, + standard.attempt_logs, + standard.attempt_metrics, + dispatch_handler, + ) + .into_service(); + + Pipeline::Custom { + #[cfg(test)] + debug: format!("{service:?}"), + #[cfg(test)] + standard_pipeline: true, + pipeline: service.into_dynamic(), + } + } + } + } + Self::Minimal => Pipeline::Minimal(Box::new(dispatch_handler)), + Self::Custom(factory) => { + let pipeline = factory.create( + dispatch_handler, + PipelineContext::new(resilience_context, meter, redaction_engine, body_builder, clock, router), + ); + + Pipeline::Custom { + #[cfg(test)] + debug: format!("{pipeline:?}"), + #[cfg(test)] + standard_pipeline: false, + pipeline, + } + } + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::time::Duration; + + use http::StatusCode; + use opentelemetry::metrics::MeterProvider; + use opentelemetry_sdk::metrics::SdkMeterProvider; + + use super::*; + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn build_minimal_ok() { + let clock = Clock::new_frozen(); + let dispatch = Dispatch::new_fake(StatusCode::OK); + let pipeline = PipelineBuilder::Minimal.build( + dispatch, + HttpResilienceContext::new(&clock), + RedactionEngine::default(), + &test_meter(), + HttpBodyBuilder::new_fake(), + clock, + Router::default(), + ); + + assert!(matches!(pipeline, Pipeline::Minimal(_))); + assert!(!pipeline.is_standard()); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + #[should_panic(expected = "must be custom pipeline")] + fn dbg_string_for_minimal_pipeline_panics() { + let clock = Clock::new_frozen(); + let dispatch = Dispatch::new_fake(StatusCode::OK); + let pipeline = PipelineBuilder::Minimal.build( + dispatch, + HttpResilienceContext::new(&clock), + RedactionEngine::default(), + &test_meter(), + HttpBodyBuilder::new_fake(), + clock, + Router::default(), + ); + + // The debug accessor is only valid for custom pipelines; a minimal pipeline must panic. + let _ = pipeline.dbg_string_for_custom_pipeline(); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn build_custom_ok() { + let clock = Clock::new_frozen(); + let dispatch = Dispatch::new_fake(StatusCode::OK); + let factory = CustomPipelineFactory::new(|dispatch, _| dispatch); + let pipeline = PipelineBuilder::Custom(factory).build( + dispatch, + HttpResilienceContext::new(&clock), + RedactionEngine::default(), + &test_meter(), + HttpBodyBuilder::new_fake(), + clock, + Router::default(), + ); + + assert!(!pipeline.is_standard()); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn build_standard_ok() { + let clock = Clock::new_frozen(); + let dispatch = Dispatch::new_fake(StatusCode::OK); + let pipeline = PipelineBuilder::StandardPipeline(ConfigureStandardPipeline::default()).build( + dispatch, + HttpResilienceContext::new(&clock), + RedactionEngine::default(), + &test_meter(), + HttpBodyBuilder::new_fake(), + clock, + Router::default(), + ); + + let _dbg = pipeline.dbg_string_for_custom_pipeline(); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn pipeline_builder_default_ok() { + let clock = Clock::new_frozen(); + let dispatch = Dispatch::new_fake(StatusCode::OK); + let pipeline = PipelineBuilder::default().build( + dispatch, + HttpResilienceContext::new(&clock), + RedactionEngine::default(), + &test_meter(), + HttpBodyBuilder::new_fake(), + clock, + Router::default(), + ); + + assert!(pipeline.is_standard()); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn configure_standard() { + let clock = Clock::new_frozen(); + let dispatch = Dispatch::new_fake(StatusCode::OK); + let pipeline = PipelineBuilder::Minimal + .configure_standard(|p, _context| p.retry(|retry| retry.max_retry_attempts(10))) + .build( + dispatch, + HttpResilienceContext::new(&clock), + RedactionEngine::default(), + &test_meter(), + HttpBodyBuilder::new_fake(), + clock, + Router::default(), + ); + + assert!(format!("{pipeline:?}").contains("max_attempts: 11")); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn configure_standard_twice() { + let clock = Clock::new_frozen(); + let dispatch = Dispatch::new_fake(StatusCode::OK); + let pipeline = PipelineBuilder::Minimal + .configure_standard(|p, _context| p.retry(|retry| retry.max_retry_attempts(10))) + .configure_standard(|p, _context| p.attempt_timeout(|timeout| timeout.timeout(Duration::from_secs(123)))) + .build( + dispatch, + HttpResilienceContext::new(&clock), + RedactionEngine::default(), + &test_meter(), + HttpBodyBuilder::new_fake(), + clock, + Router::default(), + ); + + let debug = format!("{pipeline:?}"); + assert!(debug.contains("max_attempts: 11")); + assert!(debug.contains("timeout: 123s")); + } + + fn test_meter() -> Meter { + SdkMeterProvider::default().meter("test") + } +} diff --git a/crates/fetch/src/pipeline/custom.rs b/crates/fetch/src/pipeline/custom.rs new file mode 100644 index 000000000..c857c4cf6 --- /dev/null +++ b/crates/fetch/src/pipeline/custom.rs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::any::type_name; +use std::fmt::Debug; +use std::sync::Arc; + +use layered::{DynamicService, DynamicServiceExt}; +use thread_aware::ThreadAware; + +use crate::handlers::Dispatch; +use crate::pipeline::pipeline_context::PipelineContext; +use crate::{HttpRequest, HttpResponse, RequestHandler}; + +/// A convenience API for creating a custom request pipeline. +#[derive(Clone, ThreadAware)] +pub(crate) struct CustomPipelineFactory( + #[thread_aware(skip)] Arc DynamicService> + Send + Sync>, +); + +impl CustomPipelineFactory { + pub fn new(factory: impl Fn(Dispatch, PipelineContext) -> T + Send + Sync + 'static) -> Self { + Self(Arc::new(move |dispatch, context| factory(dispatch, context).into_dynamic())) + } + + pub fn create(&self, handler: Dispatch, context: PipelineContext) -> DynamicService> { + self.0(handler, context) + } +} + +impl Debug for CustomPipelineFactory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(type_name::()).finish() + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn debug_custom_pipeline_factory() { + let factory = CustomPipelineFactory::new(|r, _| r); + let debug_str = format!("{factory:?}"); + assert_eq!(debug_str, "fetch::pipeline::custom::CustomPipelineFactory"); + } +} diff --git a/crates/fetch/src/pipeline/mod.rs b/crates/fetch/src/pipeline/mod.rs new file mode 100644 index 000000000..e87127442 --- /dev/null +++ b/crates/fetch/src/pipeline/mod.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Request-processing pipelines that wire handlers together for an [`HttpClient`](crate::HttpClient). +//! +//! A pipeline determines which handlers a request passes through, and in what +//! order. The client supports three flavors: +//! +//! - **standard**: a production-ready stack with timeouts, retries, logging, and +//! metrics, configured via [`StandardRequestPipeline`]. +//! - **custom**: a fully user-defined stack of layers over the dispatch handler. +//! - **minimal**: only the dispatch handler, with no middleware. +//! +//! [`PipelineContext`] carries the shared dependencies (clock, meter, router, and +//! so on) handed to pipeline factories. See +//! [`HttpClientBuilder`](crate::HttpClientBuilder) for how each flavor is selected. + +mod builder; +mod custom; +mod standard; + +mod pipeline_context; + +pub(crate) use builder::{Pipeline, PipelineBuilder}; +pub(crate) use custom::CustomPipelineFactory; +pub use pipeline_context::PipelineContext; +pub use standard::{RecoveryMode, StandardRequestPipeline}; diff --git a/crates/fetch/src/pipeline/pipeline_context.rs b/crates/fetch/src/pipeline/pipeline_context.rs new file mode 100644 index 000000000..36bb188b4 --- /dev/null +++ b/crates/fetch/src/pipeline/pipeline_context.rs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use data_privacy::RedactionEngine; +use http_extensions::HttpBodyBuilder; +use http_extensions::routing::Router; +use opentelemetry::metrics::Meter; +use tick::Clock; + +use crate::resilience::HttpResilienceContext; + +/// Context object provided when configuring a custom or standard request pipeline. +/// +/// This context is passed to the factory function in: +/// +/// - [`HttpClientBuilder::custom_pipeline`][crate::HttpClientBuilder::custom_pipeline] +/// - [`HttpClientBuilder::standard_pipeline`][crate::HttpClientBuilder::standard_pipeline] +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct PipelineContext { + meter: Meter, + resilience_context: HttpResilienceContext, + redaction_engine: RedactionEngine, + clock: Clock, + body_builder: HttpBodyBuilder, + router: Router, +} + +impl PipelineContext { + pub(crate) fn new( + resilience_context: HttpResilienceContext, + meter: &Meter, + redaction_engine: RedactionEngine, + body_builder: HttpBodyBuilder, + clock: Clock, + router: Router, + ) -> Self { + Self { + resilience_context, + redaction_engine, + meter: meter.clone(), + body_builder, + clock, + router, + } + } + + /// Returns the clock used for time-related operations. + #[must_use] + pub fn clock(&self) -> &Clock { + &self.clock + } + + /// Returns the meter used for telemetry and metrics. + #[must_use] + pub const fn meter(&self) -> &Meter { + &self.meter + } + + /// Returns the resilience context used for configuring resilience patterns. + #[must_use] + pub fn resilience_context(&self) -> &HttpResilienceContext { + &self.resilience_context + } + + /// Returns the redaction engine used for sensitive data handling. + #[must_use] + pub const fn redaction_engine(&self) -> &RedactionEngine { + &self.redaction_engine + } + + /// Returns the HTTP body builder used for constructing request and response bodies. + #[must_use] + pub const fn body_builder(&self) -> &HttpBodyBuilder { + &self.body_builder + } + + /// Returns the router used for routing requests. + #[must_use] + pub const fn router(&self) -> &Router { + &self.router + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use http_extensions::HttpBodyOptions; + use opentelemetry::metrics::MeterProvider; + use opentelemetry_sdk::metrics::SdkMeterProvider; + + use super::*; + + fn test_context(body_builder: HttpBodyBuilder) -> PipelineContext { + let clock = Clock::new_frozen(); + let meter = SdkMeterProvider::default().meter("test"); + + PipelineContext::new( + HttpResilienceContext::new(&clock), + &meter, + RedactionEngine::default(), + body_builder, + clock, + Router::default(), + ) + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn body_builder_returns_the_configured_builder() { + let body_builder = HttpBodyBuilder::new_fake().with_options(HttpBodyOptions::default().buffer_limit(4321)); + let context = test_context(body_builder); + + // The accessor must expose the exact builder supplied at construction, including its + // distinctive buffer limit. + assert!(format!("{:?}", context.body_builder()).contains("4321")); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn accessors_return_constructed_dependencies() { + let context = test_context(HttpBodyBuilder::new_fake()); + + // Every accessor must hand back the dependency wired in at construction; exercise each + // one and discard the value. The assertion is simply that accessing must not panic. + let _ = context.clock(); + let _ = context.meter(); + let _ = context.resilience_context(); + let _ = context.redaction_engine(); + let _ = context.router(); + } +} diff --git a/crates/fetch/src/pipeline/snapshots/fetch__pipeline__standard__tests__configure_standard_pipeline_debug.snap b/crates/fetch/src/pipeline/snapshots/fetch__pipeline__standard__tests__configure_standard_pipeline_debug.snap new file mode 100644 index 000000000..1882bf3cd --- /dev/null +++ b/crates/fetch/src/pipeline/snapshots/fetch__pipeline__standard__tests__configure_standard_pipeline_debug.snap @@ -0,0 +1,5 @@ +--- +source: crates/fetch/src/pipeline/standard.rs +expression: configure +--- +fetch::pipeline::standard::ConfigureStandardPipeline diff --git a/crates/fetch/src/pipeline/standard.rs b/crates/fetch/src/pipeline/standard.rs new file mode 100644 index 000000000..4afc77140 --- /dev/null +++ b/crates/fetch/src/pipeline/standard.rs @@ -0,0 +1,455 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::any::type_name; +use std::fmt::Debug; +use std::sync::Arc; + +use data_privacy::RedactionEngine; +use http_extensions::HttpResponse; +use http_extensions::routing::Router; +use layered::{Intercept, InterceptLayer}; +use opentelemetry::metrics::Meter; +use thread_aware::ThreadAware; +use tick::Clock; + +use crate::handlers::{Logging, LoggingLayer, Metrics, MetricsLayer}; +use crate::pipeline::PipelineContext; +use crate::resilience::HttpResilienceContext; +use crate::resilience::breaker::{HttpBreaker, HttpBreakerLayer, HttpBreakerLayerExt}; +use crate::resilience::hedging::{HttpHedging, HttpHedgingLayer, HttpHedgingLayerExt}; +use crate::resilience::retry::{HttpRetry, HttpRetryLayer, HttpRetryLayerExt}; +use crate::resilience::timeout::{HttpTimeout, HttpTimeoutLayer, HttpTimeoutLayerExt}; + +const ATTEMPT_TIMEOUT_NAME: &str = "standard.attempt_timeout"; +const ATTEMPT_TIMEOUT_DURATION: std::time::Duration = std::time::Duration::from_secs(10); + +const TOTAL_TIMEOUT_NAME: &str = "standard.total_timeout"; +const TOTAL_TIMEOUT_DURATION: std::time::Duration = std::time::Duration::from_secs(30); + +const RETRY_NAME: &str = "standard.retry"; +const HEDGING_NAME: &str = "standard.hedging"; +const BREAKER_NAME: &str = "standard.breaker"; + +/// Controls which recovery strategy the standard pipeline uses. +/// +/// Both strategies occupy the same position in the middleware stack +/// (after total timeout, before the circuit breaker). The default +/// strategy is [`Retry`][RecoveryMode::Retry]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum RecoveryMode { + /// Retries failed requests sequentially with backoff (default). + #[default] + Retry, + /// Sends hedged requests concurrently to reduce tail latency. + Hedging, +} + +/// Configuration for the standard HTTP request pipeline. +/// +/// The standard pipeline provides a production-ready configuration with essential +/// handlers that most applications need. It includes timeouts, automatic retries, +/// logging, and metrics collection with sensible defaults. +/// +/// This type is used by [`HttpClientBuilder::standard_pipeline`][crate::HttpClientBuilder::standard_pipeline] +/// to configure the default pipeline when creating HTTP clients. Individual layers +/// are configured through builder methods (such as [`retry`][Self::retry] and +/// [`attempt_timeout`][Self::attempt_timeout]) that accept a closure receiving the +/// current layer and returning the modified layer. +/// +/// # Handlers +/// +/// The standard pipeline creates the following handler chain (from outermost to innermost) +/// with production-ready defaults: +/// +/// 1. **total timeout** ([`total_timeout`][Self::total_timeout]): Enforces a total timeout for the entire request/response cycle. +/// 2. **retry** ([`retry`][Self::retry]) *or* **hedging** ([`hedging`][Self::hedging]): Recovers from transient failures. Controlled by [`recovery_mode`][Self::recovery_mode]. +/// 3. **breaker** ([`breaker`][Self::breaker]): Circuit breaker that prevents sending requests to a service that is likely to fail. +/// 4. **attempt timeout** ([`attempt_timeout`][Self::attempt_timeout]): Enforces a per-attempt timeout. +/// 5. **attempt intercept** ([`attempt_intercept`][Self::attempt_intercept]): An interception layer invoked on each request attempt. +/// 6. **logging** ([`attempt_logs`][Self::attempt_logs]): Records request and response information via logging events. +/// 7. **metrics** ([`attempt_metrics`][Self::attempt_metrics]): Collects standardized metrics about HTTP requests. +/// 8. **dispatch** ([`crate::handlers::Dispatch`]): Sends the HTTP request over the network. +#[derive(Debug)] +#[non_exhaustive] +pub struct StandardRequestPipeline { + pub(crate) total_timeout: HttpTimeoutLayer, + pub(crate) retry: HttpRetryLayer, + pub(crate) hedging: HttpHedgingLayer, + pub(crate) breaker: HttpBreakerLayer, + pub(crate) attempt_timeout: HttpTimeoutLayer, + pub(crate) attempt_intercept: InterceptLayer>, + pub(crate) attempt_logs: LoggingLayer, + pub(crate) attempt_metrics: MetricsLayer, + pub(crate) recovery_mode: RecoveryMode, +} + +impl StandardRequestPipeline { + pub(crate) fn new(options: &HttpResilienceContext, redaction: &RedactionEngine, clock: &Clock, meter: &Meter, router: &Router) -> Self { + Self { + total_timeout: HttpTimeout::layer(TOTAL_TIMEOUT_NAME, options) + .http_timeout_error() + .timeout(TOTAL_TIMEOUT_DURATION), + retry: HttpRetry::layer(RETRY_NAME, options) + .http_configure_defaults() + .handle_unavailable(router.has_alternatives()), + hedging: HttpHedging::layer(HEDGING_NAME, options) + .http_configure_defaults() + .handle_unavailable(router.has_alternatives()), + breaker: HttpBreaker::layer(BREAKER_NAME, options).http_configure_defaults(), + attempt_timeout: HttpTimeout::layer(ATTEMPT_TIMEOUT_NAME, options) + .http_timeout_error() + .timeout(ATTEMPT_TIMEOUT_DURATION), + attempt_intercept: Intercept::layer(), + attempt_logs: Logging::layer(clock, redaction), + attempt_metrics: Metrics::layer(clock).meter(meter.clone()), + recovery_mode: RecoveryMode::default(), + } + } + + /// Configures the total timeout for the entire request/response cycle, including retries. + /// + /// # Defaults + /// + /// - **Timeout**: 30 seconds for the entire request/response cycle. + /// - **Name**: `standard.total_timeout` + #[must_use] + pub fn total_timeout(mut self, configure: impl FnOnce(HttpTimeoutLayer) -> HttpTimeoutLayer) -> Self { + self.total_timeout = configure(self.total_timeout); + self + } + + /// Configures the retry layer. + /// + /// This layer is active when [`recovery_mode`][Self::recovery_mode] is set to + /// [`RecoveryMode::Retry`] (the default). + /// + /// # Defaults + /// + /// - **Max Retries**: 3 retry attempts (4 total attempts including the initial one). + /// - **Retry Delay**: 2 seconds between retry attempts. Handles the `Retry-After` header + /// if present. + /// - **Retry Policy**: Retries transient errors (5xx status codes, timeouts, and + /// 429 Too Many Requests). + /// - **Backoff Strategy**: Exponential backoff with jitter. + /// - **Retryable Requests**: Only safe HTTP methods (GET, HEAD, OPTIONS, TRACE) with + /// cloneable bodies. + /// - **Name**: `standard.retry` + #[must_use] + pub fn retry(mut self, configure: impl FnOnce(HttpRetryLayer) -> HttpRetryLayer) -> Self { + self.retry = configure(self.retry); + self + } + + /// Configures the hedging layer. + /// + /// Hedging reduces tail latency by sending concurrent requests and returning the + /// first successful response. This layer is active when [`recovery_mode`][Self::recovery_mode] + /// is set to [`RecoveryMode::Hedging`]. + /// + /// # Defaults + /// + /// - **Cloning strategy**: Safe HTTP methods only (GET, HEAD, OPTIONS, TRACE). + /// - **Recovery classification**: 5xx, 429, and timeouts are treated as transient. + /// - **Name**: `standard.hedging` + #[must_use] + pub fn hedging(mut self, configure: impl FnOnce(HttpHedgingLayer) -> HttpHedgingLayer) -> Self { + self.hedging = configure(self.hedging); + self + } + + /// Configures the circuit breaker layer. + /// + /// The circuit breaker sits between the retry/hedging layer and the attempt timeout, + /// preventing requests from being sent to a service that is likely to fail. + /// + /// When the circuit is open, requests are immediately rejected with an + /// `HttpError::unavailable` error. The original request is attached to the error + /// so that an outer retry layer can restore and re-attempt it. + /// + /// # Defaults + /// + /// - **Recovery classification**: 5xx, 429, and timeouts are treated as failures. + /// - **Rejected request error**: Returns `HttpError::unavailable` when the circuit is open. + /// - **Name**: `standard.breaker` + #[must_use] + pub fn breaker(mut self, configure: impl FnOnce(HttpBreakerLayer) -> HttpBreakerLayer) -> Self { + self.breaker = configure(self.breaker); + self + } + + /// Configures the timeout for each individual request attempt. + /// + /// # Defaults + /// + /// - **Timeout**: 10 seconds per individual request attempt. + /// - **Name**: `standard.attempt_timeout` + #[must_use] + pub fn attempt_timeout(mut self, configure: impl FnOnce(HttpTimeoutLayer) -> HttpTimeoutLayer) -> Self { + self.attempt_timeout = configure(self.attempt_timeout); + self + } + + /// Configures the interception layer that is invoked on each request attempt. + /// + /// This can be used for custom logging, metrics, or other cross-cutting concerns + /// that need to be applied to every attempt, including retries. + /// + /// # Defaults + /// + /// No default behavior; this layer is a no-op unless customized. + #[must_use] + pub fn attempt_intercept( + mut self, + configure: impl FnOnce( + InterceptLayer>, + ) -> InterceptLayer>, + ) -> Self { + self.attempt_intercept = configure(self.attempt_intercept); + self + } + + /// Configures the logging layer that logs each request attempt. + #[must_use] + pub fn attempt_logs(mut self, configure: impl FnOnce(LoggingLayer) -> LoggingLayer) -> Self { + self.attempt_logs = configure(self.attempt_logs); + self + } + + /// Configures the metrics layer that collects standardized HTTP request metrics. + #[must_use] + pub fn attempt_metrics(mut self, configure: impl FnOnce(MetricsLayer) -> MetricsLayer) -> Self { + self.attempt_metrics = configure(self.attempt_metrics); + self + } + + /// Selects the recovery strategy for the standard pipeline. + /// + /// This controls whether the pipeline uses sequential retries + /// ([`RecoveryMode::Retry`]) or concurrent hedged requests + /// ([`RecoveryMode::Hedging`]) for recovering from transient failures. + /// + /// # Defaults + /// + /// [`RecoveryMode::Retry`] — sequential retries with exponential backoff. + #[must_use] + pub fn recovery_mode(mut self, mode: RecoveryMode) -> Self { + self.recovery_mode = mode; + self + } +} + +#[derive(Clone, ThreadAware)] +pub(crate) struct ConfigureStandardPipeline( + #[thread_aware(skip)] Arc StandardRequestPipeline + Send + Sync>, +); + +impl Default for ConfigureStandardPipeline { + fn default() -> Self { + Self::new(|pipeline, _| pipeline) + } +} + +impl ConfigureStandardPipeline { + pub(crate) fn new(func: F) -> Self + where + F: Fn(StandardRequestPipeline, PipelineContext) -> StandardRequestPipeline + Send + Sync + 'static, + { + Self(Arc::new(func)) + } + + pub(crate) fn combine(self, func: F) -> Self + where + F: Fn(StandardRequestPipeline, PipelineContext) -> StandardRequestPipeline + Send + Sync + 'static, + { + let previous = self.0; + + Self::new(move |pipeline, context| { + let intermediate = previous(pipeline, context.clone()); + func(intermediate, context) + }) + } + + pub(crate) fn create(self, context: PipelineContext, redaction: &RedactionEngine) -> StandardRequestPipeline { + let pipeline = StandardRequestPipeline::new( + context.resilience_context(), + redaction, + context.clock(), + context.meter(), + context.router(), + ); + (self.0)(pipeline, context) + } +} + +impl Debug for ConfigureStandardPipeline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct(type_name::()).finish() + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use std::time::Duration; + + use opentelemetry::metrics::MeterProvider; + use opentelemetry_sdk::metrics::SdkMeterProvider; + + use super::*; + + fn test_meter() -> Meter { + SdkMeterProvider::default().meter("test") + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn test_new_with_clock_creates_pipeline() { + let clock = Clock::new_frozen(); + let pipeline = StandardRequestPipeline::new( + &HttpResilienceContext::new(&clock), + &RedactionEngine::default(), + &clock, + &test_meter(), + &Router::default(), + ); + + assert!(format!("{:?}", pipeline.total_timeout).contains("timeout: Some(30s)")); + assert!(format!("{:?}", pipeline.total_timeout).contains("standard.total_timeout")); + + assert!(format!("{:?}", pipeline.retry).contains("max_attempts: 4")); + assert!(format!("{:?}", pipeline.attempt_timeout).contains("timeout: Some(10s)")); + assert!(format!("{:?}", pipeline.attempt_timeout).contains("standard.attempt_timeout")); + } + + #[test] + fn test_default_timeout_constants() { + assert_eq!(TOTAL_TIMEOUT_DURATION, Duration::from_secs(30)); + assert_eq!(ATTEMPT_TIMEOUT_DURATION, Duration::from_secs(10)); + assert!(TOTAL_TIMEOUT_DURATION > ATTEMPT_TIMEOUT_DURATION); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn test_debug_implementation() { + let clock = Clock::new_frozen(); + let pipeline = StandardRequestPipeline::new( + &HttpResilienceContext::new(&clock), + &RedactionEngine::default(), + &clock, + &test_meter(), + &Router::default(), + ); + + let debug_str = format!("{pipeline:?}"); + assert!(debug_str.contains("StandardRequestPipeline")); + assert!(debug_str.contains("total_timeout")); + assert!(debug_str.contains("retry")); + assert!(debug_str.contains("hedging")); + assert!(debug_str.contains("breaker")); + assert!(debug_str.contains("attempt_timeout")); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn test_multiple_pipelines_are_independent() { + let clock = Clock::new_frozen(); + let pipeline1 = StandardRequestPipeline::new( + &HttpResilienceContext::new(&clock), + &RedactionEngine::default(), + &clock, + &test_meter(), + &Router::default(), + ); + let pipeline2 = StandardRequestPipeline::new( + &HttpResilienceContext::new(&clock), + &RedactionEngine::default(), + &clock, + &test_meter(), + &Router::default(), + ); + + // Different instances should have different memory addresses + // We can't test this directly, but we can verify they are separate by formatting + let debug1 = format!("{pipeline1:?}"); + let debug2 = format!("{pipeline2:?}"); + + // Both should contain the same structure but be independent instances + assert!(debug1.contains("StandardRequestPipeline")); + assert!(debug2.contains("StandardRequestPipeline")); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn test_default_recovery_mode_is_retry() { + let clock = Clock::new_frozen(); + let pipeline = StandardRequestPipeline::new( + &HttpResilienceContext::new(&clock), + &RedactionEngine::default(), + &clock, + &test_meter(), + &Router::default(), + ); + + assert_eq!(pipeline.recovery_mode, RecoveryMode::Retry); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn test_recovery_mode_can_be_set_to_hedging() { + let clock = Clock::new_frozen(); + let pipeline = StandardRequestPipeline::new( + &HttpResilienceContext::new(&clock), + &RedactionEngine::default(), + &clock, + &test_meter(), + &Router::default(), + ) + .recovery_mode(RecoveryMode::Hedging); + + assert_eq!(pipeline.recovery_mode, RecoveryMode::Hedging); + } + + #[cfg_attr(miri, ignore)] // SdkMeterProvider uses operations unsupported by Miri. + #[test] + fn test_attempt_layer_configure_closures_are_invoked() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let clock = Clock::new_frozen(); + let invocations = Arc::new(AtomicUsize::new(0)); + + let intercept_flag = Arc::clone(&invocations); + let logs_flag = Arc::clone(&invocations); + let metrics_flag = Arc::clone(&invocations); + + let _pipeline = StandardRequestPipeline::new( + &HttpResilienceContext::new(&clock), + &RedactionEngine::default(), + &clock, + &test_meter(), + &Router::default(), + ) + .attempt_intercept(move |intercept| { + intercept_flag.fetch_add(1, Ordering::Relaxed); + intercept + }) + .attempt_logs(move |logs| { + logs_flag.fetch_add(1, Ordering::Relaxed); + logs + }) + .attempt_metrics(move |metrics| { + metrics_flag.fetch_add(1, Ordering::Relaxed); + metrics + }); + + assert_eq!(invocations.load(Ordering::Relaxed), 3); + } + + #[cfg_attr(miri, ignore)] // insta snapshots are not supported under Miri. + #[test] + fn test_configure_standard_pipeline_debug() { + let configure = ConfigureStandardPipeline::default(); + insta::assert_debug_snapshot!(configure); + } +} diff --git a/crates/fetch/src/resilience.rs b/crates/fetch/src/resilience.rs new file mode 100644 index 000000000..ed956067e --- /dev/null +++ b/crates/fetch/src/resilience.rs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Re-exports the [`seatbelt_http`] crate which provides HTTP-specific +//! extensions for [`seatbelt`] resilience middleware. +//! +//! These extensions simplify the configuration of HTTP-related resilience +//! functionality by specializing the generic [`seatbelt`] middleware types +//! for [`HttpRequest`][http_extensions::HttpRequest] / +//! [`Result`][http_extensions::Result] and exposing HTTP-aware +//! builder methods (all prefixed with `http_`). +//! +//! # Configuring the standard pipeline +//! +//! The easiest way to add resilience is through +//! [`HttpClientBuilder::standard_pipeline`][crate::HttpClientBuilder::standard_pipeline], +//! which gives you a pre-built [`StandardRequestPipeline`][crate::pipeline::StandardRequestPipeline] +//! whose individual layers you can tweak: +//! +//! ```rust +//! # use std::time::Duration; +//! # use fetch::HttpClientBuilder; +//! # use fetch::resilience::HttpClone; +//! # use fetch::resilience::retry::HttpRetryLayerExt; +//! # use http::StatusCode; +//! # use seatbelt::retry::Backoff; +//! # fn example(builder: HttpClientBuilder) { +//! let client = builder +//! .standard_pipeline(|pipeline, _context| { +//! // Allow retrying idempotent methods (GET, PUT, DELETE, …) +//! // instead of only safe methods (GET, HEAD). +//! pipeline.retry(|retry| retry.http_clone(HttpClone::idempotent())) +//! }) +//! .build(); +//! # } +//! ``` +//! +//! ## Switching to hedging +//! +//! The standard pipeline supports two recovery strategies. By default it uses +//! sequential retries, but you can switch to concurrent hedging for lower +//! tail latency: +//! +//! ```rust +//! # use fetch::HttpClientBuilder; +//! # use fetch::pipeline::RecoveryMode; +//! # fn example(builder: HttpClientBuilder) { +//! let client = builder +//! .standard_pipeline(|pipeline, _context| pipeline.recovery_mode(RecoveryMode::Hedging)) +//! .build(); +//! # } +//! ``` +//! +//! # Configuring a custom pipeline +//! +//! For full control you can replace the entire pipeline via +//! [`HttpClientBuilder::custom_pipeline`][crate::HttpClientBuilder::custom_pipeline]. +//! Build any combination of [`seatbelt_http`] layers and stack them on top of +//! the dispatch handler: +//! +//! ```rust +//! # use std::time::Duration; +//! # use fetch::handlers::Logging; +//! # use fetch::HttpClientBuilder; +//! # use fetch::resilience::HttpRecovery; +//! # use fetch::resilience::retry::{HttpRetry, HttpRetryLayerExt}; +//! # use fetch::resilience::timeout::{HttpTimeout, HttpTimeoutLayerExt}; +//! # use layered::Stack; +//! # use seatbelt::RecoveryInfo; +//! # fn example(builder: HttpClientBuilder) { +//! let client = builder +//! .custom_pipeline(|dispatch, ctx| { +//! let retry = HttpRetry::layer("my_retry", ctx.resilience_context()) +//! .http_configure_defaults() +//! .max_retry_attempts(2); +//! +//! let timeout = HttpTimeout::layer("my_timeout", ctx.resilience_context()) +//! .http_timeout_error() +//! .timeout(Duration::from_secs(5)); +//! +//! // Outermost layer is listed first. +//! (retry, timeout, dispatch).into_service() +//! }) +//! .build(); +//! # } +//! ``` + +pub use seatbelt_http::{HttpClone, HttpRecovery, HttpResilienceContext, breaker, hedging, retry, timeout}; diff --git a/crates/fetch/src/telemetry.rs b/crates/fetch/src/telemetry.rs new file mode 100644 index 000000000..91e1d120e --- /dev/null +++ b/crates/fetch/src/telemetry.rs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Telemetry types for enriching `fetch` metrics and inspecting connections. +//! +//! [`TelemetryAttributes`] lets you attach custom [`KeyValue`] attributes to a +//! request so they are merged into the metrics recorded for it. +//! [`ConnectionInfo`] reports details about the connection that served a response. +//! +//! For the full list of emitted metrics and their attributes, see the +//! [telemetry reference](crate::_documentation::telemetry). + +/// Diagnostic information about the connection that served an HTTP response. +/// +/// Re-exported from [`fetch_options`]. Attached as a response extension by real +/// network connections (not by [`FakeHandler`](crate::fake::FakeHandler)); +/// retrieve via `response.extensions().get::()`. +pub use fetch_options::ConnectionInfo; +use http::Version; +use http::uri::Scheme; +use opentelemetry::metrics::{Meter, MeterProvider}; +use opentelemetry::{KeyValue, Value}; + +pub(crate) const METER_NAME: &str = "fetch"; + +/// A set of key-value attributes that enrich `fetch` telemetry. +/// +/// Attach these to a request (via its extensions) to merge custom dimensions +/// into the metrics recorded for that request. +#[derive(Debug, Clone, Default)] +pub struct TelemetryAttributes(smallvec::SmallVec<[KeyValue; 9]>); + +impl TelemetryAttributes { + /// Creates an empty set of telemetry attributes. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds a [`KeyValue`] attribute to the set. + pub fn push(&mut self, attribute: KeyValue) { + self.0.push(attribute); + } + + /// Returns a slice of the telemetry attributes. + #[must_use] + pub fn values(&self) -> &[KeyValue] { + &self.0 + } +} + +impl FromIterator for TelemetryAttributes { + fn from_iter>(iter: I) -> Self { + Self(smallvec::SmallVec::from_iter(iter)) + } +} + +impl Extend for TelemetryAttributes { + fn extend>(&mut self, iter: T) { + self.0.extend(iter); + } +} + +#[derive(Debug, Default, Clone)] +pub(crate) enum Metering { + #[default] + Global, + Custom(Meter), +} + +impl Metering { + pub fn custom(meter_provider: &dyn MeterProvider) -> Self { + Self::Custom(meter_provider.meter(METER_NAME)) + } +} + +impl From for Meter { + fn from(metering: Metering) -> Self { + match metering { + Metering::Global => opentelemetry::global::meter(METER_NAME), + Metering::Custom(meter) => meter, + } + } +} + +pub(crate) const fn http_method_name(method: &http::Method) -> &'static str { + match *method { + http::Method::GET => "GET", + http::Method::POST => "POST", + http::Method::PUT => "PUT", + http::Method::DELETE => "DELETE", + http::Method::PATCH => "PATCH", + http::Method::HEAD => "HEAD", + http::Method::OPTIONS => "OPTIONS", + http::Method::CONNECT => "CONNECT", + http::Method::TRACE => "TRACE", + _ => "_OTHER", + } +} + +#[cfg_attr(test, mutants::skip)] // Some branches are for optimization and cannot be feasibly distinguished in tests. +pub(crate) fn url_scheme(scheme: &Scheme) -> Value { + match scheme.as_str() { + "http" => Value::from("http"), + "https" => Value::from("https"), + val => Value::from(val.to_string()), + } +} + +#[cfg_attr(test, mutants::skip)] // Some branches are for optimization and cannot be feasibly distinguished in tests. +pub(crate) fn url_scheme_or(scheme: Option<&Scheme>) -> Value { + scheme.map_or_else(|| Value::from("_OTHER"), url_scheme) +} + +pub(crate) fn server_port(uri: &http::Uri) -> Option { + match uri.authority()?.port() { + Some(p) => Some(Value::from(i64::from(p.as_u16()))), + None if uri.scheme() == Some(&Scheme::HTTPS) => Some(Value::from(443)), + None if uri.scheme() == Some(&Scheme::HTTP) => Some(Value::from(80)), + None => None, + } +} + +pub(crate) fn network_protocol_name() -> Value { + // HTTP client does not support other protocols yet. + Value::from("http") +} + +pub(crate) fn network_protocol_version(version: Version) -> Value { + match version { + http::Version::HTTP_11 => Value::from("1.1"), + http::Version::HTTP_2 => Value::from("2.0"), + http::Version::HTTP_3 => Value::from("3.0"), + http::Version::HTTP_10 => Value::from("1.0"), + _ => Value::from("_OTHER"), + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use http::Method; + + use super::*; + + #[test] + fn metering_default() { + let metering = Metering::default(); + assert!(matches!(metering, Metering::Global)); + } + + #[test] + fn metering_custom() { + let metering = Metering::custom(opentelemetry::global::meter_provider().as_ref()); + assert!(matches!(metering, Metering::Custom(_expected_meter))); + } + + #[test] + fn from_metering_global() { + let metering = Metering::Global; + let _meter: Meter = metering.into(); + } + + #[test] + fn http_method_name_standard_methods() { + assert_eq!("GET", http_method_name(&Method::GET)); + assert_eq!("POST", http_method_name(&Method::POST)); + assert_eq!("PUT", http_method_name(&Method::PUT)); + assert_eq!("DELETE", http_method_name(&Method::DELETE)); + assert_eq!("PATCH", http_method_name(&Method::PATCH)); + assert_eq!("HEAD", http_method_name(&Method::HEAD)); + assert_eq!("OPTIONS", http_method_name(&Method::OPTIONS)); + assert_eq!("CONNECT", http_method_name(&Method::CONNECT)); + assert_eq!("TRACE", http_method_name(&Method::TRACE)); + } + + #[test] + fn http_method_name_custom_method() { + let custom_method = Method::from_bytes(b"CUSTOM").unwrap(); + assert_eq!("_OTHER", http_method_name(&custom_method)); + } + + #[test] + fn url_scheme_test() { + assert_eq!(Value::from("https"), url_scheme(&Scheme::HTTPS)); + assert_eq!(Value::from("http"), url_scheme(&Scheme::HTTP)); + assert_eq!(Value::from("abc"), url_scheme(&Scheme::try_from("abc").unwrap())); + + assert_eq!(Value::from("https"), url_scheme_or(Some(&Scheme::HTTPS))); + assert_eq!(Value::from("_OTHER"), url_scheme_or(None)); + } + + #[test] + fn server_port_test() { + use http::Uri; + + let uri_with_port = Uri::from_static("http://example.com:8080/path"); + assert_eq!(Some(Value::from(8080_i64)), server_port(&uri_with_port)); + + let uri_without_port = Uri::from_static("https://example.com/path"); + assert_eq!(Some(Value::from(443_i64)), server_port(&uri_without_port)); + + let uri_without_port = Uri::from_static("http://example.com/path"); + assert_eq!(Some(Value::from(80_i64)), server_port(&uri_without_port)); + + let uri_without_port = Uri::from_static("ftp://example.com/path"); + assert_eq!(None, server_port(&uri_without_port)); + + let relative_uri = Uri::from_static("/path/to/resource"); + assert_eq!(None, server_port(&relative_uri)); + } + + #[test] + fn network_protocol_name_test() { + assert_eq!(Value::from("http"), network_protocol_name()); + } + + #[test] + fn network_protocol_version_test() { + assert_eq!(Value::from("1.0"), network_protocol_version(Version::HTTP_10)); + + assert_eq!(Value::from("1.1"), network_protocol_version(Version::HTTP_11)); + + assert_eq!(Value::from("2.0"), network_protocol_version(Version::HTTP_2)); + + assert_eq!(Value::from("3.0"), network_protocol_version(Version::HTTP_3)); + + assert_eq!(Value::from("_OTHER"), network_protocol_version(Version::HTTP_09)); + } +} diff --git a/crates/fetch/src/tls.rs b/crates/fetch/src/tls.rs new file mode 100644 index 000000000..f37a4b26d --- /dev/null +++ b/crates/fetch/src/tls.rs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TLS configuration for HTTP clients. +//! +//! All TLS types are defined in the [`fetch_tls`] crate and re-exported here so +//! that `fetch` callers can configure TLS without depending on `fetch_tls` +//! directly. The concrete backend is selected by the enabled Cargo features +//! (`rustls` and/or `native-tls`), which `fetch` forwards to `fetch_tls`. + +#[cfg(any(feature = "native-tls", test))] +pub use fetch_tls::NativeTlsOptions; +#[cfg(any(feature = "rustls", test))] +pub use fetch_tls::RustlsOptions; +pub use fetch_tls::{AutoBackend, ClientIdentity, ClientIdentityError, TlsOptions, TlsOptionsBuilder}; diff --git a/crates/fetch/src/tokio.rs b/crates/fetch/src/tokio.rs new file mode 100644 index 000000000..b9151a7b3 --- /dev/null +++ b/crates/fetch/src/tokio.rs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tokio-runtime entry points for [`HttpClient`]. +//! +//! This module groups the Tokio runtime dependencies ([`TokioDeps`]) and the +//! factory methods that produce HTTP clients backed by the Tokio runtime and the +//! [`fetch_hyper`] transport. They are gated behind the `tokio` feature combined with a +//! TLS backend (`rustls` and/or `native-tls`). + +use anyspawn::Spawner; +use fetch_hyper::HyperTransportBuilder; +use fetch_options::TransportOptions; +use fetch_tls::{TlsBackend, TlsBackendBuilder}; +use http_extensions::Result; +use hyper_util::rt::TokioIo; +use templated_uri::BaseUri; +use thread_aware::ThreadAware; +use tick::Clock; + +use crate::custom::{CustomContext, CustomDeps, Isolation}; +use crate::handlers::TransportHandler; +use crate::tls::TlsOptions; +use crate::{HttpClient, HttpClientBuilder}; + +/// Configuration dependencies for Tokio runtime HTTP operations. +/// +/// Contains the necessary dependencies for HTTP client operations in a Tokio +/// environment, including clock access and memory management. +#[derive(Debug, Clone, ThreadAware)] +#[fundle::deps] +pub struct TokioDeps { + /// Clock for timing operations and timeouts. + pub clock: Clock, + /// Memory pool for usage-neutral memory allocations. + pub global_pool: bytesbuf::mem::GlobalPool, +} + +impl Default for TokioDeps { + fn default() -> Self { + Self::with_clock(&Clock::new_tokio()) + } +} + +impl TokioDeps { + /// Creates `TokioDeps` with the given clock and a dedicated HTTP-client memory pool. + #[must_use] + pub fn with_clock(clock: &Clock) -> Self { + Self { + global_pool: bytesbuf::mem::GlobalPool::new(), + clock: clock.clone(), + } + } +} + +impl HttpClient { + /// Creates a new HTTP client builder for the Tokio runtime. + /// + /// This factory method provides a builder specifically configured for Tokio. + /// Use this when working with Tokio-based applications. + /// + /// Available only when compiled with the `tokio` feature and a TLS backend + /// (`rustls` and/or `native-tls`). + pub fn builder_tokio(deps: impl Into) -> HttpClientBuilder { + let deps = deps.into(); + let clock = deps.clock.clone(); + let global_pool = deps.global_pool.clone(); + + // Re-layer on top of the in-crate `builder_custom_internal` path: the + // full `TokioDeps` rides through `CustomDeps::extras` so that the + // per-slot factory has the same data it had with the previous direct + // transport factory call. + Self::builder_custom_internal( + |cx| TransportHandler(build_tokio_handler(cx).into()), + Isolation::Shared, + CustomDeps { + clock, + global_pool, + extras: deps, + }, + ) + } + + /// Creates a new HTTP client for the Tokio runtime. + /// + /// This method creates a fully configured HTTP client instance with the default + /// configuration. Use [`builder_tokio`][Self::builder_tokio] if you want to customize the + /// client (e.g. supply a custom [`TokioDeps`]) before creating it. + /// + /// Available only when compiled with the `tokio` feature and a TLS backend + /// (`rustls` and/or `native-tls`). + #[must_use] + pub fn new_tokio() -> Self { + Self::builder_tokio(TokioDeps::default()).build() + } +} + +/// Plain-TCP connector for the Tokio transport. +/// +/// Named pipes / Unix-domain sockets are intentionally not supported; the +/// connector opens a TCP stream to the request authority and hands the wrapped +/// stream to hyper. TLS, when required, is layered on top by the transport. +#[derive(Clone)] +struct TokioConnector; + +impl layered::Service for TokioConnector { + type Out = Result>; + + async fn execute(&self, input: BaseUri) -> Self::Out { + let host = input.authority().host(); + let port = input.try_effective_port()?; + let stream = ::tokio::net::TcpStream::connect((host, port)).await?; + Ok(TokioIo::new(stream)) + } +} + +fn build_tokio_handler(cx: CustomContext) -> fetch_hyper::HyperTransport { + let tls_backend = build_tls_backend(&cx.options, cx.tls); + + HyperTransportBuilder::new(TokioConnector, Spawner::new_tokio(), cx.clock, cx.options) + .body_builder(cx.body_builder) + .pool_index(cx.pool_index) + .meter(cx.meter) + .build(tls_backend) +} + +/// Materializes the client's [`TlsOptions`] into a concrete [`TlsBackend`]. +/// +/// When the `rustls` feature is enabled, rustls is wired up with the aws-lc-rs +/// crypto provider and the platform certificate verifier (the OS trust store), +/// and rustls becomes the default backend. When only `native-tls` is enabled it +/// becomes the default backend instead. +fn build_tls_backend(options: &TransportOptions, tls: TlsOptions) -> TlsBackend { + let mut builder = TlsBackendBuilder::new(); + if !options.supported_http_versions.is_empty() { + builder = builder.supported_http_versions(&options.supported_http_versions); + } + + #[cfg(any(feature = "rustls", test))] + { + // aws-lc-rs is the default crypto provider when rustls is enabled. + let provider = std::sync::Arc::new(::rustls::crypto::aws_lc_rs::default_provider()); + let verifier = std::sync::Arc::new( + rustls_platform_verifier::Verifier::new(std::sync::Arc::clone(&provider)) + .expect("the platform certificate verifier must initialize with the aws-lc-rs crypto provider"), + ); + // `configure_rustls` auto-promotes rustls to the default backend. + builder = builder.configure_rustls(provider, verifier); + } + + #[cfg(all(feature = "native-tls", not(any(feature = "rustls", test))))] + { + builder = builder.defaults_to_native_tls(); + } + + // `build_backend` is fallible (invalid client identity material, missing + // backend configuration), but `build()` on the transport is infallible. Any + // failure here reflects a misconfigured `TlsOptions` supplied by the caller, + // which is a programming error surfaced eagerly at client construction. + builder + .build_backend(tls) + .expect("TLS backend construction must succeed for the configured TlsOptions") +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use http::StatusCode; + use http_extensions::FakeHandler; + use thread_aware::ThreadAware; + use thread_aware::affinity::pinned_affinities; + use tick::Clock; + + use super::TokioDeps; + use crate::pipeline::Pipeline; + use crate::{HttpClient, HttpResponseBuilder}; + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_builder_tokio() { + let clock = Clock::new_tokio(); + let client = HttpClient::builder_tokio(TokioDeps::with_clock(&clock)).minimal_pipeline().build(); + + assert!(matches!(client.pipeline(), Pipeline::Minimal(_))); + + if let Pipeline::Minimal(dispatch) = client.pipeline() { + assert!(matches!(dispatch.mode, crate::handlers::DispatchMode::Single(_))); + } + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn test_new_tokio() { + let clock = Clock::new_tokio(); + let client = HttpClient::builder_tokio(TokioDeps::with_clock(&clock)).build(); + + assert!(client.pipeline().is_standard()); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn new_tokio_uses_default_deps() { + // `new_tokio` builds the client from `TokioDeps::default()`, exercising the default + // dependency wiring (including `Clock::new_tokio`) and the standard pipeline. + let client = HttpClient::new_tokio(); + + assert!(client.pipeline().is_standard()); + } + + #[cfg_attr(miri, ignore)] + #[tokio::test] + async fn tokio_client_works_after_relocation() { + let affinities = pinned_affinities(&[2]); + let clock = Clock::new_tokio(); + + let mut client = HttpClient::builder_tokio(TokioDeps::with_clock(&clock)) + .custom_pipeline(|_root, _ctx| { + FakeHandler::from_sync_handler(|_request| HttpResponseBuilder::new_fake().status(StatusCode::OK).build()) + }) + .build(); + + // Verify the client works before relocation. + let response = client.get("https://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // Relocate the client to a different affinity. + client.relocate(None, affinities[0]); + + // Verify the relocated client still serves requests correctly. + let response = client.get("https://example.com/after-relocation").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[cfg_attr(miri, ignore)] + #[test] + fn build_tls_backend_skips_empty_supported_http_versions() { + use fetch_options::TransportOptions; + + use crate::tls::TlsOptions; + + // An empty `supported_http_versions` means "no preference", so + // `build_tls_backend` must leave the builder's own default versions in place. + // It must NOT forward the empty list to `TlsBackendBuilder::supported_http_versions`, + // which panics on an empty slice. The `!is_empty()` guard is what prevents that + // panic; without it, materializing the backend here panics. + let mut options = TransportOptions::default(); + options.supported_http_versions = Vec::new(); + + // Must not panic: the empty list has to be skipped, not forwarded. + let _backend = super::build_tls_backend(&options, TlsOptions::default()); + } +} diff --git a/crates/fetch/tests/requests.rs b/crates/fetch/tests/requests.rs new file mode 100644 index 000000000..aa64e817d --- /dev/null +++ b/crates/fetch/tests/requests.rs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests exercising real network requests through the HTTP client. + +use std::time::Duration; + +use bytes::Bytes; +use fetch::options::{ConnectionLifetime, ConnectionPoolOptions, PoolIndex}; +use fetch::telemetry::ConnectionInfo; +use fetch::tokio::TokioDeps; +use fetch::{HttpClient, HttpClientBuilder}; +use http::{StatusCode, Version}; +use http_extensions::HttpBodyOptions; +use ohno::{ErrorExt, assert_error_message}; +use tick::ClockControl; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn response_ok() { + let client = create_builder().build(); + + let server = serve(Bytes::from("Hello World!")).await; + let response = client.get(server.uri() + "/hello-world").fetch_text().await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn response_buffering_limit() { + let client = create_builder() + .response_body_options(HttpBodyOptions::default().buffer_limit(1024)) + .build(); + let content = vec![0; 1025]; + let server = serve(Bytes::from(content)).await; + + let error = client.get(server.uri() + "/hello-world").fetch_buffered().await.unwrap_err(); + + assert_eq!(error.message(), "body size exceeds the limit of 1024 bytes"); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn no_tls_http_1_selected() { + let client = create_builder().build(); + let server = serve(Bytes::from_static(b"hello")).await; + + let version = client.get(server.uri() + "/hello-world").fetch().await.unwrap().version(); + + assert_eq!(version, Version::HTTP_11); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn no_tls_http_2_accepted() { + let client = create_builder().supported_http_versions(&[Version::HTTP_2]).build(); + let server = serve(Bytes::from_static(b"hello")).await; + + let _response = client.get(server.uri() + "/hello-world").fetch().await.unwrap(); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn no_tls_http_2_rejected() { + let client = create_builder() + .supported_http_versions(&[Version::HTTP_2, Version::HTTP_3]) + .build(); + let server = serve(Bytes::from_static(b"hello")).await; + + let error = client.get(server.uri() + "/hello-world").fetch().await.unwrap_err(); + + assert_error_message!(error, "client error (Connect)"); + error.find_source_with::(|e| { + e.message() + == "the connection was established with unsupported HTTP version: HTTP/1.1, supported versions are: [HTTP/2.0, HTTP/3.0]" + }); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn no_tls_http_3_rejected() { + let client = create_builder() + .supported_http_versions(&[Version::HTTP_2, Version::HTTP_3]) + .build(); + let server = serve(Bytes::from_static(b"hello")).await; + + let error = client.get(server.uri() + "/hello-world").fetch().await.unwrap_err(); + + assert_error_message!(error, "client error (Connect)"); + error.find_source_with::(|e| { + e.message() + == "the connection was established with unsupported HTTP version: HTTP/1.1, supported versions are: [HTTP/2.0, HTTP/3.0]" + }); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +#[cfg(feature = "json")] +async fn json_owned() { + use serde::Deserialize; + #[derive(Deserialize, Debug)] + struct Person { + name: String, + surname: String, + } + + let json = Bytes::from_static(br#"{"name": "John", "surname": "Doe"}"#); + let client = create_builder().build(); + let server = serve(json).await; + + let person = client + .get(server.uri() + "/hello-world") + .fetch_json_owned::() + .await + .unwrap() + .into_body(); + + assert_eq!(person.name, "John"); + assert_eq!(person.surname, "Doe"); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +#[cfg(feature = "json")] +async fn json_borrowed() { + use serde::Deserialize; + #[derive(Deserialize, Debug)] + struct Person<'a> { + name: std::borrow::Cow<'a, str>, + surname: std::borrow::Cow<'a, str>, + } + + let json = Bytes::from_static(br#"{"name": "John", "surname": "Doe"}"#); + let client = create_builder().build(); + let server = serve(json).await; + + let mut json = client + .get(server.uri() + "/hello-world") + .fetch_json::() + .await + .unwrap() + .into_body(); + + let person = json.read().unwrap(); + assert_eq!(person.name, "John"); + assert_eq!(person.surname, "Doe"); +} + +/// Verifies two related guarantees of the real network pipeline: +/// +/// 1. Every response served by a real connection carries a [`ConnectionInfo`] +/// extension whose configured `pool_index` and `max_age` match the client. +/// 2. Once the connection's age exceeds the configured maximum lifetime, the +/// handler poisons it so the pool drops it; subsequent requests are then +/// served by a freshly established connection. +/// +/// Time is driven by a [`ClockControl`] so the test does not need to sleep for +/// real wall-clock durations. +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn connection_info_attached_and_recreated_after_max_lifetime() { + let control = ClockControl::new(); + let max_age = Duration::from_mins(1); + let client = HttpClient::builder_tokio(TokioDeps::with_clock(&control.to_clock())) + .insecure_allow_http() + .connection_pool_options(ConnectionPoolOptions::default().connection_lifetime(ConnectionLifetime::fixed(max_age))) + .build(); + + let server = serve(Bytes::from_static(b"hello")).await; + let url = server.uri() + "/hello-world"; + + // Fresh connection: `ConnectionInfo` must be attached, carry the configured + // pool index, and report a non-poisoned, brand-new connection. + let response = client.get(url.clone()).fetch().await.unwrap(); + let info = response + .extensions() + .get::() + .expect("ConnectionInfo must be attached to every response served by a real connection"); + assert_eq!(info.pool_index(), PoolIndex::new(0)); + assert!(!info.is_poisoned()); + + // Drive the controlled clock past the configured max lifetime. The next + // request reuses the pooled connection one final time; the handler observes + // the age overflow and poisons the connection so the pool drops it. + control.advance(max_age + Duration::from_secs(1)); + + let response = client.get(url.clone()).fetch().await.unwrap(); + let expired_info = response + .extensions() + .get::() + .expect("ConnectionInfo must be attached to every response served by a real connection"); + assert!( + expired_info.is_poisoned(), + "expected the connection to be poisoned once its age exceeded max_age", + ); + + // Subsequent requests must be served by a freshly established connection + // whose `ConnectionInfo` is not poisoned. + let response = client.get(url).fetch().await.unwrap(); + let fresh_info = response + .extensions() + .get::() + .expect("ConnectionInfo must be attached to every response served by a real connection"); + assert!(!fresh_info.is_poisoned(), "a newly established connection must not be poisoned"); +} + +fn create_builder() -> HttpClientBuilder { + HttpClient::builder_tokio(TokioDeps::default()).insecure_allow_http() +} + +async fn serve(body: impl Into) -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/hello-world")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body.into().to_vec())) + .mount(&mock_server) + .await; + + mock_server +} diff --git a/crates/fetch/tests/resilience.rs b/crates/fetch/tests/resilience.rs new file mode 100644 index 000000000..e92be00bf --- /dev/null +++ b/crates/fetch/tests/resilience.rs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for HTTP resilience middleware (retry & circuit breaker). + +#![allow(clippy::unwrap_used, reason = "test code")] + +use fetch::fake::{FakeDeps, FakeHandler}; +use fetch::resilience::breaker::{HttpBreaker, HttpBreakerLayerExt}; +use fetch::resilience::retry::{HttpRetry, HttpRetryLayerExt}; +use fetch::{HttpClient, HttpResponseBuilder}; +use http::{Method, StatusCode}; +use http_extensions::HttpError; +use layered::Stack; +use ohno::ErrorExt; +use seatbelt::Recovery; +use seatbelt::retry::Attempt; +use tick::ClockControl; + +const ALL_HTTP_METHODS: &[Method] = &[ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::HEAD, + Method::OPTIONS, + Method::CONNECT, + Method::TRACE, + Method::PATCH, +]; + +// ── Retry integration tests ────────────────────────────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn retry_defaults_for_methods() { + let client = create_retry_client(StatusCode::INTERNAL_SERVER_ERROR); + + for method in ALL_HTTP_METHODS { + let response = client.request(method, "https://example.com").fetch().await.unwrap(); + + let attempt = response.extensions().get::().copied(); + + if method.is_safe() { + assert_eq!(attempt.unwrap(), Attempt::new(3, true)); + } else { + assert_eq!(attempt.unwrap(), Attempt::new(0, false)); + } + } +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn retry_defaults_non_transient_codes() { + let client = create_retry_client(StatusCode::BAD_REQUEST); + let response = client.get("https://example.com").fetch().await.unwrap(); + + assert_eq!(response.extensions().get::().copied().unwrap(), Attempt::new(0, false)); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn retry_defaults_restore_requests() { + let handler = FakeHandler::from_http_error(|req| { + let index = req.extensions().get::().copied().unwrap().index(); + HttpError::unavailable(format!("unavailable-{index}")).with_request(req) + }); + let clock = ClockControl::default().auto_advance_timers(true).to_clock(); + let client = HttpClient::builder_fake(handler, FakeDeps { clock }) + .custom_pipeline(move |dispatch, context| { + let layer = HttpRetry::layer("dummy", context.resilience_context()) + .http_configure_defaults() + .handle_unavailable(true); + (layer, dispatch).into_service() + }) + .build(); + + // Send non-cloneable body, so we rely on restoring the request from error + let error = client + .get("https://example.com") + .stream(futures::stream::empty::>()) + .fetch() + .await + .unwrap_err(); + + assert_eq!(error.message(), "unavailable-3"); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn retry_defaults_non_cloneable_body() { + let client = create_retry_client(StatusCode::INTERNAL_SERVER_ERROR); + let response = client + .get("https://example.com") + .stream(futures::stream::empty::>()) + .fetch() + .await + .unwrap(); + + assert_eq!(response.extensions().get::().unwrap().index(), 0); +} + +fn create_retry_client(status: StatusCode) -> HttpClient { + let clock = ClockControl::default().auto_advance_timers(true).to_clock(); + HttpClient::builder_fake(status, FakeDeps { clock }) + .custom_pipeline(move |dispatch, context| { + let layer = HttpRetry::layer("dummy", context.resilience_context()) + .http_configure_defaults() + .handle_unavailable(true); + (layer, dispatch).into_service() + }) + .build() +} + +// ── Breaker integration tests ──────────────────────────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn breaker_defaults_trips_on_server_errors() { + let client = create_breaker_client(StatusCode::INTERNAL_SERVER_ERROR); + for _ in 0..200 { + let _ = client.get("https://example.com").fetch().await; + } + + let error = client.get("https://example.com").fetch().await.unwrap_err(); + + assert!(error.message().contains("circuit breaker")); + assert_eq!(error.recovery().kind(), seatbelt::RecoveryKind::Unavailable); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn breaker_defaults_does_not_trip_on_success() { + let client = create_breaker_client(StatusCode::OK); + + for _ in 0..200 { + let response = client.get("https://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn breaker_defaults_does_not_trip_on_client_errors() { + let client = create_breaker_client(StatusCode::BAD_REQUEST); + + for _ in 0..200 { + let response = client.get("https://example.com").fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn breaker_rejected_request_error_attaches_request() { + let client = create_breaker_client(StatusCode::INTERNAL_SERVER_ERROR); + for _ in 0..200 { + let _ = client.get("https://example.com").fetch().await; + } + + let mut error = client.get("https://example.com").fetch().await.unwrap_err(); + + let restored = error.take_request(); + assert!(restored.is_some()); +} + +fn create_breaker_client(status: StatusCode) -> HttpClient { + let handler = FakeHandler::from(HttpResponseBuilder::new_fake().status(status).build().unwrap()); + let clock = ClockControl::default().auto_advance_timers(true).to_clock(); + HttpClient::builder_fake(handler, FakeDeps { clock }) + .custom_pipeline(move |dispatch, context| { + let layer = HttpBreaker::layer("test", context.resilience_context()) + .http_configure_defaults() + .min_throughput(100) + .failure_threshold(0.5); + (layer, dispatch).into_service() + }) + .build() +} diff --git a/crates/fetch/tests/standard_pipeline.rs b/crates/fetch/tests/standard_pipeline.rs new file mode 100644 index 000000000..0c48803a9 --- /dev/null +++ b/crates/fetch/tests/standard_pipeline.rs @@ -0,0 +1,416 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for the standard pipeline exercising the circuit breaker, +//! retries, hedging, and per-origin isolation. + +#![allow(clippy::unwrap_used, reason = "test code")] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use fetch::fake::{FakeDeps, FakeHandler}; +use fetch::pipeline::RecoveryMode; +use fetch::{HttpClient, HttpError, HttpResponseBuilder}; +use http::StatusCode; +use http_extensions::routing::Router; +use ohno::ErrorExt; +use seatbelt::retry::Attempt; +use seatbelt::{Recovery, RecoveryKind}; +use templated_uri::BaseUri; +use tick::ClockControl; + +const FAILING_HOST: &str = "https://failing.example.com"; +const HEALTHY_HOST: &str = "https://healthy.example.com"; + +// ── Helpers ────────────────────────────────────────────────────── + +/// Shared counter that tracks how many times the fake handler was invoked. +#[derive(Clone)] +struct Calls(Arc); + +impl Calls { + /// Creates a new counter starting at zero. + fn new() -> Self { + Self(Arc::new(AtomicUsize::new(0))) + } + + /// Atomically increments the counter by one. + fn increment(&self) { + self.0.fetch_add(1, Ordering::Relaxed); + } + + /// Returns the current value of the counter. + fn get(&self) -> usize { + self.0.load(Ordering::Relaxed) + } +} + +/// Sends `count` GET requests to `uri`, discarding the results. +async fn send_requests(client: &HttpClient, uri: &str, count: usize) { + for _ in 0..count { + let _ = client.get(uri).fetch().await; + } +} + +/// Creates a client using the standard pipeline with a fake handler that +/// dispatches responses based on the request host. +/// +/// `failing.example.com` → 500 Internal Server Error +/// everything else → 200 OK +fn create_per_host_client(calls: Calls) -> HttpClient { + let handler = FakeHandler::from_sync_handler(move |req| { + calls.increment(); + + let status = if req.uri().host() == Some("failing.example.com") { + StatusCode::INTERNAL_SERVER_ERROR + } else { + StatusCode::OK + }; + + HttpResponseBuilder::new_fake().status(status).build() + }); + + let clock = ClockControl::default().auto_advance_timers(true).to_clock(); + HttpClient::builder_fake(handler, FakeDeps { clock }) + .standard_pipeline(|pipeline, _| { + pipeline + // Large timeouts to prevent auto-advance timers from + // triggering them before the test logic completes. + .total_timeout(|t| t.timeout(Duration::MAX)) + .attempt_timeout(|t| t.timeout(Duration::MAX)) + .retry(|retry| retry.max_retry_attempts(3).base_delay(Duration::from_millis(1))) + .breaker(|breaker| { + breaker + .min_throughput(5) + .failure_threshold(0.5) + .break_duration(Duration::from_mins(1)) + }) + }) + .build() +} + +/// Creates a client where every request returns the given status code. +fn create_uniform_client(status: StatusCode, calls: Calls) -> HttpClient { + let handler = FakeHandler::from_sync_handler(move |_req| { + calls.increment(); + HttpResponseBuilder::new_fake().status(status).build() + }); + + let clock = ClockControl::default().auto_advance_timers(true).to_clock(); + HttpClient::builder_fake(handler, FakeDeps { clock }) + .standard_pipeline(|pipeline, _| { + pipeline + .total_timeout(|t| t.timeout(Duration::MAX)) + .attempt_timeout(|t| t.timeout(Duration::MAX)) + .retry(|retry| retry.max_retry_attempts(3).base_delay(Duration::from_millis(1))) + .breaker(|breaker| { + breaker + .min_throughput(5) + .failure_threshold(0.5) + .break_duration(Duration::from_mins(1)) + }) + }) + .build() +} + +// ── Breaker trips on server errors ─────────────────────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn breaker_trips_after_repeated_server_errors() { + let calls = Calls::new(); + let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone()); + + // Each logical request triggers 1 initial + 3 retries = 4 handler calls. + // With min_throughput(5) and failure_threshold(0.5), the breaker opens once + // enough failures are recorded. + send_requests(&client, "https://example.com", 10).await; + + let calls_before = calls.get(); + + // The breaker should now be open — the handler should NOT be called. + let error = client.get("https://example.com").fetch().await.unwrap_err(); + + assert!( + error.message().contains("circuit breaker"), + "expected 'circuit breaker' in message, got: {}", + error.message() + ); + assert_eq!(error.recovery().kind(), RecoveryKind::Unavailable); + + // No new handler calls should have occurred. + assert_eq!(calls.get(), calls_before); +} + +// ── Retries are attempted before the breaker trips ─────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn retries_happen_before_breaker_trips() { + let calls = Calls::new(); + let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone()); + + // A single logical request: the retry layer should attempt 1 + 3 = 4 calls. + let response = client.get("https://example.com").fetch().await.unwrap(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let attempt = response.extensions().get::().copied().unwrap(); + assert_eq!(attempt, Attempt::new(3, true)); + assert_eq!(calls.get(), 4); +} + +// ── Success does not trip the breaker ──────────────────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn success_does_not_trip_breaker() { + let calls = Calls::new(); + let client = create_uniform_client(StatusCode::OK, calls.clone()); + + send_requests(&client, "https://example.com", 20).await; + + // One handler call per request — no retries, no rejections. + assert_eq!(calls.get(), 20); +} + +// ── Client errors do not trip the breaker ──────────────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn client_errors_do_not_trip_breaker() { + let calls = Calls::new(); + let client = create_uniform_client(StatusCode::BAD_REQUEST, calls.clone()); + + send_requests(&client, "https://example.com", 20).await; + + // Client errors are not retried and do not trip the breaker. + assert_eq!(calls.get(), 20); +} + +// ── Per-origin isolation ───────────────────────────────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn breaker_is_isolated_per_origin() { + let calls = Calls::new(); + let client = create_per_host_client(calls.clone()); + + // Hammer the failing host to trip its breaker. + send_requests(&client, FAILING_HOST, 20).await; + + // Verify the failing host's breaker is open. + let error = client.get(FAILING_HOST).fetch().await.unwrap_err(); + assert!( + error.message().contains("circuit breaker"), + "expected breaker open for failing host, got: {}", + error.message() + ); + + // The healthy host should be completely unaffected. + let response = client.get(HEALTHY_HOST).fetch().await.unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "healthy host must not be affected by the failing host's breaker" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn healthy_host_stays_healthy_while_failing_host_is_broken() { + let calls = Calls::new(); + let client = create_per_host_client(calls.clone()); + + // Send requests to both hosts — the breaker tracks state per-origin, + // so the order does not matter. + send_requests(&client, HEALTHY_HOST, 20).await; + send_requests(&client, FAILING_HOST, 20).await; + + // The failing host's breaker should be open. + let error = client.get(FAILING_HOST).fetch().await.unwrap_err(); + assert!(error.message().contains("circuit breaker")); + + // The healthy host should still work. + let response = client.get(HEALTHY_HOST).fetch().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +// ── Rejected request carries the original request ──────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn breaker_rejection_attaches_request() { + let calls = Calls::new(); + let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone()); + + send_requests(&client, "https://example.com", 20).await; + + let mut error = client.get("https://example.com").fetch().await.unwrap_err(); + + assert!(error.message().contains("circuit breaker")); + assert!(error.take_request().is_some(), "rejected request should be attached to the error"); +} + +// ── Retry observes breaker opening mid-sequence ────────────────── + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn retry_receives_breaker_rejection() { + // Use a handler where the first few calls succeed (to let the breaker + // accumulate failures on a *previous* batch) and then the breaker is + // already open when a new request arrives. + let calls = Calls::new(); + let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone()); + + // Trip the breaker first. + send_requests(&client, "https://example.com", 20).await; + + let calls_before = calls.get(); + + // Now the retry layer should see the breaker rejection immediately, without + // ever reaching the handler. + let error = client.get("https://example.com").fetch().await.unwrap_err(); + + assert!(error.message().contains("circuit breaker")); + assert_eq!(calls.get(), calls_before, "handler should not be called when the breaker is open"); +} + +// ── Hedging reduces tail latency ───────────────────────────────── + +const HEDGING_DELAY: Duration = Duration::from_millis(100); + +/// Creates a hedging client whose handler returns status codes from the +/// given iterator in order. +fn create_hedging_client(calls: Calls, responses: Vec) -> HttpClient { + let clock = ClockControl::default().auto_advance_timers(true).to_clock(); + let responses = Arc::new(std::sync::Mutex::new(responses.into_iter())); + + let handler = FakeHandler::from_sync_handler(move |_req| { + calls.increment(); + let status = responses.lock().unwrap().next().unwrap_or(StatusCode::SERVICE_UNAVAILABLE); + HttpResponseBuilder::new_fake().status(status).build() + }); + + HttpClient::builder_fake(handler, FakeDeps { clock }) + .standard_pipeline(|pipeline, _| { + pipeline + .total_timeout(|t| t.timeout(Duration::MAX)) + .attempt_timeout(|t| t.timeout(Duration::MAX)) + .recovery_mode(RecoveryMode::Hedging) + .hedging(|hedging| hedging.max_hedged_attempts(1).hedging_delay(HEDGING_DELAY)) + }) + .build() +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn hedging_returns_success_response() { + let calls = Calls::new(); + // First attempt: 500 (transient) → hedged attempt: 200 OK. + let client = create_hedging_client(calls.clone(), vec![StatusCode::INTERNAL_SERVER_ERROR, StatusCode::OK]); + + let response = client.get("https://example.com").fetch().await.unwrap(); + + // The hedged attempt should have returned 200 OK. + assert_eq!(response.status(), StatusCode::OK); + + // Both attempts should have been dispatched (original + 1 hedge). + assert_eq!(calls.get(), 2); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn hedging_returns_first_success_immediately() { + let calls = Calls::new(); + // First attempt succeeds right away — no hedge needed. + let client = create_hedging_client(calls.clone(), vec![StatusCode::OK, StatusCode::OK]); + + let response = client.get("https://example.com").fetch().await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + // Only the initial attempt should have been dispatched. + assert_eq!(calls.get(), 1, "no hedge should fire when the first attempt succeeds"); +} + +// ── Fallback router ────────────────────────────────────────────── + +const PRIMARY_HOST: &str = "primary.example.com"; +const SECONDARY_HOST: &str = "secondary.example.com"; + +/// Tracks per-host invocations of the fake handler so the test can assert +/// that both the primary and the fallback endpoints were exercised. +#[derive(Clone, Default)] +struct HostCalls { + primary: Arc, + secondary: Arc, +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn fallback_router_recovers_when_primary_is_unavailable() { + let calls = HostCalls::default(); + let handler_calls = calls.clone(); + + // Primary always fails with an `unavailable` error so the retry layer in + // the standard pipeline (which enables `handle_unavailable` whenever the + // router exposes alternatives) routes the next attempt to the fallback. + let handler = FakeHandler::from_sync_handler(move |req| match req.uri().host() { + Some(PRIMARY_HOST) => { + handler_calls.primary.fetch_add(1, Ordering::Relaxed); + Err(HttpError::unavailable("primary is down").with_request(req)) + } + Some(SECONDARY_HOST) => { + handler_calls.secondary.fetch_add(1, Ordering::Relaxed); + HttpResponseBuilder::new_fake().status(StatusCode::OK).build() + } + other => panic!("unexpected host: {other:?}"), + }); + + let clock = ClockControl::default().auto_advance_timers(true).to_clock(); + let client = HttpClient::builder_fake(handler, FakeDeps { clock }) + .router(Router::fallback( + BaseUri::from_static("https://primary.example.com/"), + BaseUri::from_static("https://secondary.example.com/"), + )) + .standard_pipeline(|pipeline, _| { + pipeline + .total_timeout(|t| t.timeout(Duration::MAX)) + .attempt_timeout(|t| t.timeout(Duration::MAX)) + .retry(|retry| retry.max_retry_attempts(3).base_delay(Duration::from_millis(1))) + }) + .build(); + + let response = client.get("/foo").fetch().await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + // One retry was needed: the initial attempt hit the primary (unavailable), + // the second attempt hit the fallback and succeeded. + let attempt = response.extensions().get::().copied().unwrap(); + assert_eq!(attempt, Attempt::new(1, false)); + assert_eq!( + calls.primary.load(Ordering::Relaxed), + 1, + "primary endpoint should have been attempted exactly once" + ); + assert_eq!( + calls.secondary.load(Ordering::Relaxed), + 1, + "fallback endpoint should have served the request exactly once" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn hedging_does_not_clone_unsafe_methods() { + let calls = Calls::new(); + let client = create_hedging_client(calls.clone(), vec![StatusCode::INTERNAL_SERVER_ERROR, StatusCode::OK]); + + // POST is not safe — the default clone strategy refuses to clone it, + // so no hedged attempt is sent and we get the 500 response. + let response = client.post("https://example.com").fetch().await.unwrap(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(calls.get(), 1, "unsafe method should not produce a hedged attempt"); +} diff --git a/crates/fetch/tests/thread_aware.rs b/crates/fetch/tests/thread_aware.rs new file mode 100644 index 000000000..4610b9798 --- /dev/null +++ b/crates/fetch/tests/thread_aware.rs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for thread-aware (per-core) client relocation. + +use std::assert_eq; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use bytes::Bytes; +use fetch::HttpClient; +use fetch::tokio::TokioDeps; +use futures::future::join_all; +use thread_aware::ThreadAware; +use thread_aware::affinity::pinned_affinities; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[cfg_attr(miri, ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn not_isolated_on_tokio() { + let counts = Arc::new(AtomicUsize::new(0)); + let clone = Arc::clone(&counts); + + let client = HttpClient::builder_tokio(TokioDeps::default()) + .custom_pipeline(move |dispatch, _| { + clone.fetch_add(1, Ordering::Relaxed); + dispatch + }) + .build(); + assert_eq!(counts.load(Ordering::Relaxed), 1); + + for affinity in pinned_affinities(&[2, 2]) { + let mut client_clone = client.clone(); + client_clone.relocate(None, affinity); + } + assert_eq!(counts.load(Ordering::Relaxed), 1); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tokio_client_relocated_ensure_works() { + let server = serve(Bytes::from("Hello World!")).await; + let url = server.uri() + "/hello-world"; + let client = HttpClient::builder_tokio(TokioDeps::default()).insecure_allow_http().build(); + + // Use the client on tokio + let text = client.get(url.clone()).fetch_text().await.unwrap().into_body(); + assert_eq!(text, "Hello World!"); + + // relocate the client and use it on worker threads + let handles = pinned_affinities(&[2, 2]) + .into_iter() + .map(|affinity| { + let mut client = client.clone(); + let url = url.clone(); + tokio::spawn(async move { + client.relocate(None, affinity); + let text = client.get(url).fetch_text().await.unwrap().into_body(); + + assert_eq!(text, "Hello World!"); + }) + }) + .collect::>(); + _ = join_all(handles).await; + + // Use the client on tokio again + let text = client.get(url.clone()).fetch_text().await.unwrap().into_body(); + assert_eq!(text, "Hello World!"); +} + +async fn serve(body: impl Into) -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/hello-world")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(body.into().to_vec())) + .mount(&mock_server) + .await; + + mock_server +} diff --git a/crates/fetch/tests/timeout.rs b/crates/fetch/tests/timeout.rs new file mode 100644 index 000000000..79fadb012 --- /dev/null +++ b/crates/fetch/tests/timeout.rs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for request timeout behavior. + +use std::time::Duration; + +use fetch::fake::FakeDeps; +use fetch::{HttpClient, Recovery, RecoveryInfo}; +use http_extensions::FakeHandler; +use ohno::Labeled; +use tick::ClockControl; + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn response_timeout() { + let handler = FakeHandler::never_completes(); + let clock = ClockControl::new().auto_advance_timers(true).to_clock(); + let client = HttpClient::builder_fake(handler, FakeDeps { clock }).minimal_pipeline().build(); + + let err = client + .get("https://example.com") + .response_timeout(Duration::from_secs(10)) + .fetch() + .await + .unwrap_err(); + + assert_eq!(err.recovery(), RecoveryInfo::retry()); + assert_eq!(err.label(), "response_timeout"); +} diff --git a/crates/fetch/tests/uri_handling.rs b/crates/fetch/tests/uri_handling.rs new file mode 100644 index 000000000..7233e8b62 --- /dev/null +++ b/crates/fetch/tests/uri_handling.rs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Integration tests for URI handling and normalization in the HTTP client. + +#![allow(clippy::unwrap_used, reason = "test code")] + +use fetch::fake::{FakeDeps, FakeHandler}; +use fetch::{HttpBodyBuilder, HttpClient, HttpResponseBuilder}; +use http::{Request, StatusCode}; +use layered::Service; +use templated_uri::{BaseUri, PathAndQuery, Uri}; + +fn prepare_client_with_base_uri(base_uri: BaseUri) -> HttpClient { + HttpClient::builder_fake( + FakeHandler::from_async_handler(|request| async move { + let response = format!("requested URI: {}", request.uri()); + HttpResponseBuilder::new_fake().status(StatusCode::OK).text(response).build() + }), + FakeDeps::default(), + ) + .base_uri(base_uri) + .build() +} + +async fn fetch_text(client: &HttpClient, uri: Uri) -> String { + client.get(uri).fetch_text().await.unwrap().into_body() +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn fetch_method() { + let client = prepare_client_with_base_uri(BaseUri::from_static("https://default.example.com")); + + // Make sure that missing request endpoint is substituted by the client endpoint + let uri_without_endpoint: Uri = Uri::default().with_path_and_query(PathAndQuery::from_static("/foo")); + let response_endpoint_none = fetch_text(&client, uri_without_endpoint).await; + assert_eq!( + response_endpoint_none, "requested URI: https://default.example.com/foo", + "Request endpoint should use the client endpoint" + ); + + // Test with empty path and query + let uri_without_endpoint: Uri = Uri::default().with_path_and_query(PathAndQuery::from_static("/")); + let response_endpoint_none = fetch_text(&client, uri_without_endpoint).await; + assert_eq!( + response_endpoint_none, "requested URI: https://default.example.com/", + "Request endpoint should use the client endpoint" + ); + + // And with default Uri + let response_endpoint_none = fetch_text(&client, Uri::default()).await; + assert_eq!( + response_endpoint_none, "requested URI: https://default.example.com/", + "Request endpoint should use the client endpoint" + ); + + // Test request endpoint replacement + let response_endpoint_set = fetch_text(&client, Uri::try_from("https://example.com/bar").unwrap()).await; + assert_eq!( + response_endpoint_set, "requested URI: https://default.example.com/bar", + "Request endpoint should be overridden by the client endpoint" + ); + + // Test with a different host to guarantee that the whole endpoint is replaced + let response_endpoint_different_host = fetch_text(&client, Uri::try_from("https://192.0.2.42/bar").unwrap()).await; + assert_eq!( + response_endpoint_different_host, "requested URI: https://default.example.com/bar", + "Request endpoint should be overridden by the client endpoint" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn fetch_with_base_uri_with_path() { + let client = prepare_client_with_base_uri(BaseUri::from_static("https://default.example.com/base/")); + + let uri: Uri = Uri::default().with_path_and_query(PathAndQuery::from_static("/foo")); + let full_uri = fetch_text(&client, uri).await; + + assert_eq!( + full_uri, "requested URI: https://default.example.com/base/foo", + "Base URI path should be prepended to the request path" + ); + + let uri: Uri = Uri::default().with_path_and_query(PathAndQuery::from_static("/")); + let full_uri = fetch_text(&client, uri).await; + assert_eq!( + full_uri, "requested URI: https://default.example.com/base/", + "Base URI path should be prepended to the request path" + ); + + let uri: Uri = Uri::default(); + let full_uri = fetch_text(&client, uri).await; + assert_eq!( + full_uri, "requested URI: https://default.example.com/base/", + "Base URI path should be prepended to the request path" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn change_client_base_uri() { + let client = prepare_client_with_base_uri(BaseUri::from_static("https://default.example.com/foo/")); + + let client_2 = client.with_base_uri(BaseUri::from_static("https://default.example.com/bar/")); + + let uri: Uri = Uri::default().with_path_and_query(PathAndQuery::from_static("/api")); + let full_uri = fetch_text(&client, uri.clone()).await; + + assert_eq!( + full_uri, "requested URI: https://default.example.com/foo/api", + "Former client should keep the old base URI" + ); + + let full_uri = fetch_text(&client_2, uri).await; + + assert_eq!( + full_uri, "requested URI: https://default.example.com/bar/api", + "Derived client should use the new base URI" + ); +} + +#[cfg_attr(miri, ignore)] +#[tokio::test] +async fn send_request() { + let client = prepare_client_with_base_uri(BaseUri::from_static("https://default.example.com")); + let request = Request::builder() + .uri("/send_request") + .body(HttpBodyBuilder::new_fake().empty()) + .unwrap(); + let response = client.execute(request).await.unwrap(); + let response = response.into_body().into_text().await.unwrap(); + assert_eq!( + response, "requested URI: https://default.example.com/send_request", + "Request endpoint should use the client endpoint" + ); +} diff --git a/crates/fetch_options/src/pooling.rs b/crates/fetch_options/src/pooling.rs index e4b8e3816..d23dcfba8 100644 --- a/crates/fetch_options/src/pooling.rs +++ b/crates/fetch_options/src/pooling.rs @@ -83,6 +83,7 @@ const DEFAULT_POOL_LIFETIME: Duration = Duration::from_mins(1); /// [`multiple_pools`](Self::multiple_pools) don't all recycle at the same instant), use /// [`ConnectionLifetime::per_connection`]. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct ConnectionPoolOptions { /// How long idle pooled connections are kept before eviction. pub connection_idle_timeout: ConnectionIdleTimeout, diff --git a/crates/http_extensions/examples/custom_server.rs b/crates/http_extensions/examples/custom_server.rs index 19be2e2e9..1b75e64cf 100644 --- a/crates/http_extensions/examples/custom_server.rs +++ b/crates/http_extensions/examples/custom_server.rs @@ -34,7 +34,7 @@ async fn main() -> Result<(), ohno::AppError> { let body_builder = HttpBodyBuilder::new(GlobalPool::new(), &clock); let body_builder_clone = body_builder.clone(); - // Define an execution stack of middlewares + // Define an execution stack of middleware let stack = ( Intercept::layer() .on_input(|req: &HttpRequest| println!("received request, uri: {}", req.uri())) diff --git a/crates/seatbelt/examples/resilience_pipeline.rs b/crates/seatbelt/examples/resilience_pipeline.rs index 832609370..07675a21a 100644 --- a/crates/seatbelt/examples/resilience_pipeline.rs +++ b/crates/seatbelt/examples/resilience_pipeline.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! This example demonstrates how to combine multiple resilience middlewares +//! This example demonstrates how to combine multiple resilience middleware //! using the `seatbelt` crate to create a robust execution pipeline with basic //! resilience capabilities. @@ -27,7 +27,7 @@ async fn main() -> Result<(), AppError> { // Shared options for resilience middleware let context = ResilienceContext::new(&clock).use_metrics(&meter_provider).name("my_pipeline"); - // Define stack with retry and timeout middlewares + // Define stack with retry and timeout middleware let stack = ( Retry::layer("my_retry", &context) // automatically clones the input for retries diff --git a/crates/seatbelt/examples/tower.rs b/crates/seatbelt/examples/tower.rs index 5526af52e..482753c7c 100644 --- a/crates/seatbelt/examples/tower.rs +++ b/crates/seatbelt/examples/tower.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! This example demonstrates how to combine multiple resilience middlewares +//! This example demonstrates how to combine multiple resilience middleware //! using the `seatbelt` crate with Tower's `ServiceBuilder` to create a robust //! execution pipeline compatible with the Tower ecosystem. @@ -21,7 +21,7 @@ async fn main() -> Result<(), ohno::AppError> { // Shared context for resilience middleware let context = ResilienceContext::new(Clock::new_tokio()).name("tower_pipeline"); - // Build a Tower service with retry and timeout middlewares using ServiceBuilder. + // Build a Tower service with retry and timeout middleware using ServiceBuilder. // Layers are applied bottom-to-top: timeout wraps the inner service first, // then retry wraps the timeout layer. let mut service = ServiceBuilder::new() diff --git a/deny.toml b/deny.toml index cfd4a963a..6cecc2eac 100644 --- a/deny.toml +++ b/deny.toml @@ -13,6 +13,9 @@ allow = [ "Unicode-3.0", "Zlib", "BSL-1.0", + # CDLA-Permissive-2.0 covers the Mozilla CA certificate data bundled by + # `webpki-root-certs` (pulled in transitively via `rustls-platform-verifier`). + "CDLA-Permissive-2.0", ] confidence-threshold = 0.8