fetch 0.11.0

HTTP client with resilience, observability, and Tokio runtime support.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
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<crate::HttpRequest, crate::Result<HttpResponse>>,
    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<crate::HttpRequest, crate::Result<HttpResponse>>,
        ) -> InterceptLayer<crate::HttpRequest, crate::Result<HttpResponse>>,
    ) -> 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<dyn Fn(StandardRequestPipeline, PipelineContext) -> StandardRequestPipeline + Send + Sync>,
);

impl Default for ConfigureStandardPipeline {
    fn default() -> Self {
        Self::new(|pipeline, _| pipeline)
    }
}

impl ConfigureStandardPipeline {
    pub(crate) fn new<F>(func: F) -> Self
    where
        F: Fn(StandardRequestPipeline, PipelineContext) -> StandardRequestPipeline + Send + Sync + 'static,
    {
        Self(Arc::new(func))
    }

    pub(crate) fn combine<F>(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::<Self>()).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);
    }
}