Skip to main content

typesafe_sdk/transport/
mod.rs

1//! The seam between this crate and whatever sends the bytes.
2//!
3//! The SDK drives a `tower`-shaped service rather than an HTTP client, so a
4//! caller can put their own stack underneath it - a recorder, a proxy, an
5//! in-memory harness - without this crate knowing about it, and the default
6//! stack, [`HyperTransport`], is one implementation of that seam rather than a
7//! hard dependency. Any [`tower_service::Service`] over `http` requests with
8//! this crate's [`Body`] is a transport; [`HttpService`] names the bounds.
9//!
10//! The request body is this crate's own type implementing `http-body`, not a
11//! type borrowed from a pre-1.0 crate, so the public signature does not commit
12//! a caller to a version of somebody else's dependency.
13//!
14//! A transport sends one request. Everything around that - the headers every
15//! request carries, the deadline, reading the response under the size limit,
16//! turning a failure into this crate's [`Error`] - happens here, once per
17//! attempt, whichever transport is underneath.
18
19mod hyper;
20
21use std::{
22    convert::Infallible,
23    error::Error as StdError,
24    fmt,
25    future::{Future, poll_fn},
26    pin::Pin,
27    task::{Context, Poll},
28    time::Duration,
29};
30
31use bytes::Bytes;
32use http::{
33    HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri,
34    header::CONTENT_TYPE,
35};
36use http_body::{Frame, SizeHint};
37use http_body_util::{BodyExt as _, LengthLimitError, Limited};
38use tower_service::Service;
39
40pub(crate) use self::hyper::TransportSettings;
41pub use self::hyper::{HttpVersion, HyperResponseFuture, HyperTransport, ResponseBody};
42use crate::{
43    config::Config,
44    constants::{
45        JSON_CONTENT_TYPE, PROTECTED_HEADERS, RETRY_COUNT_HEADER, RUNTIME_IDENTIFIER,
46        SDK_IDENTIFIER, TRANSPORT_HEADERS,
47    },
48    error::{ApiError, Error, ErrorKind, format_endpoint},
49    question::upsert,
50    redact::{self, Credentials, Outcome},
51    telemetry,
52    text::{self, Backslash, SafeText},
53};
54
55/// The error type a transport may fail with: any error that can cross
56/// threads.
57pub type BoxError = Box<dyn StdError + Send + Sync>;
58
59// ------------------------------------------------------------------- Body
60
61/// The body of a request the SDK sends: finished bytes, handed over in one
62/// frame.
63///
64/// Its length is known before the first byte is sent, so `size_hint` is exact
65/// and a transport can write a `Content-Length` without buffering. Cloning it
66/// shares the bytes rather than copying them.
67///
68/// `Debug` prints the length only: a body carries the caller's `state`, which
69/// may be personal data.
70#[derive(Clone, Default)]
71pub struct Body {
72    /// `None` once the one frame has been handed out, or for an empty body.
73    data: Option<Bytes>,
74}
75
76impl Body {
77    /// A body with no bytes, as a `GET` carries.
78    #[must_use]
79    pub fn empty() -> Self {
80        Self { data: None }
81    }
82
83    /// The bytes not yet handed out as a frame.
84    #[must_use]
85    pub fn len(&self) -> usize {
86        self.data.as_ref().map_or(0, Bytes::len)
87    }
88
89    /// Whether no bytes are left to hand out.
90    #[must_use]
91    pub fn is_empty(&self) -> bool {
92        self.len() == 0
93    }
94}
95
96impl From<Bytes> for Body {
97    fn from(bytes: Bytes) -> Self {
98        // An empty buffer is no frame at all, so that `is_end_stream` is true
99        // from the start and a transport sends no empty DATA frame.
100        Self { data: (!bytes.is_empty()).then_some(bytes) }
101    }
102}
103
104impl fmt::Debug for Body {
105    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106        formatter.debug_struct("Body").field("len", &self.len()).finish()
107    }
108}
109
110impl http_body::Body for Body {
111    type Data = Bytes;
112    type Error = Infallible;
113
114    fn poll_frame(
115        self: Pin<&mut Self>,
116        _: &mut Context<'_>,
117    ) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
118        // `Body` holds nothing that cares where it lives in memory, so the
119        // pinned reference can be turned back into a plain one.
120        Poll::Ready(self.get_mut().data.take().map(|bytes| Ok(Frame::data(bytes))))
121    }
122
123    fn is_end_stream(&self) -> bool {
124        self.data.is_none()
125    }
126
127    fn size_hint(&self) -> SizeHint {
128        SizeHint::with_exact(self.len() as u64)
129    }
130}
131
132// ------------------------------------------------------------ HttpService
133
134mod sealed {
135    /// Keeps [`HttpService`](super::HttpService) implemented only through its
136    /// blanket implementation.
137    pub trait Sealed {}
138}
139
140/// What a transport has to be: a `tower` service that takes an `http` request
141/// with this crate's [`Body`] and answers with an `http` response.
142///
143/// It is implemented for every [`tower_service::Service`] that fits, and for
144/// nothing else, so it is a name for a set of bounds rather than a trait to
145/// implement: implement `Service` and a type is a transport. The service is
146/// cloned for every request and driven with its own `poll_ready`, so a
147/// service with back-pressure keeps it; the default [`HyperTransport`] is
148/// always ready.
149///
150/// The error of the service, and of its response body, is anything that can
151/// cross threads. It is kept as the [`source`](StdError::source) of the
152/// connection [`Error`] the call fails with.
153pub trait HttpService: sealed::Sealed + Clone + Send + Sync + 'static {
154    /// The body of the responses the service answers with.
155    type ResponseBody: http_body::Body<Data: Send, Error: Into<BoxError>> + Send + 'static;
156    /// What the service fails with when it cannot produce a response.
157    type Error: Into<BoxError>;
158    /// The future a call returns.
159    type Future: Future<Output = Result<Response<Self::ResponseBody>, Self::Error>> + Send;
160
161    /// [`Service::poll_ready`], forwarded.
162    ///
163    /// # Errors
164    ///
165    /// Returns the service's error when it can take no more requests.
166    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
167
168    /// [`Service::call`], forwarded.
169    fn call(&mut self, request: Request<Body>) -> Self::Future;
170}
171
172impl<S, B> sealed::Sealed for S
173where
174    S: Service<Request<Body>, Response = Response<B>> + Clone + Send + Sync + 'static,
175    S::Error: Into<BoxError>,
176    S::Future: Send,
177    B: http_body::Body<Data: Send, Error: Into<BoxError>> + Send + 'static,
178{
179}
180
181impl<S, B> HttpService for S
182where
183    S: Service<Request<Body>, Response = Response<B>> + Clone + Send + Sync + 'static,
184    S::Error: Into<BoxError>,
185    S::Future: Send,
186    B: http_body::Body<Data: Send, Error: Into<BoxError>> + Send + 'static,
187{
188    type ResponseBody = B;
189    type Error = S::Error;
190    type Future = S::Future;
191
192    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
193        Service::poll_ready(self, cx)
194    }
195
196    fn call(&mut self, request: Request<Body>) -> S::Future {
197        Service::call(self, request)
198    }
199}
200
201// ------------------------------------------------------- header assembly
202
203/// The headers every request of one kind carries, built once per client.
204///
205/// Precedence, lowest first: the client's default headers, then the SDK's
206/// own, which no default can replace, and, when the request has a body,
207/// `Content-Type: application/json`. A default `X-TypeSafe-Retry-Count` is
208/// dropped: the SDK sets that header on retries and only there. So is a
209/// default framing or connection header ([`TRANSPORT_HEADERS`]). Per-call
210/// headers are applied on top of this map for each attempt; see
211/// [`call_headers`]. `User-Agent` carries the value the configuration built
212/// once, and `X-TypeSafe-Runtime` is left out when the configuration says so;
213/// a caller's header of either name is dropped all the same.
214pub(crate) fn base_headers(config: &Config, with_body: bool) -> HeaderMap {
215    let defaults = config.default_headers();
216    // Room for every default, the protected five and `Content-Type`, so
217    // building the map never grows it.
218    let mut headers = HeaderMap::with_capacity(defaults.len() + PROTECTED_HEADERS.len() + 1);
219    for (name, value) in defaults {
220        if !is_sdk_owned(name, with_body) {
221            headers.append(name, value.clone());
222        }
223    }
224    let [authorization, accept, user_agent, sdk, runtime] = PROTECTED_HEADERS;
225    headers.insert(authorization, config.authorization().clone());
226    headers.insert(accept, JSON_CONTENT_TYPE);
227    headers.insert(user_agent, config.user_agent().clone());
228    headers.insert(sdk, SDK_IDENTIFIER);
229    if config.send_runtime_header() {
230        headers.insert(runtime, RUNTIME_IDENTIFIER.clone());
231    }
232    if with_body {
233        headers.insert(CONTENT_TYPE, JSON_CONTENT_TYPE);
234    }
235    headers
236}
237
238/// Whether the SDK or its transport owns `name` on a request, so a caller
239/// cannot set it.
240fn is_sdk_owned(name: &HeaderName, with_body: bool) -> bool {
241    PROTECTED_HEADERS.contains(name)
242        || *name == RETRY_COUNT_HEADER
243        || (with_body && *name == CONTENT_TYPE)
244        || TRANSPORT_HEADERS.contains(name)
245}
246
247/// Parses the headers a caller set on one call, dropping the ones the SDK
248/// owns.
249///
250/// A later header of a name replaces an earlier one, as a later key does in a
251/// Python mapping. The protected headers, `X-TypeSafe-Retry-Count`, and
252/// `Content-Type` on a request with a body are dropped without an error, as
253/// the Python SDK overrides them. The framing and connection headers
254/// ([`TRANSPORT_HEADERS`]) are dropped the same way: they belong to the
255/// transport. `Host` is kept.
256///
257/// # Errors
258///
259/// Returns an [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest)
260/// error when a name is not a valid header name or a value is not a valid
261/// header value. The message names the header and never repeats a value.
262pub(crate) fn call_headers<'a, I>(
263    raw: I,
264    with_body: bool,
265) -> Result<Vec<(HeaderName, HeaderValue)>, Error>
266where
267    I: IntoIterator<Item = (&'a str, &'a str)>,
268    I::IntoIter: ExactSizeIterator,
269{
270    let raw = raw.into_iter();
271    let mut parsed: Vec<(HeaderName, HeaderValue)> = Vec::with_capacity(raw.len());
272    for (name, value) in raw {
273        let (name, value) = parse_header(name, value, "").map_err(Error::invalid_request)?;
274        if is_sdk_owned(&name, with_body) {
275            continue;
276        }
277        upsert(&mut parsed, name, value);
278    }
279    Ok(parsed)
280}
281
282/// Parses one header, or says which part of it is not valid.
283///
284/// The message names the header by its name - escaped, and cut at 128
285/// characters, since a name that fails here can be anything - and never
286/// repeats the value: a value is where a caller puts a token. `whose` is the word before `header` in the message: `default ` for
287/// a client default, empty for a per-call header.
288pub(crate) fn parse_header(
289    name: &str,
290    value: &str,
291    whose: &str,
292) -> Result<(HeaderName, HeaderValue), String> {
293    let parsed = HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
294        format!("The {whose}header name {} is not a valid HTTP header name.", text::quoted(name))
295    })?;
296    let value = HeaderValue::from_str(value).map_err(|_| {
297        format!(
298            "The value of the {whose}header {} is not a valid HTTP header value.",
299            text::quoted(name)
300        )
301    })?;
302    Ok((parsed, value))
303}
304
305// ------------------------------------------------------------ one attempt
306
307/// Everything one request needs that does not change between its attempts.
308#[derive(Clone, Copy)]
309pub(crate) struct Exchange<'a> {
310    pub(crate) method: &'a Method,
311    pub(crate) uri: &'a Uri,
312    /// The client's headers for this kind of request; see [`base_headers`].
313    pub(crate) base_headers: &'a HeaderMap,
314    /// The call's own headers, already parsed; see [`call_headers`].
315    pub(crate) call_headers: &'a [(HeaderName, HeaderValue)],
316    /// The deadline of one attempt, or `None` for no deadline.
317    pub(crate) deadline: Option<Duration>,
318    pub(crate) max_response_bytes: usize,
319}
320
321/// What a successful attempt returns: a success status, the headers and the
322/// whole body.
323type Received = (StatusCode, HeaderMap, Bytes);
324
325/// Sends one attempt of a request and reads its response.
326///
327/// `retry` is how many attempts came before this one: from 1 it is sent as
328/// `X-TypeSafe-Retry-Count`, and at 0 the header is absent. `body` is the
329/// finished request body, handed over by value so that a first attempt costs
330/// no copy and a later one only a reference count.
331///
332/// The deadline covers the whole attempt: waiting for the transport to be
333/// ready, connecting, sending, and reading the response body.
334///
335/// # Errors
336///
337/// - [`ErrorKind::Timeout`](crate::ErrorKind::Timeout) when the deadline
338///   passes first.
339/// - [`ErrorKind::Api`](crate::ErrorKind::Api) for any status outside 2xx.
340/// - [`ErrorKind::Connection`](crate::ErrorKind::Connection) when the
341///   transport fails or the body cannot be read; the transport's own error, or
342///   its redacted copy when it held a credential of the request, is the
343///   [`source`](StdError::source).
344/// - [`ErrorKind::ResponseTooLarge`](crate::ErrorKind::ResponseTooLarge) when
345///   a success response's body is larger than the limit. A body over the
346///   limit is not read past it, and a failure response whose body is over the
347///   limit is an API error with the status and headers and no body.
348pub(crate) async fn attempt<S>(
349    service: &S,
350    exchange: Exchange<'_>,
351    retry: u32,
352    body: Option<Bytes>,
353) -> Result<Received, Error>
354where
355    S: HttpService,
356{
357    let events = telemetry::Exchange::new(exchange.method, exchange.uri, retry);
358    // The header map and the request are built inside a block so that their
359    // storage ends before the await. An async function keeps every local of
360    // a scope that is still open when it suspends, even one whose value was
361    // moved out, so written at the top level the two would ride in the
362    // future beside the copy the transport call already holds: 352 bytes of
363    // every call's future, which over a custom transport tips it over the
364    // size at which tokio boxes a spawned future in a debug build (2,048
365    // bytes). Over the default transport hyper's response future keeps the
366    // call over that size either way (2,344 bytes), so a debug-build call
367    // spawned on the default client is boxed.
368    let (started, exchanged) = {
369        let mut headers = exchange.base_headers.clone();
370        for (name, value) in exchange.call_headers {
371            headers.insert(name.clone(), value.clone());
372        }
373        if retry > 0 {
374            headers.insert(RETRY_COUNT_HEADER, HeaderValue::from(retry));
375        }
376        telemetry::sending(events, &headers, body.as_ref());
377        let started = telemetry::clock();
378
379        let mut request = Request::new(body.map_or_else(Body::empty, Body::from));
380        *request.method_mut() = exchange.method.clone();
381        *request.uri_mut() = exchange.uri.clone();
382        *request.headers_mut() = headers;
383        (started, exchange_once(service, request, exchange.max_response_bytes))
384    };
385    let outcome = match exchange.deadline {
386        Some(deadline) => tokio::time::timeout(deadline, exchanged)
387            .await
388            .unwrap_or_else(|_| Err(Failure::Error(Error::timeout(deadline)))),
389        None => exchanged.await,
390    };
391
392    // A response is reported by its status; an attempt that ended without
393    // one is reported by what ended it. An API error is not reported a second
394    // time as a failure: its message can come from the body.
395    let failure = match outcome {
396        Ok((status, headers, body)) => {
397            telemetry::responded(events, status, &headers, started);
398            telemetry::received(events, status, &headers, &body, started);
399            if status.is_success() {
400                return Ok((status, headers, body));
401            }
402            return Err(ApiError::new(status, body, headers, Some(endpoint(exchange))).into());
403        }
404        Err(Failure::TooLarge { status, headers }) if !status.is_success() => {
405            telemetry::responded(events, status, &headers, started);
406            return Err(ApiError::with_message(
407                status,
408                Bytes::new(),
409                headers,
410                Some(endpoint(exchange)),
411                Error::response_too_large(exchange.max_response_bytes).to_string(),
412            )
413            .into());
414        }
415        Err(Failure::TooLarge { .. }) => Error::response_too_large(exchange.max_response_bytes),
416        Err(Failure::Error(error)) => redacted(error, exchange),
417    };
418    telemetry::failed(events, &failure, started);
419    Err(failure)
420}
421
422/// How an attempt can end short of a response body.
423enum Failure {
424    /// Anything that becomes an [`Error`] without needing the response.
425    Error(Error),
426    /// The body was larger than the limit. Whether that is a response too
427    /// large or an API error depends on the status, which is kept.
428    TooLarge { status: StatusCode, headers: HeaderMap },
429}
430
431/// Waits for the transport, sends `request`, and reads the whole response.
432async fn exchange_once<S>(
433    service: &S,
434    request: Request<Body>,
435    limit: usize,
436) -> Result<Received, Failure>
437where
438    S: HttpService,
439{
440    // A clone per call, as `tower` intends: readiness belongs to the handle
441    // that is then called, and a shared one would let another task take the
442    // slot this one waited for. The handle is dropped once the call has been
443    // made: the response future owns what it needs, and a handle kept to the
444    // end would be stored in this future through the whole body read.
445    let called = {
446        let mut service = service.clone();
447        poll_fn(|cx| service.poll_ready(cx))
448            .await
449            .map_err(|error| Failure::Error(connection(error)))?;
450        service.call(request)
451    };
452    let response = called.await.map_err(|error| Failure::Error(connection(error)))?;
453    let (parts, body) = response.into_parts();
454
455    // A declared length over the limit is refused before a byte is read.
456    if http_body::Body::size_hint(&body).lower() > limit as u64 {
457        return Err(Failure::TooLarge { status: parts.status, headers: parts.headers });
458    }
459    match Limited::new(body, limit).collect().await {
460        Ok(collected) => Ok((parts.status, parts.headers, collected.to_bytes())),
461        Err(error) if error.is::<LengthLimitError>() => {
462            Err(Failure::TooLarge { status: parts.status, headers: parts.headers })
463        }
464        Err(error) => Err(Failure::Error(connection(error))),
465    }
466}
467
468/// The error a transport failure becomes.
469///
470/// An error this crate raised inside its own transport is passed through as
471/// it is. Anything else is a connection failure whose message is the chain of
472/// the transport's own messages - the cause stays reachable as the
473/// [`source`](StdError::source).
474pub(crate) fn connection(error: impl Into<BoxError>) -> Error {
475    match error.into().downcast::<Error>() {
476        Ok(ours) => *ours,
477        Err(other) => Error::connection(connection_message(&*other), Some(other)),
478    }
479}
480
481/// `Connection error: ` and then every message of the error chain, joined
482/// with `: `.
483///
484/// The chain is the transport's, not the request's: it names what failed -
485/// `tcp connect error: Connection refused`, `invalid peer certificate:
486/// UnknownIssuer` - and holds neither a header nor a body. It is cut after
487/// eight links, which no real chain reaches.
488///
489/// It can still hold text the server chose: an HTTP/2 GOAWAY's debug data
490/// (up to a frame, 16 KiB by default), the subject of a certificate the
491/// platform refused, and whatever a caller's own transport puts in its
492/// `Display`. So every link is written as text this SDK did not write -
493/// control and format characters escaped - and the whole of it after the
494/// prefix is cut at [`text::MAX_MESSAGE_CHARS`] characters and marked
495/// with U+2026; the full chain stays reachable through
496/// [`source`](StdError::source). A backslash is written as it is: h2 and
497/// rustls already print their own text through `Debug`, where a doubled
498/// backslash would only make an escape harder to read, and nothing is ever
499/// parsed back out of this message.
500fn connection_message(error: &(dyn StdError + 'static)) -> String {
501    cut(&render_uncut(error))
502}
503
504/// What every connection error's message starts with.
505const CONNECTION_PREFIX: &str = "Connection error: ";
506
507/// A connection error's message before it is cut: the text, and the byte
508/// offset at which each of its pieces ends. A piece is one escaped character
509/// or the `": "` between two links, and a cut never splits one.
510struct Uncut {
511    text: String,
512    ends: Vec<usize>,
513}
514
515/// The whole message for `error`: [`CONNECTION_PREFIX`], then up to eight
516/// links of the chain, each escaped, joined with `": "`.
517fn render_uncut(error: &(dyn StdError + 'static)) -> Uncut {
518    let mut message = SafeText::after(String::from(CONNECTION_PREFIX), usize::MAX, Backslash::Keep);
519    let mut ends = Vec::new();
520    let mut character_bytes = [0; 4];
521    let mut link = Some(error);
522    for index in 0..8 {
523        let Some(current) = link else { break };
524        if index > 0 {
525            message.fixed(": ");
526            ends.push(message.byte_len());
527        }
528        for character in current.to_string().chars() {
529            message.untrusted(character.encode_utf8(&mut character_bytes), usize::MAX);
530            ends.push(message.byte_len());
531        }
532        link = current.source();
533    }
534    Uncut { text: message.into_string(), ends }
535}
536
537/// `uncut` cut at [`text::MAX_MESSAGE_CHARS`] characters after the prefix,
538/// the last whole piece that fits followed by U+2026.
539fn cut(uncut: &Uncut) -> String {
540    let mut message = String::from(CONNECTION_PREFIX);
541    let mut chars = 0;
542    let mut start = CONNECTION_PREFIX.len();
543    for &end in &uncut.ends {
544        let piece = &uncut.text[start..end];
545        let len = piece.chars().count();
546        if chars + len > text::MAX_MESSAGE_CHARS {
547            message.push('\u{2026}');
548            break;
549        }
550        message.push_str(piece);
551        chars += len;
552        start = end;
553    }
554    message
555}
556
557impl Uncut {
558    /// This message with every form of a credential after the prefix
559    /// replaced by `***`.
560    ///
561    /// It runs over the escaped text, since escaping can form a credential no
562    /// link holds: a tab written as `\t`, or two links joined by `": "`. A
563    /// match that covers part of a piece takes the whole piece, and the
564    /// replacement is one piece, so the cut can neither split it nor leave
565    /// part of the credential before it.
566    fn redacted(&self, credentials: &Credentials) -> Self {
567        let prefix = CONNECTION_PREFIX.len();
568        let mut found = credentials
569            .matches(&self.text[prefix..])
570            .map(|range| range.start + prefix..range.end + prefix);
571        let mut next = found.next();
572        let mut text = String::from(CONNECTION_PREFIX);
573        let mut ends = Vec::with_capacity(self.ends.len());
574        let mut start = prefix;
575        let mut index = 0;
576        while let Some(&end) = self.ends.get(index) {
577            match &next {
578                Some(first) if first.start < end => {
579                    let mut group_end = first.end;
580                    next = found.next();
581                    while let Some(&piece_end) = self.ends.get(index) {
582                        index += 1;
583                        while let Some(another) = &next
584                            && another.start < piece_end
585                        {
586                            group_end = group_end.max(another.end);
587                            next = found.next();
588                        }
589                        start = piece_end;
590                        if piece_end >= group_end {
591                            break;
592                        }
593                    }
594                    text.push_str("***");
595                }
596                _ => {
597                    text.push_str(&self.text[start..end]);
598                    start = end;
599                    index += 1;
600                }
601            }
602            ends.push(text.len());
603        }
604        Self { text, ends }
605    }
606}
607
608/// `error` with the request's credentials kept out of it.
609///
610/// Only a connection error with a cause can hold one: its cause is the
611/// transport's error, and its message is built from it. The credentials are
612/// read from the headers the request was built from, and only here, after
613/// the attempt failed. A chain that holds none is returned as it is; see
614/// [`redact::copy_chain`] for the other two outcomes.
615pub(crate) fn redacted(error: Error, exchange: Exchange<'_>) -> Error {
616    let Some(source) =
617        StdError::source(&error).filter(|_| matches!(error.kind(), ErrorKind::Connection))
618    else {
619        return error;
620    };
621    let credentials = Credentials::new(
622        exchange
623            .base_headers
624            .iter()
625            .chain(exchange.call_headers.iter().map(|(name, value)| (name, value))),
626    );
627    // The fixed prefix is the SDK's own text: only what follows it came from
628    // the transport, as the Python SDK redacts the error before prefixing it.
629    let message = error.to_string();
630    let transport_text = message.strip_prefix(CONNECTION_PREFIX).unwrap_or(&message);
631    match redact::copy_chain(source, transport_text, &credentials) {
632        Outcome::Kept => error,
633        Outcome::MessageOnly => {
634            let (_, source) = error.into_parts();
635            let redacted = credentials.redact(transport_text);
636            Error::connection(format!("{CONNECTION_PREFIX}{redacted}"), source)
637        }
638        Outcome::Replaced(link) => {
639            let message = cut(&render_uncut(&link).redacted(&credentials));
640            Error::connection(message, Some(Box::new(link)))
641        }
642    }
643}
644
645/// The endpoint of `exchange` as an error names it.
646fn endpoint(exchange: Exchange<'_>) -> Box<str> {
647    format_endpoint(exchange.method, exchange.uri).into_boxed_str()
648}
649
650#[cfg(test)]
651#[path = "mod_tests.rs"]
652mod tests;