Skip to main content

faucet_core/
io.rs

1//! The I/O call wrapper every connector routes its backend calls through
2//! (RFC 0013, #880).
3//!
4//! A connector's backend call needs the same handful of guarantees no matter
5//! what the backend is: a per-call timeout, an overall deadline, classified
6//! retry that honours a server's stated wait (`Retry-After` →
7//! [`FaucetError::RateLimited`]), prompt cancellation, round-trip metering and
8//! redaction of what an error carries. Before this module each connector wrote
9//! its own copy of some of them. [`CallContext::call`] applies all of them in
10//! one place:
11//!
12//! ```
13//! # async fn ex() -> Result<(), faucet_core::FaucetError> {
14//! use faucet_core::io::CallContext;
15//! use std::time::Duration;
16//!
17//! let ctx = CallContext::new()
18//!     .with_timeout(Duration::from_secs(30))
19//!     .with_retry(faucet_core::RetryPolicy::default());
20//! let n: u32 = ctx.call("query", || async { Ok(42) }).await?;
21//! # assert_eq!(n, 42);
22//! # Ok(())
23//! # }
24//! ```
25//!
26//! ## Who sets what
27//!
28//! A context has two owners. The **connector** owns the per-call behaviour it
29//! documents in its config — the attempt timeout, the [`RetryPolicy`], a
30//! rate-limit budget — and keeps it in a [`CallSlot`] built in `new()`. The
31//! **pipeline** owns the run: it installs a context carrying the cancel token,
32//! the round-trip recorder and any deadline through
33//! [`Source::set_call_context`](crate::Source::set_call_context) /
34//! [`Sink::set_call_context`](crate::Sink::set_call_context). [`CallSlot::get`]
35//! layers the two: a field the connector set wins, every other field comes
36//! from the installed context, and a field neither set takes its default
37//! (a [`DEFAULT_CALL_TIMEOUT`] attempt timeout, no retry).
38//!
39//! The pipeline never hands a sink its cancel token: a cancelled run still
40//! flushes the sink (#146 H16), and a flush that failed on the token would
41//! discard the very output the cancellation is meant to keep. Nor does it hand
42//! either side a retry policy — sink writes are retried page by page under the
43//! duplication-safety rule (ADR 0007), and a source's policy is its own.
44
45use crate::error::FaucetError;
46use crate::observability::RoundtripRecorder;
47use crate::resilience::RetryPolicy;
48use std::fmt;
49use std::future::Future;
50use std::sync::{Arc, OnceLock};
51use std::time::Duration;
52use tokio::time::Instant;
53use tokio_util::sync::CancellationToken;
54
55/// The attempt timeout a call gets when neither the connector nor the caller
56/// set one. Long enough for any single request a healthy backend answers;
57/// short enough that a hung connection cannot stall a run for good.
58pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(300);
59
60/// A redaction function: takes text, returns it with every known secret
61/// replaced. See [`crate::redact`].
62pub type CallRedactor = Arc<dyn Fn(&str) -> String + Send + Sync>;
63
64#[derive(Clone, Default)]
65struct Inner {
66    /// `None` inherits; `Some(None)` means no attempt timeout.
67    timeout: Option<Option<Duration>>,
68    deadline: Option<Instant>,
69    retry: Option<RetryPolicy>,
70    rate_limit_retries: Option<u32>,
71    cancel: Option<CancellationToken>,
72    recorder: Option<Arc<RoundtripRecorder>>,
73    metered: Option<bool>,
74    redactor: Option<CallRedactor>,
75}
76
77/// Everything one backend call needs: timeout, deadline, retry policy, cancel
78/// token, round-trip recorder and redactor. Cheap to clone.
79///
80/// Build one with [`CallContext::new`] and the `with_*` methods; run a call
81/// with [`call`](Self::call). See the [module docs](self).
82#[derive(Clone, Default)]
83pub struct CallContext {
84    inner: Arc<Inner>,
85}
86
87// Immutable once built; a panic cannot leave it half-updated.
88impl std::panic::UnwindSafe for CallContext {}
89impl std::panic::RefUnwindSafe for CallContext {}
90
91impl fmt::Debug for CallContext {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.debug_struct("CallContext")
94            .field("timeout", &self.timeout())
95            .field("deadline", &self.inner.deadline)
96            .field("retry", &self.inner.retry)
97            .field("rate_limit_retries", &self.inner.rate_limit_retries)
98            .field("cancel", &self.inner.cancel.is_some())
99            .field("recorder", &self.inner.recorder.is_some())
100            .field("redactor", &self.inner.redactor.is_some())
101            .finish()
102    }
103}
104
105impl CallContext {
106    /// A context with nothing set: a [`DEFAULT_CALL_TIMEOUT`] attempt timeout,
107    /// one attempt, no cancel token, no deadline, no metering.
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    fn edit(mut self, f: impl FnOnce(&mut Inner)) -> Self {
113        f(Arc::make_mut(&mut self.inner));
114        self
115    }
116
117    /// Bound each attempt by `timeout`. An attempt that runs out fails with a
118    /// retriable [`FaucetError::Timeout`].
119    pub fn with_timeout(self, timeout: Duration) -> Self {
120        self.edit(|i| i.timeout = Some(Some(timeout)))
121    }
122
123    /// Let each attempt run as long as it takes. For a call that legitimately
124    /// outlives any fixed bound (a long poll, a streamed download); a deadline
125    /// still applies.
126    pub fn without_timeout(self) -> Self {
127        self.edit(|i| i.timeout = Some(None))
128    }
129
130    /// End the whole call — every attempt and every backoff — at `deadline`,
131    /// with a [`FaucetError::Timeout`].
132    pub fn with_deadline(self, deadline: Instant) -> Self {
133        self.edit(|i| i.deadline = Some(deadline))
134    }
135
136    /// Retry failed attempts under `policy`: classified by
137    /// [`RetryClass`](crate::RetryClass), backed off with jitter, and a
138    /// [`FaucetError::RateLimited`] waits the time it states.
139    pub fn with_retry(self, policy: RetryPolicy) -> Self {
140        self.edit(|i| i.retry = Some(policy))
141    }
142
143    /// Make exactly one attempt, whatever a layered context says.
144    pub fn without_retry(self) -> Self {
145        self.with_retry(RetryPolicy {
146            max_attempts: 1,
147            ..RetryPolicy::default()
148        })
149    }
150
151    /// Give rate limits their own budget: a [`FaucetError::RateLimited`]
152    /// outcome waits its stated time and is retried up to `n` consecutive
153    /// times without using one of the retry policy's attempts (any other
154    /// outcome resets the count). Without this, a rate limit is an ordinary
155    /// retriable attempt.
156    pub fn with_rate_limit_retries(self, n: u32) -> Self {
157        self.edit(|i| i.rate_limit_retries = Some(n))
158    }
159
160    /// Stop the call, and any backoff, as soon as `cancel` fires, with
161    /// [`FaucetError::Cancelled`].
162    pub fn with_cancel(self, cancel: CancellationToken) -> Self {
163        self.edit(|i| i.cancel = Some(cancel))
164    }
165
166    /// Count each attempt as a round trip, and report throttling and retries,
167    /// on `recorder`.
168    pub fn with_recorder(self, recorder: Arc<RoundtripRecorder>) -> Self {
169        self.edit(|i| i.recorder = Some(recorder))
170    }
171
172    /// Whether [`call`](Self::call) counts each attempt as a round trip
173    /// (default `true`). Throttling and retries are reported either way; turn
174    /// this off for a call whose round trips the connector counts itself.
175    pub fn with_roundtrip_metering(self, on: bool) -> Self {
176        self.edit(|i| i.metered = Some(on))
177    }
178
179    /// Scrub error text with `redactor` instead of the process-wide hook
180    /// ([`crate::redact::redact`]).
181    pub fn with_redactor(self, redactor: CallRedactor) -> Self {
182        self.edit(|i| i.redactor = Some(redactor))
183    }
184
185    /// This context with every field it leaves unset taken from `base`.
186    pub fn inherit(&self, base: &CallContext) -> CallContext {
187        let (mine, b) = (&*self.inner, &*base.inner);
188        CallContext {
189            inner: Arc::new(Inner {
190                timeout: mine.timeout.or(b.timeout),
191                deadline: match (mine.deadline, b.deadline) {
192                    (Some(x), Some(y)) => Some(x.min(y)),
193                    (x, y) => x.or(y),
194                },
195                retry: mine.retry.clone().or_else(|| b.retry.clone()),
196                rate_limit_retries: mine.rate_limit_retries.or(b.rate_limit_retries),
197                cancel: mine.cancel.clone().or_else(|| b.cancel.clone()),
198                recorder: mine.recorder.clone().or_else(|| b.recorder.clone()),
199                metered: mine.metered.or(b.metered),
200                redactor: mine.redactor.clone().or_else(|| b.redactor.clone()),
201            }),
202        }
203    }
204
205    /// The attempt timeout, `None` when attempts are unbounded.
206    pub fn timeout(&self) -> Option<Duration> {
207        self.inner.timeout.unwrap_or(Some(DEFAULT_CALL_TIMEOUT))
208    }
209
210    /// The overall deadline, when one is set.
211    pub fn deadline(&self) -> Option<Instant> {
212        self.inner.deadline
213    }
214
215    /// The retry policy, when one is set (`None` = a single attempt).
216    pub fn retry(&self) -> Option<&RetryPolicy> {
217        self.inner.retry.as_ref()
218    }
219
220    /// The rate-limit budget set by
221    /// [`with_rate_limit_retries`](Self::with_rate_limit_retries).
222    pub fn rate_limit_retries(&self) -> Option<u32> {
223        self.inner.rate_limit_retries
224    }
225
226    /// The cancel token, when one is set.
227    pub fn cancel(&self) -> Option<&CancellationToken> {
228        self.inner.cancel.as_ref()
229    }
230
231    /// Whether the cancel token has fired.
232    pub fn is_cancelled(&self) -> bool {
233        self.inner.cancel.as_ref().is_some_and(|c| c.is_cancelled())
234    }
235
236    /// The round-trip recorder, when one is set.
237    pub fn recorder(&self) -> Option<&Arc<RoundtripRecorder>> {
238        self.inner.recorder.as_ref()
239    }
240
241    /// Count one round trip the connector made outside [`call`](Self::call).
242    pub fn record(&self, op: &'static str) {
243        if let Some(r) = &self.inner.recorder {
244            r.record(op);
245        }
246    }
247
248    /// Report a backend-measured usage figure (see
249    /// [`RoundtripRecorder::signal`]).
250    pub fn signal(&self, kind: &'static str, unit: &'static str, quantity: f64) {
251        if let Some(r) = &self.inner.recorder {
252            r.signal(kind, unit, quantity);
253        }
254    }
255
256    /// Scrub `text` with this context's redactor, or the process-wide one.
257    pub fn redact(&self, text: &str) -> String {
258        match &self.inner.redactor {
259            Some(f) => f(text),
260            None => crate::redact::redact(text),
261        }
262    }
263
264    /// `err` with the text it carries scrubbed: the URL and body of an HTTP
265    /// status error, and the URL of a transport error (credentials in it are
266    /// removed too).
267    pub fn redact_error(&self, err: FaucetError) -> FaucetError {
268        match err {
269            FaucetError::HttpStatus { status, url, body } => FaucetError::HttpStatus {
270                status,
271                url: self.redact(&url),
272                body: self.redact(&body),
273            },
274            FaucetError::Http(e) => FaucetError::Http(redact_reqwest_url(e, |u| self.redact(u))),
275            other => other,
276        }
277    }
278
279    /// Wait `duration` the way a call waits: ended early with
280    /// [`FaucetError::Cancelled`] when the token fires, and with
281    /// [`FaucetError::Timeout`] when the deadline comes first. For a pause that
282    /// is not a retry — a poll interval, a server-requested pacing delay.
283    #[allow(clippy::disallowed_methods)] // the sanctioned connector wait
284    pub async fn wait(&self, duration: Duration) -> Result<(), FaucetError> {
285        if self.is_cancelled() {
286            return Err(cancelled_error("wait"));
287        }
288        let cancelled = async {
289            match &self.inner.cancel {
290                Some(c) => c.cancelled().await,
291                None => std::future::pending().await,
292            }
293        };
294        let end = Instant::now() + duration;
295        match self.inner.deadline {
296            Some(deadline) if deadline < end => {
297                tokio::select! {
298                    biased;
299                    _ = cancelled => Err(cancelled_error("wait")),
300                    _ = tokio::time::sleep_until(deadline) => Err(deadline_error("wait")),
301                }
302            }
303            _ => {
304                tokio::select! {
305                    biased;
306                    _ = cancelled => Err(cancelled_error("wait")),
307                    _ = tokio::time::sleep_until(end) => Ok(()),
308                }
309            }
310        }
311    }
312
313    /// The bound for the next attempt: the attempt timeout, cut to what is
314    /// left of the deadline. `Err` once the deadline has passed.
315    fn attempt_bound(&self, op: &'static str) -> Result<Option<Duration>, FaucetError> {
316        let timeout = self.timeout();
317        match self.inner.deadline {
318            None => Ok(timeout),
319            Some(deadline) => {
320                let left = deadline.saturating_duration_since(Instant::now());
321                if left.is_zero() {
322                    return Err(deadline_error(op));
323                }
324                Ok(Some(timeout.map_or(left, |t| t.min(left))))
325            }
326        }
327    }
328
329    /// Run `f` as one backend call named `op` (a closed, connector-defined
330    /// label such as `query`, `page`, `put`).
331    ///
332    /// Each invocation of `f` is one attempt. The call:
333    /// - fails fast with [`FaucetError::Cancelled`] when the token has fired,
334    ///   and stops an attempt or a backoff the moment it fires;
335    /// - bounds each attempt by the attempt timeout and the whole call by the
336    ///   deadline ([`FaucetError::Timeout`], retriable while the deadline
337    ///   allows);
338    /// - retries under the retry policy: classified errors only, jittered
339    ///   backoff, a [`FaucetError::RateLimited`] waiting its stated time;
340    /// - counts every attempt as a round trip on the recorder (with its
341    ///   duration), and every throttle, throttle wait and retry;
342    /// - scrubs the final error ([`redact_error`](Self::redact_error)).
343    #[allow(clippy::disallowed_methods)] // the sanctioned deadline wait
344    pub async fn call<T, F, Fut>(&self, op: &'static str, mut f: F) -> Result<T, FaucetError>
345    where
346        F: FnMut() -> Fut,
347        Fut: Future<Output = Result<T, FaucetError>>,
348    {
349        if self.is_cancelled() {
350            return Err(cancelled_error(op));
351        }
352        let single = RetryPolicy {
353            max_attempts: 1,
354            ..RetryPolicy::default()
355        };
356        let policy = self.inner.retry.as_ref().unwrap_or(&single);
357        let recorder = self.inner.recorder.as_ref();
358        let metered = self.inner.metered.unwrap_or(true);
359        let attempt = || {
360            let bound = self.attempt_bound(op);
361            let fut = f();
362            async move {
363                let bound = bound?;
364                let started = std::time::Instant::now();
365                let outcome = match bound {
366                    Some(t) => match tokio::time::timeout(t, fut).await {
367                        Ok(r) => r,
368                        Err(_) => Err(FaucetError::Timeout {
369                            operation: format!("{op} call"),
370                            after: t,
371                        }),
372                    },
373                    None => fut.await,
374                };
375                if metered && let Some(r) = recorder {
376                    r.record_timed(op, started.elapsed());
377                }
378                outcome
379            }
380        };
381        let run = crate::resilience::run_call(
382            policy,
383            self.inner.cancel.as_ref(),
384            recorder,
385            self.inner.rate_limit_retries,
386            attempt,
387        );
388        let cancelled = async {
389            match &self.inner.cancel {
390                Some(c) => c.cancelled().await,
391                None => std::future::pending().await,
392            }
393        };
394        let deadline = async {
395            match self.inner.deadline {
396                Some(d) => tokio::time::sleep_until(d).await,
397                None => std::future::pending().await,
398            }
399        };
400        let outcome = tokio::select! {
401            biased;
402            _ = cancelled => Err(cancelled_error(op)),
403            _ = deadline => Err(deadline_error(op)),
404            r = run => r,
405        };
406        outcome.map_err(|e| self.redact_error(e))
407    }
408
409    /// Run `fut` as one backend call named `op`, in a single attempt: the
410    /// form for a call that cannot be rebuilt — a statement on a borrowed
411    /// transaction, a step of a cursor or stream, a non-idempotent write.
412    /// Timeout, deadline, cancel, metering and redaction apply as in
413    /// [`call`](Self::call); any retry policy is ignored.
414    pub async fn call_once<T, Fut>(&self, op: &'static str, fut: Fut) -> Result<T, FaucetError>
415    where
416        Fut: Future<Output = Result<T, FaucetError>>,
417    {
418        let reset;
419        let single = match &self.inner.retry {
420            Some(p) if p.max_attempts > 1 => {
421                reset = self.clone().without_retry();
422                &reset
423            }
424            _ => self,
425        };
426        let mut fut = Some(fut);
427        single
428            .call(op, || {
429                let f = fut.take();
430                async move { f.expect("call_once makes one attempt").await }
431            })
432            .await
433    }
434}
435
436fn cancelled_error(op: &'static str) -> FaucetError {
437    FaucetError::Cancelled {
438        operation: format!("{op} call"),
439    }
440}
441
442fn deadline_error(op: &'static str) -> FaucetError {
443    FaucetError::Timeout {
444        operation: format!("{op} call (deadline)"),
445        after: Duration::ZERO,
446    }
447}
448
449/// `e` with its URL scrubbed by `redact` and stripped of credentials; the URL
450/// is dropped when the scrubbed text no longer parses.
451pub(crate) fn redact_reqwest_url(
452    e: reqwest::Error,
453    redact: impl Fn(&str) -> String,
454) -> reqwest::Error {
455    let Some(url) = e.url() else {
456        return e;
457    };
458    let scrubbed = crate::util::redact_uri_credentials(&redact(url.as_str()));
459    if scrubbed == url.as_str() {
460        return e;
461    }
462    match reqwest::Url::parse(&scrubbed) {
463        Ok(u) => e.with_url(u),
464        Err(_) => e.without_url(),
465    }
466}
467
468/// Where a connector keeps its [`CallContext`]: its own settings, plus the
469/// context the pipeline installs.
470///
471/// Build it in `new()` from the connector's config, forward
472/// [`Source::set_call_context`](crate::Source::set_call_context) /
473/// [`Sink::set_call_context`](crate::Sink::set_call_context) to
474/// [`install`](Self::install), and run calls through [`call`](Self::call).
475/// Before the pipeline installs anything (a library caller driving the
476/// connector directly) the connector's own settings and the defaults apply.
477#[derive(Debug, Default)]
478pub struct CallSlot {
479    local: CallContext,
480    installed: OnceLock<CallContext>,
481    recorder: OnceLock<Arc<RoundtripRecorder>>,
482}
483
484// Set-once cells over immutable contexts; a panic cannot leave a torn value.
485impl std::panic::UnwindSafe for CallSlot {}
486impl std::panic::RefUnwindSafe for CallSlot {}
487
488impl CallSlot {
489    /// A slot whose connector-owned settings are `local`.
490    pub fn new(local: CallContext) -> Self {
491        Self {
492            local,
493            installed: OnceLock::new(),
494            recorder: OnceLock::new(),
495        }
496    }
497
498    /// The connector-owned settings.
499    pub fn local(&self) -> &CallContext {
500        &self.local
501    }
502
503    /// Replace the connector-owned settings (from a builder method).
504    pub fn set_local(&mut self, local: CallContext) {
505        self.local = local;
506    }
507
508    /// Install the pipeline's context. The first call wins.
509    pub fn install(&self, ctx: CallContext) {
510        let _ = self.installed.set(ctx);
511    }
512
513    /// Install a round-trip recorder, used when the installed context carries
514    /// none (a caller that only calls `set_roundtrip_recorder`). The first
515    /// call wins.
516    pub fn install_recorder(&self, recorder: Arc<RoundtripRecorder>) {
517        let _ = self.recorder.set(recorder);
518    }
519
520    /// The effective context: the connector's settings over the installed
521    /// context, with the separately installed recorder as a fallback.
522    pub fn get(&self) -> CallContext {
523        let mut ctx = match self.installed.get() {
524            Some(base) => self.local.inherit(base),
525            None => self.local.clone(),
526        };
527        if ctx.inner.recorder.is_none()
528            && let Some(r) = self.recorder.get()
529        {
530            ctx = ctx.with_recorder(Arc::clone(r));
531        }
532        ctx
533    }
534
535    /// The round-trip recorder in effect, if any.
536    pub fn recorder(&self) -> Option<Arc<RoundtripRecorder>> {
537        self.get().inner.recorder.clone()
538    }
539
540    /// Count one round trip made outside [`call`](Self::call).
541    pub fn record(&self, op: &'static str) {
542        self.get().record(op);
543    }
544
545    /// Wait through the effective context ([`CallContext::wait`]).
546    pub async fn wait(&self, duration: Duration) -> Result<(), FaucetError> {
547        self.get().wait(duration).await
548    }
549
550    /// Run `f` through the effective context ([`CallContext::call`]).
551    pub async fn call<T, F, Fut>(&self, op: &'static str, f: F) -> Result<T, FaucetError>
552    where
553        F: FnMut() -> Fut,
554        Fut: Future<Output = Result<T, FaucetError>>,
555    {
556        self.get().call(op, f).await
557    }
558
559    /// Run `fut` once through the effective context
560    /// ([`CallContext::call_once`]).
561    pub async fn call_once<T, Fut>(&self, op: &'static str, fut: Fut) -> Result<T, FaucetError>
562    where
563        Fut: Future<Output = Result<T, FaucetError>>,
564    {
565        self.get().call_once(op, fut).await
566    }
567}
568
569/// Run any future as a one-shot backend call: `client.send().within(&slot,
570/// "put").await?` is [`CallSlot::call_once`] for a future whose output is not
571/// a [`FaucetError`] result (an SDK response), handing that output back
572/// untouched. The outer `Result` is the call context running out — the
573/// deadline or the cancel token.
574pub trait CallExt: Future + Send + Sized {
575    /// `self` as the call `op` through `slot`'s effective context.
576    fn within<'a>(
577        self,
578        slot: &'a CallSlot,
579        op: &'static str,
580    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Self::Output, FaucetError>> + Send + 'a>>
581    where
582        Self: 'a,
583        Self::Output: Send,
584    {
585        Box::pin(slot.call_once(op, async move { Ok(self.await) }))
586    }
587
588    /// [`within`](Self::within) through a [`CallContext`].
589    fn within_ctx<'a>(
590        self,
591        ctx: &'a CallContext,
592        op: &'static str,
593    ) -> std::pin::Pin<Box<dyn Future<Output = Result<Self::Output, FaucetError>> + Send + 'a>>
594    where
595        Self: 'a,
596        Self::Output: Send,
597    {
598        Box::pin(ctx.call_once(op, async move { Ok(self.await) }))
599    }
600}
601
602impl<F: Future + Send> CallExt for F {}
603
604/// A streamed response body read under a [`CallContext`]: every read fails
605/// once the context's cancel token fires or its deadline passes, so a body
606/// that stops arriving mid-stream ends like any other call. Built with
607/// [`CallContext::reader`].
608pub struct CallReader<R> {
609    inner: R,
610    cancel: Option<std::pin::Pin<Box<tokio_util::sync::WaitForCancellationFutureOwned>>>,
611    deadline: Option<std::pin::Pin<Box<tokio::time::Sleep>>>,
612}
613
614impl<R> fmt::Debug for CallReader<R> {
615    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
616        f.debug_struct("CallReader")
617            .field("cancel", &self.cancel.is_some())
618            .field("deadline", &self.deadline.is_some())
619            .finish_non_exhaustive()
620    }
621}
622
623impl CallContext {
624    /// `inner` read under this context ([`CallReader`]).
625    #[allow(clippy::disallowed_methods)] // the deadline timer of a read, not a wait
626    pub fn reader<R>(&self, inner: R) -> CallReader<R> {
627        CallReader {
628            inner,
629            cancel: self
630                .inner
631                .cancel
632                .clone()
633                .map(|c| Box::pin(c.cancelled_owned())),
634            deadline: self
635                .inner
636                .deadline
637                .map(|d| Box::pin(tokio::time::sleep_until(d))),
638        }
639    }
640}
641
642impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for CallReader<R> {
643    fn poll_read(
644        mut self: std::pin::Pin<&mut Self>,
645        cx: &mut std::task::Context<'_>,
646        buf: &mut tokio::io::ReadBuf<'_>,
647    ) -> std::task::Poll<std::io::Result<()>> {
648        use std::task::Poll;
649        if let Some(c) = self.cancel.as_mut()
650            && c.as_mut().poll(cx).is_ready()
651        {
652            return Poll::Ready(Err(std::io::Error::new(
653                std::io::ErrorKind::Interrupted,
654                cancelled_error("read"),
655            )));
656        }
657        if let Some(d) = self.deadline.as_mut()
658            && d.as_mut().poll(cx).is_ready()
659        {
660            return Poll::Ready(Err(std::io::Error::new(
661                std::io::ErrorKind::TimedOut,
662                deadline_error("read"),
663            )));
664        }
665        std::pin::Pin::new(&mut self.inner).poll_read(cx, buf)
666    }
667}
668
669#[cfg(test)]
670#[allow(clippy::disallowed_methods)] // tests drive time and raw clients directly
671mod tests {
672    use super::*;
673    use crate::observability::RoundtripSide;
674    use crate::resilience::BackoffKind;
675    use std::sync::atomic::{AtomicU32, Ordering};
676
677    fn fast(max_attempts: u32) -> RetryPolicy {
678        RetryPolicy {
679            max_attempts,
680            backoff: BackoffKind::None,
681            base: Duration::ZERO,
682            max: Duration::ZERO,
683            jitter: false,
684            ..RetryPolicy::default()
685        }
686    }
687
688    fn status(code: u16) -> FaucetError {
689        FaucetError::HttpStatus {
690            status: code,
691            url: "http://h/x".into(),
692            body: String::new(),
693        }
694    }
695
696    #[test]
697    fn call_types_are_unwind_safe() {
698        fn assert_unwind_safe<T: std::panic::UnwindSafe + std::panic::RefUnwindSafe>() {}
699        assert_unwind_safe::<CallContext>();
700        assert_unwind_safe::<CallSlot>();
701    }
702
703    #[tokio::test]
704    async fn call_once_makes_one_metered_attempt_whatever_the_policy() {
705        let meter = Arc::new(crate::usage::UsageMeter::new());
706        let rec = Arc::new(
707            RoundtripRecorder::new(RoundtripSide::Source, "p", "r", "c").with_meter(meter.clone()),
708        );
709        let slot = CallSlot::new(CallContext::new().with_retry(fast(5)));
710        slot.install_recorder(rec);
711        let err = slot
712            .call_once("query", async { Err::<(), _>(status(503)) })
713            .await
714            .unwrap_err();
715        assert!(matches!(err, FaucetError::HttpStatus { status: 503, .. }));
716        assert_eq!(slot.call_once("query", async { Ok(7) }).await.unwrap(), 7);
717        assert_eq!(meter.snapshot().source_roundtrips["query"], 2);
718    }
719
720    /// A call whose deadline has already passed is refused before it starts.
721    #[tokio::test]
722    async fn a_spent_deadline_refuses_the_call_without_running_it() {
723        let ctx = CallContext::new().with_deadline(Instant::now() - Duration::from_millis(1));
724        let ran = AtomicU32::new(0);
725        let r: Result<(), _> = ctx
726            .call("read", || async {
727                ran.fetch_add(1, Ordering::SeqCst);
728                Ok(())
729            })
730            .await;
731        match r {
732            Err(FaucetError::Timeout { operation, after }) => {
733                assert_eq!(operation, "read call (deadline)");
734                assert_eq!(after, Duration::ZERO);
735            }
736            other => panic!("expected a deadline timeout, got {other:?}"),
737        }
738        assert_eq!(ran.load(Ordering::SeqCst), 0);
739    }
740
741    #[tokio::test]
742    async fn call_once_ends_at_the_deadline_and_on_cancel() {
743        let ctx = CallContext::new().with_deadline(Instant::now() + Duration::from_millis(20));
744        let err = ctx
745            .call_once("query", std::future::pending::<Result<(), FaucetError>>())
746            .await
747            .unwrap_err();
748        assert!(matches!(err, FaucetError::Timeout { .. }));
749        let token = CancellationToken::new();
750        token.cancel();
751        let err = CallContext::new()
752            .with_cancel(token)
753            .call_once("query", async { Ok(()) })
754            .await
755            .unwrap_err();
756        assert!(matches!(err, FaucetError::Cancelled { .. }));
757    }
758
759    #[tokio::test]
760    async fn defaults_make_one_attempt_with_the_default_timeout() {
761        let ctx = CallContext::new();
762        assert_eq!(ctx.timeout(), Some(DEFAULT_CALL_TIMEOUT));
763        assert!(ctx.retry().is_none());
764        assert!(ctx.deadline().is_none());
765        let calls = AtomicU32::new(0);
766        let r: Result<(), _> = ctx
767            .call("op", || {
768                calls.fetch_add(1, Ordering::SeqCst);
769                async { Err(status(503)) }
770            })
771            .await;
772        assert!(matches!(
773            r,
774            Err(FaucetError::HttpStatus { status: 503, .. })
775        ));
776        assert_eq!(calls.load(Ordering::SeqCst), 1);
777    }
778
779    #[tokio::test(start_paused = true)]
780    async fn call_once_bounds_a_one_shot_future_and_never_retries() {
781        let ctx = CallContext::new()
782            .with_timeout(Duration::from_secs(1))
783            .with_retry(fast(3));
784        let polls = AtomicU32::new(0);
785        let r: Result<(), _> = ctx
786            .call_once("read", async {
787                polls.fetch_add(1, Ordering::SeqCst);
788                std::future::pending().await
789            })
790            .await;
791        assert!(matches!(r, Err(FaucetError::Timeout { .. })), "{r:?}");
792        assert_eq!(polls.load(Ordering::SeqCst), 1, "never retried");
793
794        let r = ctx
795            .call_once("read", async { Err::<(), _>(status(503)) })
796            .await;
797        assert!(matches!(
798            r,
799            Err(FaucetError::HttpStatus { status: 503, .. })
800        ));
801
802        let slot = CallSlot::new(CallContext::new());
803        let cancel = CancellationToken::new();
804        slot.install(CallContext::new().with_cancel(cancel.clone()));
805        assert_eq!(slot.call_once("read", async { Ok(5) }).await.unwrap(), 5);
806        cancel.cancel();
807        let r = slot.call_once("read", async { Ok(5) }).await;
808        assert!(matches!(r, Err(FaucetError::Cancelled { .. })), "{r:?}");
809    }
810
811    #[tokio::test(start_paused = true)]
812    async fn within_hands_back_the_output_or_the_spent_context() {
813        let slot = CallSlot::new(CallContext::new().with_timeout(Duration::from_secs(1)));
814        let out: Result<u8, &str> = async { Err("sdk") }.within(&slot, "put").await.unwrap();
815        assert_eq!(out, Err("sdk"));
816        let hung = std::future::pending::<u8>().within(&slot, "put").await;
817        assert!(matches!(hung, Err(FaucetError::Timeout { .. })), "{hung:?}");
818        let ctx = CallContext::new();
819        assert_eq!(async { 3 }.within_ctx(&ctx, "get").await.unwrap(), 3);
820    }
821
822    #[tokio::test(start_paused = true)]
823    async fn a_reader_fails_at_the_deadline_and_on_cancel() {
824        use tokio::io::AsyncReadExt;
825        let mut buf = Vec::new();
826        let mut plain = CallContext::new().reader(&b"abc"[..]);
827        plain.read_to_end(&mut buf).await.unwrap();
828        assert_eq!(buf, b"abc");
829        assert!(format!("{plain:?}").contains("CallReader"));
830
831        let (_tx, rx) = tokio::io::duplex(8);
832        let ctx = CallContext::new().with_deadline(Instant::now() + Duration::from_secs(1));
833        let mut stalled = ctx.reader(rx);
834        let err = stalled.read_to_end(&mut buf).await.unwrap_err();
835        assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
836
837        let (_tx, rx) = tokio::io::duplex(8);
838        let cancel = CancellationToken::new();
839        let mut stalled = CallContext::new().with_cancel(cancel.clone()).reader(rx);
840        cancel.cancel();
841        let err = stalled.read_to_end(&mut buf).await.unwrap_err();
842        assert_eq!(err.kind(), std::io::ErrorKind::Interrupted);
843        assert!(err.to_string().contains("cancelled"), "{err}");
844    }
845
846    #[tokio::test]
847    async fn retries_classified_errors_under_the_policy() {
848        let ctx = CallContext::new().with_retry(fast(3));
849        let calls = AtomicU32::new(0);
850        let r = ctx
851            .call("op", || {
852                let n = calls.fetch_add(1, Ordering::SeqCst);
853                async move { if n < 2 { Err(status(502)) } else { Ok(n) } }
854            })
855            .await;
856        assert_eq!(r.unwrap(), 2);
857        let calls = AtomicU32::new(0);
858        let r: Result<(), _> = ctx
859            .call("op", || {
860                calls.fetch_add(1, Ordering::SeqCst);
861                async { Err(status(404)) }
862            })
863            .await;
864        assert!(r.is_err());
865        assert_eq!(calls.load(Ordering::SeqCst), 1, "a 404 is not retried");
866    }
867
868    #[tokio::test(start_paused = true)]
869    async fn a_hung_attempt_times_out_and_is_retried() {
870        let ctx = CallContext::new()
871            .with_timeout(Duration::from_secs(1))
872            .with_retry(fast(2));
873        let calls = AtomicU32::new(0);
874        let r: Result<(), _> = ctx
875            .call("query", || {
876                calls.fetch_add(1, Ordering::SeqCst);
877                std::future::pending()
878            })
879            .await;
880        match r {
881            Err(FaucetError::Timeout { operation, after }) => {
882                assert_eq!(operation, "query call");
883                assert_eq!(after, Duration::from_secs(1));
884            }
885            other => panic!("expected a timeout, got {other:?}"),
886        }
887        assert_eq!(calls.load(Ordering::SeqCst), 2);
888    }
889
890    #[tokio::test(start_paused = true)]
891    async fn the_deadline_ends_the_whole_call() {
892        let start = Instant::now();
893        let ctx = CallContext::new()
894            .without_timeout()
895            .with_deadline(start + Duration::from_secs(5))
896            .with_retry(RetryPolicy {
897                max_attempts: 100,
898                backoff: BackoffKind::Fixed,
899                base: Duration::from_secs(2),
900                max: Duration::from_secs(2),
901                jitter: false,
902                ..RetryPolicy::default()
903            });
904        let r: Result<(), _> = ctx.call("op", || async { Err(status(503)) }).await;
905        assert!(matches!(r, Err(FaucetError::Timeout { .. })), "{r:?}");
906        assert!(Instant::now() - start <= Duration::from_secs(5));
907        let r: Result<(), _> = ctx.call("op", || async { Ok(()) }).await;
908        assert!(
909            matches!(r, Err(FaucetError::Timeout { .. })),
910            "past deadline"
911        );
912    }
913
914    #[tokio::test(start_paused = true)]
915    async fn the_deadline_cuts_an_attempt_short() {
916        let ctx = CallContext::new()
917            .with_timeout(Duration::from_secs(60))
918            .with_deadline(Instant::now() + Duration::from_secs(3));
919        let r: Result<(), _> = ctx.call("op", std::future::pending).await;
920        match r {
921            Err(FaucetError::Timeout { after, .. }) => assert!(after <= Duration::from_secs(3)),
922            other => panic!("{other:?}"),
923        }
924    }
925
926    #[tokio::test(start_paused = true)]
927    async fn cancellation_stops_an_attempt_and_fails_fast_afterwards() {
928        let token = CancellationToken::new();
929        let ctx = CallContext::new().with_cancel(token.clone());
930        let t = token.clone();
931        tokio::spawn(async move {
932            tokio::time::sleep(Duration::from_secs(1)).await;
933            t.cancel();
934        });
935        let r: Result<(), _> = ctx.call("op", std::future::pending).await;
936        assert!(matches!(r, Err(FaucetError::Cancelled { .. })), "{r:?}");
937        assert!(ctx.is_cancelled());
938        let calls = AtomicU32::new(0);
939        let r: Result<(), _> = ctx
940            .call("op", || {
941                calls.fetch_add(1, Ordering::SeqCst);
942                async { Ok(()) }
943            })
944            .await;
945        assert!(matches!(r, Err(FaucetError::Cancelled { .. })));
946        assert_eq!(calls.load(Ordering::SeqCst), 0);
947    }
948
949    #[tokio::test(start_paused = true)]
950    async fn wait_honours_cancel_and_deadline() {
951        let start = Instant::now();
952        CallContext::new()
953            .wait(Duration::from_secs(4))
954            .await
955            .unwrap();
956        assert!(Instant::now() - start >= Duration::from_secs(4));
957        let ctx = CallContext::new().with_deadline(Instant::now() + Duration::from_secs(1));
958        assert!(matches!(
959            ctx.wait(Duration::from_secs(10)).await,
960            Err(FaucetError::Timeout { .. })
961        ));
962        let token = CancellationToken::new();
963        let ctx = CallContext::new().with_cancel(token.clone());
964        let t = token.clone();
965        tokio::spawn(async move {
966            tokio::time::sleep(Duration::from_secs(1)).await;
967            t.cancel();
968        });
969        assert!(matches!(
970            ctx.wait(Duration::from_secs(10)).await,
971            Err(FaucetError::Cancelled { .. })
972        ));
973        assert!(matches!(
974            ctx.wait(Duration::ZERO).await,
975            Err(FaucetError::Cancelled { .. })
976        ));
977        let slot = CallSlot::default();
978        slot.wait(Duration::from_millis(1)).await.unwrap();
979    }
980
981    #[tokio::test(start_paused = true)]
982    async fn a_rate_limit_waits_its_stated_time() {
983        let ctx = CallContext::new().with_retry(fast(2));
984        let calls = AtomicU32::new(0);
985        let start = Instant::now();
986        let r = ctx
987            .call("op", || {
988                let n = calls.fetch_add(1, Ordering::SeqCst);
989                async move {
990                    if n == 0 {
991                        Err(FaucetError::RateLimited(Duration::from_secs(7)))
992                    } else {
993                        Ok(n)
994                    }
995                }
996            })
997            .await;
998        assert_eq!(r.unwrap(), 1);
999        assert!(Instant::now() - start >= Duration::from_secs(7));
1000    }
1001
1002    #[tokio::test(start_paused = true)]
1003    async fn the_rate_limit_budget_does_not_use_attempts() {
1004        let ctx = CallContext::new()
1005            .with_retry(fast(1))
1006            .with_rate_limit_retries(3);
1007        let calls = AtomicU32::new(0);
1008        let r = ctx
1009            .call("op", || {
1010                let n = calls.fetch_add(1, Ordering::SeqCst);
1011                async move {
1012                    if n < 3 {
1013                        Err(FaucetError::RateLimited(Duration::from_millis(10)))
1014                    } else {
1015                        Ok(n)
1016                    }
1017                }
1018            })
1019            .await;
1020        assert_eq!(r.unwrap(), 3, "three rate limits retried with one attempt");
1021        let calls = AtomicU32::new(0);
1022        let r: Result<(), _> = ctx
1023            .call("op", || {
1024                calls.fetch_add(1, Ordering::SeqCst);
1025                async { Err(FaucetError::RateLimited(Duration::from_millis(1))) }
1026            })
1027            .await;
1028        assert!(matches!(r, Err(FaucetError::RateLimited(_))));
1029        assert_eq!(calls.load(Ordering::SeqCst), 4, "1 + 3 consecutive retries");
1030    }
1031
1032    #[tokio::test]
1033    async fn every_attempt_is_metered() {
1034        let meter = Arc::new(crate::usage::UsageMeter::new());
1035        let rec = Arc::new(
1036            RoundtripRecorder::new(RoundtripSide::Source, "p", "r", "c").with_meter(meter.clone()),
1037        );
1038        let ctx = CallContext::new()
1039            .with_recorder(rec.clone())
1040            .with_retry(fast(3));
1041        let calls = AtomicU32::new(0);
1042        let _ = ctx
1043            .call("get", || {
1044                let n = calls.fetch_add(1, Ordering::SeqCst);
1045                async move {
1046                    if n == 0 {
1047                        Err(FaucetError::RateLimited(Duration::ZERO))
1048                    } else {
1049                        Ok(())
1050                    }
1051                }
1052            })
1053            .await;
1054        ctx.record("list");
1055        ctx.signal("bytes_billed", "bytes", 5.0);
1056        let unmetered = ctx.clone().with_roundtrip_metering(false);
1057        unmetered.call("get", || async { Ok(()) }).await.unwrap();
1058        let snap = meter.snapshot();
1059        assert_eq!(snap.source_roundtrips["get"], 2);
1060        assert_eq!(snap.source_roundtrips["list"], 1);
1061        assert_eq!(snap.throttled, 1);
1062        assert_eq!(snap.signals.len(), 1);
1063    }
1064
1065    #[tokio::test]
1066    async fn the_final_error_is_redacted() {
1067        let ctx = CallContext::new().with_redactor(Arc::new(|s: &str| s.replace("SECRET", "***")));
1068        let r: Result<(), _> = ctx
1069            .call("op", || async {
1070                Err(FaucetError::HttpStatus {
1071                    status: 400,
1072                    url: "http://h/?k=SECRET".into(),
1073                    body: "bad SECRET".into(),
1074                })
1075            })
1076            .await;
1077        match r {
1078            Err(FaucetError::HttpStatus { url, body, .. }) => {
1079                assert_eq!(url, "http://h/?k=***");
1080                assert_eq!(body, "bad ***");
1081            }
1082            other => panic!("{other:?}"),
1083        }
1084        assert!(matches!(
1085            ctx.redact_error(FaucetError::Config("SECRET".into())),
1086            FaucetError::Config(s) if s == "SECRET"
1087        ));
1088        assert_eq!(CallContext::new().redact("plain"), "plain");
1089    }
1090
1091    async fn refused(url: &str) -> FaucetError {
1092        let client = crate::http::Client::new().unwrap();
1093        client.send(client.get(url)).await.unwrap_err()
1094    }
1095
1096    #[tokio::test]
1097    async fn transport_error_urls_lose_credentials() {
1098        let e = refused("http://user:pw@127.0.0.1:1/x?token=SECRET").await;
1099        let text = CallContext::new().redact_error(e).to_string();
1100        assert!(!text.contains("pw@"), "{text}");
1101        assert!(!text.contains("SECRET"), "{text}");
1102        let FaucetError::Http(e) = refused("http://127.0.0.1:1/x").await else {
1103            panic!("expected a transport error");
1104        };
1105        assert!(e.url().is_some());
1106        let kept = redact_reqwest_url(e, |u| u.to_string());
1107        assert!(kept.url().is_some(), "an unchanged URL is kept");
1108        let dropped = redact_reqwest_url(kept, |_| "not a url".into());
1109        assert!(dropped.url().is_none());
1110        assert!(
1111            redact_reqwest_url(dropped, |_| String::new())
1112                .url()
1113                .is_none()
1114        );
1115    }
1116
1117    #[test]
1118    fn inherit_layers_connector_settings_over_the_installed_context() {
1119        let token = CancellationToken::new();
1120        let now = Instant::now();
1121        let rec = Arc::new(RoundtripRecorder::new(RoundtripSide::Sink, "p", "r", "c"));
1122        let installed = CallContext::new()
1123            .with_cancel(token.clone())
1124            .with_recorder(rec)
1125            .with_timeout(Duration::from_secs(9))
1126            .with_deadline(now + Duration::from_secs(50))
1127            .with_redactor(Arc::new(|_| "X".into()));
1128        let local = CallContext::new()
1129            .with_retry(fast(4))
1130            .with_rate_limit_retries(2)
1131            .with_deadline(now + Duration::from_secs(10))
1132            .with_roundtrip_metering(false);
1133        let eff = local.inherit(&installed);
1134        assert_eq!(eff.timeout(), Some(Duration::from_secs(9)));
1135        assert_eq!(eff.retry().unwrap().max_attempts, 4);
1136        assert_eq!(eff.rate_limit_retries(), Some(2));
1137        assert!(eff.cancel().is_some() && eff.recorder().is_some());
1138        assert_eq!(eff.deadline(), Some(now + Duration::from_secs(10)));
1139        assert_eq!(eff.redact("y"), "X");
1140        let eff = CallContext::new().without_timeout().inherit(&installed);
1141        assert_eq!(eff.timeout(), None);
1142        let eff = CallContext::new().without_retry().inherit(&installed);
1143        assert_eq!(eff.retry().unwrap().max_attempts, 1);
1144        assert!(format!("{eff:?}").contains("CallContext"));
1145    }
1146
1147    #[tokio::test]
1148    async fn a_slot_uses_its_own_settings_until_installed() {
1149        let mut slot = CallSlot::new(CallContext::new().with_timeout(Duration::from_secs(3)));
1150        assert_eq!(slot.get().timeout(), Some(Duration::from_secs(3)));
1151        assert!(slot.recorder().is_none());
1152        slot.record("noop");
1153        slot.set_local(CallContext::new().with_retry(fast(2)));
1154        assert_eq!(slot.local().retry().unwrap().max_attempts, 2);
1155        let rec = Arc::new(RoundtripRecorder::new(RoundtripSide::Source, "p", "r", "c"));
1156        slot.install_recorder(rec);
1157        assert!(slot.recorder().is_some());
1158        let token = CancellationToken::new();
1159        slot.install(CallContext::new().with_cancel(token.clone()));
1160        slot.install(CallContext::new());
1161        let eff = slot.get();
1162        assert!(eff.cancel().is_some(), "first install wins");
1163        assert!(eff.recorder().is_some(), "recorder fallback");
1164        assert_eq!(eff.retry().unwrap().max_attempts, 2);
1165        assert_eq!(slot.call("op", || async { Ok(1) }).await.unwrap(), 1);
1166        token.cancel();
1167        let r: Result<(), _> = slot.call("op", || async { Ok(()) }).await;
1168        assert!(matches!(r, Err(FaucetError::Cancelled { .. })));
1169    }
1170}