Skip to main content

pubhubs/misc/
awc_http_connector.rs

1//! An [`object_store`] [`osc::HttpConnector`] built on [`awc`], letting the S3 store run without
2//! `reqwest` (and hence without `ring`).
3
4use std::pin::Pin;
5use std::task::Poll;
6use std::time::Duration;
7
8use bytes::Bytes;
9use futures_util::StreamExt as _;
10use http_body::{Body as _, Frame};
11use tokio::sync::mpsc;
12
13use object_store::client as osc;
14
15use crate::misc::stream_ext::{StreamExt as _, SyncStream, Truncated};
16
17/// An [`object_store`] [`osc::HttpConnector`]: a factory that builds one [`awc`]-backed [`osc::HttpClient`]
18/// per [`osc::HttpConnector::connect`] call.  Create using [`AwcHttpConnector::new`].
19///
20/// Reusing `awc` keeps our whole outbound HTTP stack — including the process-wide post-quantum
21/// rustls provider (see [`crate::misc::rustls_ext`]) — in one place, and keeps `reqwest` (and
22/// transitively `ring`) out of the build.
23///
24/// object_store calls `connect` once per HTTP client it needs — the main store client and, for
25/// non-static credentials, separate credential/metadata clients — each with its own
26/// [`osc::ClientOptions`] (e.g. the IMDS metadata client asks for a shorter connect timeout).  We mirror
27/// its reqwest connector by building one `awc::Client` per call, configured from that call's options.
28#[derive(Clone, Debug, Default)]
29pub struct AwcHttpConnector;
30
31impl AwcHttpConnector {
32    pub fn new() -> Self {
33        Self
34    }
35}
36
37impl osc::HttpConnector for AwcHttpConnector {
38    fn connect(&self, options: &osc::ClientOptions) -> object_store::Result<osc::HttpClient> {
39        // We honor only the options in `ConnectorOptions::HONORED_CLIENT_OPTIONS`; every other
40        // admin-settable client option is rejected at store construction (see
41        // crate::servers::object_store::DefaultObjectStore), so none is silently dropped.
42        let mut opts = ConnectorOptions::default();
43        for &(key, apply) in ConnectorOptions::HONORED_CLIENT_OPTIONS {
44            if let Some(value) = options.get_config_value(&key) {
45                opts = apply(opts, key, value)?;
46            }
47        }
48
49        let allow_http = opts.allow_http;
50        let timeout = opts.timeout;
51        let connect_timeout = opts.connect_timeout;
52        let jobs = opts
53            .spawn_worker()
54            .map_err(|err| object_store::Error::Generic {
55                store: "AwcHttpConnector",
56                source: format!("could not spawn the awc-object-store worker thread: {err}").into(),
57            })?;
58
59        Ok(osc::HttpClient::new(AwcClient {
60            jobs,
61            allow_http,
62            timeout,
63            connect_timeout,
64        }))
65    }
66}
67
68/// The client options [`AwcHttpConnector::connect`](osc::HttpConnector::connect) understands, parsed from [`osc::ClientOptions`].
69pub(crate) struct ConnectorOptions {
70    /// Whether plaintext HTTP is permitted (enforced in [`AwcClient::call`](osc::HttpService::call)).
71    allow_http: bool,
72
73    /// Overall request timeout; `None` means it was explicitly disabled.
74    timeout: Option<Duration>,
75
76    /// Connection-establishment timeout; `None` means it was explicitly disabled.
77    connect_timeout: Option<Duration>,
78
79    /// Restrict to HTTP/1.1 (no HTTP/2); see [`ConnectorOptions::build_client`].
80    http1_only: bool,
81
82    /// `User-Agent` header to send; `None` falls back to awc's default.
83    user_agent: Option<String>,
84}
85
86impl Default for ConnectorOptions {
87    fn default() -> Self {
88        Self {
89            allow_http: false,
90            timeout: None,
91            connect_timeout: None,
92
93            // Mirror object_store's `ClientOptions`, whose `http1_only` defaults to true, so the
94            // starting point matches even before the option is read.
95            http1_only: true,
96            user_agent: None,
97        }
98    }
99}
100
101/// How [`AwcHttpConnector::connect`](osc::HttpConnector::connect) folds one honored client
102/// option into [`ConnectorOptions`]: given the options parsed so far, return them with this one set.  
103type ApplyOption =
104    fn(ConnectorOptions, osc::ClientConfigKey, String) -> object_store::Result<ConnectorOptions>;
105
106impl ConnectorOptions {
107    /// The client options the awc connector honors, each paired with how it is applied.  This is the
108    /// single source of truth for them: `connect` folds exactly these keys,
109    /// [`ConnectorOptions::honors_client_config_key`] derives membership from it, and the S3 store
110    /// builder rejects every other *transport* client option — those would only configure the built-in
111    /// HTTP client we replaced.  (The builder still accepts a non-transport client key it can apply
112    /// without the transport — `DefaultContentType`, a request header — see `object_store.rs`.)
113    /// Supporting one more transport option is a single entry here.
114    const HONORED_CLIENT_OPTIONS: &[(osc::ClientConfigKey, ApplyOption)] = &[
115        (osc::ClientConfigKey::AllowHttp, |opts, key, value| {
116            Ok(ConnectorOptions {
117                allow_http: Self::parse_bool(key, &value)?,
118                ..opts
119            })
120        }),
121        (osc::ClientConfigKey::Timeout, |opts, key, value| {
122            Ok(ConnectorOptions {
123                timeout: Some(Self::parse_duration(key, value)?),
124                ..opts
125            })
126        }),
127        (osc::ClientConfigKey::ConnectTimeout, |opts, key, value| {
128            Ok(ConnectorOptions {
129                connect_timeout: Some(Self::parse_duration(key, value)?),
130                ..opts
131            })
132        }),
133        (osc::ClientConfigKey::Http1Only, |opts, key, value| {
134            Ok(ConnectorOptions {
135                http1_only: Self::parse_bool(key, &value)?,
136                ..opts
137            })
138        }),
139        (osc::ClientConfigKey::UserAgent, |opts, _key, value| {
140            Ok(ConnectorOptions {
141                user_agent: Some(value),
142                ..opts
143            })
144        }),
145    ];
146
147    /// Parse bool client option.
148    /// C.f. <https://github.com/apache/arrow-rs-object-store/blob/6c5b299d4274219ecd406cc4828b94efe4a14f8d/src/config.rs#L74-L86>
149    fn parse_bool(key: osc::ClientConfigKey, value: &str) -> object_store::Result<bool> {
150        match value.to_ascii_lowercase().as_str() {
151            "1" | "true" | "on" | "yes" | "y" => Ok(true),
152            "0" | "false" | "off" | "no" | "n" => Ok(false),
153            _ => Err(object_store::Error::Generic {
154                store: "AwcHttpConnector",
155                source: format!(
156                    "could not parse the {key:?} client option ({value:?}) as a boolean"
157                )
158                .into(),
159            }),
160        }
161    }
162
163    /// Parses a duration client option, rejecting one too large to be a real deadline: each request
164    /// turns the timeout into an `Instant::now() + duration` deadline, which overflows (panics) for
165    /// absurd values.
166    /// C.f. <https://github.com/apache/arrow-rs-object-store/blob/6c5b299d4274219ecd406cc4828b94efe4a14f8d/src/config.rs#L88-L95>
167    fn parse_duration(key: osc::ClientConfigKey, value: String) -> object_store::Result<Duration> {
168        // A timeout longer than this is never a real deadline; capping it well below the overflow
169        // boundary keeps deadline arithmetic safe with enormous margin.
170        const MAX: Duration = Duration::from_secs(365 * 24 * 60 * 60); // one year
171
172        let duration =
173            humantime::parse_duration(&value).map_err(|err| object_store::Error::Generic {
174                store: "AwcHttpConnector",
175                source: format!(
176                    "could not parse the {key:?} client option ({value:?}) as a duration: {err}"
177                )
178                .into(),
179            })?;
180
181        if duration > MAX {
182            return Err(object_store::Error::Generic {
183                store: "AwcHttpConnector",
184                source: format!(
185                    "the {key:?} client option ({value:?}) exceeds the one-year maximum; a timeout \
186                     this long is never a real deadline — pick a realistic value"
187                )
188                .into(),
189            });
190        }
191
192        Ok(duration)
193    }
194
195    /// Whether [`AwcHttpConnector::connect`](osc::HttpConnector::connect) reads `key` back from
196    /// [`osc::ClientOptions`] — i.e. whether it is in [`ConnectorOptions::HONORED_CLIENT_OPTIONS`].
197    /// The S3 store builder rejects every other client option (see `DefaultObjectStore`).
198    pub(crate) fn honors_client_config_key(key: &osc::ClientConfigKey) -> bool {
199        Self::HONORED_CLIENT_OPTIONS.iter().any(|(k, _)| k == key)
200    }
201
202    /// Spawns a worker thread — a current-thread runtime + `LocalSet` owning one `awc::Client` built
203    /// from these options — and returns the channel that feeds it, or the error if the thread could
204    /// not be spawned.  The worker thread stops when the channel is closed/dropped.
205    ///
206    /// `awc::Client` is `!Send`, but [`osc::HttpService`] must be `Send + Sync`: awc drives connections
207    /// with [`tokio::task::spawn_local`] (so it needs a `LocalSet`) and its futures can't be awaited
208    /// inside the `Send` future [`osc::HttpService::call`] returns.  We run our own thread rather than
209    /// the caller's runtime because object_store does not promise the caller is on a `LocalSet`.  The
210    /// thread exits once the returned sender and every clone are dropped (i.e. when the owning object
211    /// store is).
212    ///
213    /// The runtime is built here, on the caller's thread (a `Runtime` is `Send`), then moved into the
214    /// worker — so a failed runtime build surfaces as a construction-time error rather than letting
215    /// every later request fail opaquely with [`Error::WorkerStopped`].  The `awc::Client` is still
216    /// built on the worker thread, since it is `!Send`; that build is effectively infallible here (the
217    /// process-wide rustls provider is installed in `main` before any store is constructed).
218    fn spawn_worker(self) -> std::io::Result<mpsc::UnboundedSender<Job>> {
219        let (jobs, mut requests) = mpsc::unbounded_channel::<Job>();
220        let runtime = tokio::runtime::Builder::new_current_thread()
221            .enable_all()
222            .build()?;
223        std::thread::Builder::new()
224            .name("awc-object-store".to_owned())
225            .spawn(move || {
226                tokio::task::LocalSet::new().block_on(&runtime, async move {
227                    let client = self.build_client();
228
229                    while let Some(job) = requests.recv().await {
230                        tokio::task::spawn_local(job.serve(client.clone()));
231                    }
232                });
233            })?;
234        Ok(jobs)
235    }
236
237    /// Builds the worker's `awc::Client` from these options: the `user_agent` and `http1_only`.  The
238    /// request and connect timeouts are *not* applied here — awc's own timeouts don't match reqwest's
239    /// semantics (its request timeout bounds only the response head, and its connect timeout is two
240    /// per-phase budgets that can sum to ~2× the configured value).  Instead [`Job::serve`] enforces
241    /// both as single wall-clock deadlines per request, started when the request is initiated (see
242    /// [`Job::deadline`] and [`Job::connect_deadline`]), matching reqwest.  We therefore disable awc's
243    /// built-in response timeout so its default cannot also fire.
244    ///
245    /// When `http1_only` is set (object_store defaults it to true, since HTTP/2 multiplexes onto a
246    /// single TCP connection, which is slower for bulk object transfers) we restrict the client to
247    /// HTTP/1.1.  This goes through `Connector::max_http_version`, which rebuilds the TLS config
248    /// advertising only `http/1.1` in ALPN — still via `ClientConfig::builder()`, so it keeps the
249    /// process-wide post-quantum rustls provider (see [`crate::misc::rustls_ext`]); swapping the
250    /// connector in does not drop post-quantum key exchange.
251    fn build_client(&self) -> awc::Client {
252        let max_http_version = match self.http1_only {
253            true => awc::http::Version::HTTP_11,
254            false => awc::http::Version::HTTP_2,
255        };
256        let connector = awc::Connector::new().max_http_version(max_http_version);
257
258        // The request and connect timeouts are enforced per-request as wall-clock deadlines in
259        // [`Job::serve`], not here (see this method's doc).  Disable awc's built-in response timeout so
260        // its default cannot fire on top of ours.
261        let builder = awc::ClientBuilder::new()
262            .connector(connector)
263            .disable_timeout();
264
265        // object_store's reqwest connector always sends a User-Agent: the admin-configured one, or its
266        // own `object_store/<version>` default.  That default lives in a const object_store applies
267        // only when building its own reqwest client — it is not carried in the `ClientOptions` we are
268        // handed — so we substitute our own `pubhubs/<version>` rather than send none.
269        let user_agent =
270            self.user_agent
271                .clone()
272                .unwrap_or_else(|| match crate::servers::version::version() {
273                    Some(version) => format!("{}/{}", env!("CARGO_PKG_NAME"), version),
274                    // No known version (e.g. built outside a git checkout): send the bare name rather
275                    // than a useless `pubhubs/n/a`.
276                    None => env!("CARGO_PKG_NAME").to_owned(),
277                });
278
279        let builder = builder.add_default_header((awc::http::header::USER_AGENT, user_agent));
280        builder.finish()
281    }
282}
283
284/// The [`osc::HttpService`] used to implement [`AwcHttpConnector::connect`](osc::HttpConnector::connect).
285#[derive(Clone, Debug)]
286struct AwcClient {
287    jobs: mpsc::UnboundedSender<Job>,
288
289    /// Whether plaintext HTTP is permitted.
290    allow_http: bool,
291
292    /// Whole-request timeout, turned into each [`Job::deadline`]; `None` if disabled.
293    timeout: Option<Duration>,
294
295    /// Connect-phase timeout, turned into each [`Job::connect_deadline`]; `None` if disabled.
296    connect_timeout: Option<Duration>,
297}
298
299#[async_trait::async_trait]
300impl osc::HttpService for AwcClient {
301    async fn call(&self, request: osc::HttpRequest) -> Result<osc::HttpResponse, osc::HttpError> {
302        if !self.allow_http && request.uri().scheme() == Some(&http::uri::Scheme::HTTP) {
303            return Err(Error::HttpNotAllowed(request.uri().clone()).into());
304        }
305
306        // Start the timeout clocks now, at request initiation, so they also cover the time the job
307        // waits in the channel for the worker — like reqwest, whose timeout spans the whole operation.
308        let now = tokio::time::Instant::now();
309        let deadline = self.timeout.map(|t| now + t);
310
311        // The connect phase is part of the whole request, so its deadline can't outlast the request
312        // deadline; clamp it here so `serve` can use it directly as the head bound without re-deriving
313        // the minimum.
314        let connect_deadline = match (self.connect_timeout.map(|t| now + t), deadline) {
315            (Some(connect), Some(deadline)) => Some(connect.min(deadline)),
316            (connect_deadline, _) => connect_deadline,
317        };
318
319        let (respond_to, response) = tokio::sync::oneshot::channel();
320
321        self.jobs
322            .send(Job {
323                request,
324                respond_to,
325                deadline,
326                connect_deadline,
327            })
328            .map_err(|_| Error::WorkerStopped)?;
329
330        response.await.map_err(|_| Error::WorkerStopped)?
331    }
332}
333
334/// A request for the worker thread, with a channel for the response head.
335struct Job {
336    request: osc::HttpRequest,
337    respond_to: tokio::sync::oneshot::Sender<Result<osc::HttpResponse, osc::HttpError>>,
338
339    /// Whole-request deadline (connect + head + body), or `None` if no request timeout is set.
340    /// Computed at request initiation in [`AwcClient::call`](osc::HttpService::call).
341    deadline: Option<tokio::time::Instant>,
342
343    /// Deadline for receiving the response head — connect *and* head, since awc's `send_body` bundles
344    /// them into one future with no "connected" hook to split them.  `None` if no connect timeout is
345    /// set, and otherwise clamped to not exceed [`Job::deadline`] (the connect phase is part of the
346    /// whole request).  Computed at request initiation in [`AwcClient::call`](osc::HttpService::call).
347    connect_deadline: Option<tokio::time::Instant>,
348}
349
350impl Job {
351    /// Relays this request and answers the caller with the response head, whose body streams off the
352    /// worker thread over a [`SyncStream`].  [`Job::connect_deadline`] bounds receiving the head and
353    /// [`Job::deadline`] bounds the whole request, including each body read.
354    async fn serve(self, client: awc::Client) {
355        let Job {
356            request,
357            respond_to,
358            deadline,
359            connect_deadline,
360        } = self;
361
362        let (parts, body) = request.into_parts();
363
364        // Producing the response head is fallible; do it in one block so a failure has a single exit.
365        let prepared = async {
366            let request = Self::build_request(&client, &parts)?;
367
368            let send = request.send_body(RequestBody(body));
369            let mut response = match connect_deadline.or(deadline) {
370                Some(at) => tokio::time::timeout_at(at, send)
371                    .await
372                    .map_err(|_elapsed| Error::HeadTimeout)?
373                    .map_err(Error::from)?,
374                None => send.await.map_err(Error::from)?,
375            };
376
377            // S3 frames every response with Content-Length, never Transfer-Encoding (and RFC 9112 §6.1
378            // makes the two mutually exclusive).  So Transfer-Encoding here means an intermediary
379            // re-framed the body as chunked, in which case awc decodes by the transfer coding and ignores
380            // Content-Length — leaving the length bookkeeping below working off a stale length.  Reject it
381            // loudly rather than silently mis-frame.
382            if response
383                .headers()
384                .contains_key(awc::http::header::TRANSFER_ENCODING)
385            {
386                return Err(Error::UnexpectedTransferEncoding.into());
387            }
388
389            // awc reports a Content-Length body cut short by a premature close as a clean end, not an
390            // error (its decoder inherits tokio_util's default `decode_eof`).  So we remember the
391            // promised length and check it ourselves once the stream ends; otherwise object_store would
392            // accept a short object.
393            //
394            // A bodiless response carries no message body even when it advertises a Content-Length
395            // (which then describes the resource, not bytes it will send), so we must not mistake its
396            // (correct) empty body for truncation.  The bodiless subset an S3 client meets (RFC 9112
397            // §6.3): any response to a HEAD request (HeadObject, bucket-exists checks), 304 Not Modified
398            // (a conditional GetObject, which may echo the object's Content-Length), and 204 No Content
399            // (DeleteObject).  The rule's other cases don't arise: S3's 100 Continue is consumed by awc
400            // before the final response reaches us, and S3 clients never issue CONNECT.
401            let expected_len = if parts.method == http::Method::HEAD
402                || matches!(response.status().as_u16(), 204 | 304)
403            {
404                None
405            } else {
406                // The Content-Length the response declared, if any.
407                response
408                    .headers()
409                    .get(awc::http::header::CONTENT_LENGTH)
410                    .and_then(|value| value.to_str().ok())
411                    .and_then(|value| value.parse::<u64>().ok())
412            };
413
414            // Translate the awc response head (the `http` 0.2 crate) into an object_store one
415            // (`http` 1.x); its body is filled in below.
416            let mut builder = http::Response::builder().status(response.status().as_u16());
417            for (name, value) in response.headers() {
418                builder = builder.header(name.as_str(), value.as_bytes());
419            }
420
421            // `awc::ClientResponse` is `!Send`, so we read the body here on the worker thread — bounding
422            // each read and checking the promised length — and relay the chunks over a `SyncStream` to
423            // the `Send` body object_store reads.  If this worker is torn down mid-stream the relay
424            // yields [`Truncated`], which `ResponseBody` turns into a retryable error rather than let
425            // object_store accept a silently short body.
426            let source = async_stream::stream! {
427                let mut received: u64 = 0;
428                loop {
429                    // Bound the body by the whole-request deadline: awc's timeouts don't cover the
430                    // body, so without this a stalled (or trickling) body would tie up the worker past
431                    // the configured timeout.  A lapse is retryable, so object_store can retry the
432                    // (idempotent) request.
433                    let next = match deadline {
434                        Some(at) => match tokio::time::timeout_at(at, response.next()).await {
435                            Ok(next) => next,
436                            Err(_elapsed) => {
437                                yield Err(Error::ReadTimeout);
438                                return;
439                            }
440                        },
441                        None => response.next().await,
442                    };
443                    match next {
444                        Some(Ok(bytes)) => {
445                            received += bytes.len() as u64;
446                            yield Ok(bytes);
447                        }
448                        // awc surfaced a body error; forward it (object_store retries body errors).
449                        Some(Err(err)) => {
450                            yield Err(Error::ResponseBody(err.to_string()));
451                            return;
452                        }
453                        None => break, // awc's response stream ended
454                    }
455                }
456
457                if let Some(expected) = expected_len {
458                    // `expected_len` is set only for a Content-Length–framed body (bodiless responses and
459                    // Transfer-Encoding ones are excluded above), and awc's length decoder stops at the
460                    // declared length, so `received` can fall short but never exceed it.  Were that ever
461                    // untrue, awc would have handed object_store an over-long body; fail loudly instead.
462                    assert!(
463                        received <= expected,
464                        "awc yielded {received} body bytes, exceeding the Content-Length of {expected}"
465                    );
466                    // A short Content-Length means a premature close, which object_store should retry rather
467                    // than accept as a complete (but short) object.
468                    if received < expected {
469                        yield Err(Error::IncompleteBody { expected, received });
470                    }
471                }
472            };
473
474            // Queue at most this many response-body chunks ahead of a slow reader before the worker
475            // blocks (backpressure), so a lagging consumer can't make the worker buffer the whole
476            // object in memory.
477            //
478            // A bodiless response (HEAD, 204, 304 — frequent for an S3 store) still sets up this
479            // channel and pump task only to relay zero bytes.  We don't special-case it: a bodiless
480            // status bounds the *length check*, not what awc actually yields, so the uniform relay
481            // faithfully forwards whatever frames arrive, at the cost of one wasted channel per such
482            // response.
483            let chunks = source.sync(std::num::NonZero::new(16).unwrap());
484
485            // `head` is the whole `http::Response`, but only its head has arrived: the body is the
486            // `SyncStream` object_store drains lazily off the worker, so building it reads no body bytes.
487            let head = builder
488                .body(osc::HttpResponseBody::new(ResponseBody { chunks }))
489                .map_err(Error::Response)?;
490            Ok::<_, osc::HttpError>(head)
491        }
492        .await;
493
494        // The head is ready (or failed); hand it back over the oneshot.  The body's frames are read and
495        // relayed in the background by the pump `source.sync` spawned, which outlives this `serve` call.
496        match prepared {
497            Ok(head) => {
498                if respond_to.send(Ok(head)).is_err() {
499                    log::debug!(
500                        "object store: caller went away before the response head of {} {}",
501                        parts.method,
502                        parts.uri
503                    );
504                }
505            }
506            Err(err) => {
507                let _ = respond_to.send(Err(err));
508            }
509        }
510    }
511
512    /// Translates an object_store request (the `http` 1.x crate) into an awc one (the `http` 0.2
513    /// crate), through the version-agnostic byte/string forms of the method and headers.
514    fn build_request(
515        client: &awc::Client,
516        parts: &http::request::Parts,
517    ) -> Result<awc::ClientRequest, Error> {
518        let method = awc::http::Method::from_bytes(parts.method.as_str().as_bytes())
519            .map_err(|_| Error::Method(parts.method.clone()))?;
520
521        let mut request = client.request(method, parts.uri.to_string());
522
523        // Copy object_store's (SigV4-signed) headers verbatim.  We don't special-case Content-Length:
524        // actix-http derives Content-Length/Transfer-Encoding from the body it sends, replacing any we
525        // set, so the framing always matches the signed body bytes.
526        for (name, value) in &parts.headers {
527            let header_name = awc::http::header::HeaderName::from_bytes(name.as_str().as_bytes())
528                .map_err(|_| Error::HeaderName(name.clone()))?;
529            let header_value = awc::http::header::HeaderValue::from_bytes(value.as_bytes())
530                .map_err(|_| Error::HeaderValue(name.clone()))?;
531            request = request.append_header((header_name, header_value));
532        }
533
534        Ok(request)
535    }
536}
537
538/// Something that went wrong relaying a request through [`awc`].
539#[derive(Debug, thiserror::Error)]
540enum Error {
541    #[error("the awc worker thread has stopped")]
542    WorkerStopped,
543
544    #[error("awc rejected the request method {0:?}")]
545    Method(http::Method),
546
547    #[error("awc rejected the request header name {0:?}")]
548    HeaderName(http::HeaderName),
549
550    #[error("awc rejected the value of request header {0:?}")]
551    HeaderValue(http::HeaderName),
552
553    #[error(
554        "refusing a plaintext HTTP request to {0}; set the object store's allow_http option to permit it"
555    )]
556    HttpNotAllowed(http::Uri),
557
558    // A transport error from awc, pre-classified into the object_store kind that decides
559    // retryability (see `classify_send_error`).  awc's `SendRequestError` is `!Sync` (it can hold a
560    // `Box<dyn Debug>`) and `HttpError::new` wants `Send + Sync`, so we keep only its message.
561    #[error("sending the request failed: {message}")]
562    Transport {
563        kind: osc::HttpErrorKind,
564        message: String,
565    },
566
567    #[error("reading the response body failed: {0}")]
568    ResponseBody(String),
569
570    /// The remote under-delivered: awc ended the stream cleanly but fewer bytes arrived than the
571    /// response's Content-Length (a premature close awc reports as a clean EOF).
572    #[error("the response body was {received} bytes but its Content-Length was {expected}")]
573    IncompleteBody { expected: u64, received: u64 },
574
575    #[error(
576        "the response used Transfer-Encoding, which S3's use of Content-Length precludes.  (Perhaps a proxy is to blame?)"
577    )]
578    UnexpectedTransferEncoding,
579
580    #[error("reading the response body timed out")]
581    ReadTimeout,
582
583    #[error("establishing the connection or receiving the response head timed out")]
584    HeadTimeout,
585
586    #[error("assembling the response failed: {0}")]
587    Response(#[from] http::Error),
588}
589
590impl From<awc::error::SendRequestError> for Error {
591    /// Wraps an awc send error, classifying it into the object_store [`osc::HttpErrorKind`] up front (the
592    /// error itself is `!Sync` and can't be carried, so we keep only its message).
593    fn from(err: awc::error::SendRequestError) -> Self {
594        Error::Transport {
595            kind: Self::classify_send_error(&err),
596            message: err.to_string(),
597        }
598    }
599}
600
601impl From<Error> for osc::HttpError {
602    fn from(err: Error) -> Self {
603        // object_store uses the kind to decide whether to retry.
604        let kind = match &err {
605            Error::Transport { kind, .. } => *kind,
606
607            // awc reported a Content-Length short read as a clean end.  Interrupted is retried for
608            // idempotent requests (and object_store's body-retry retries it regardless of kind).
609            //
610            Error::IncompleteBody { .. } => osc::HttpErrorKind::Interrupted,
611
612            // A connect/head or body read that we timed out; retried for idempotent requests.
613            Error::ReadTimeout | Error::HeadTimeout => osc::HttpErrorKind::Timeout,
614
615            // Reading/decoding the response failed, or it was framed in a way S3 never uses;
616            // re-sending can't help (a proxy will keep re-framing), so object_store does not retry this.
617            Error::ResponseBody(_) | Error::Response(_) | Error::UnexpectedTransferEncoding => {
618                osc::HttpErrorKind::Decode
619            }
620
621            // Construction or infrastructure failures: re-sending the identical request can't help
622            // (a forbidden plaintext request, an un-translatable method/header, or a dead worker), so
623            // we mark them non-retryable rather than let object_store spin on them.
624            Error::HttpNotAllowed(_)
625            | Error::WorkerStopped
626            | Error::Method(_)
627            | Error::HeaderName(_)
628            | Error::HeaderValue(_) => osc::HttpErrorKind::Unknown,
629        };
630        osc::HttpError::new(kind, err)
631    }
632}
633
634impl Error {
635    /// Maps an awc transport error to the object_store [`osc::HttpErrorKind`] that best describes it.
636    /// object_store's retry loop — not this function — then decides retryability from that kind: it
637    /// retries `Connect`/`Request` unconditionally, `Timeout`/`Interrupted` only for idempotent
638    /// requests, and never `Decode`/`Unknown`
639    /// (<https://github.com/apache/arrow-rs-object-store/blob/6c5b299d4274219ecd406cc4828b94efe4a14f8d/src/client/retry.rs#L394-L399>).
640    /// We pick each kind so that decision matches what object_store's own reqwest connector
641    /// (`HttpError::reqwest`) would produce for the equivalent failure.
642    fn classify_send_error(err: &awc::error::SendRequestError) -> osc::HttpErrorKind {
643        use awc::error::SendRequestError;
644        match err {
645            // The connection was never established (DNS/TCP/TLS failure, or a connect/handshake
646            // timeout): the request was not sent, so it is always safe to retry.
647            SendRequestError::Connect(_) => osc::HttpErrorKind::Connect,
648
649            // Our overall request timeout (`ClientBuilder::timeout`) elapsed: the request may have
650            // reached the server, so object_store retries only when it is idempotent.
651            SendRequestError::Timeout => osc::HttpErrorKind::Timeout,
652
653            // An I/O error while writing the request to the socket: refine by the OS error kind,
654            // exactly as the reqwest connector does for its hyper/io sources.
655            SendRequestError::Send(io) => Self::classify_io_error(io),
656
657            // The request body failed mid-send, or an HTTP/2 stream was reset: the request was partly
658            // in flight, so retry only when idempotent.
659            SendRequestError::Body(_) | SendRequestError::H2(_) => osc::HttpErrorKind::Interrupted,
660
661            // Receiving the response head failed.  awc lumps transport failures here — a premature
662            // close, a socket I/O error, or a read timeout *while receiving the head* — in with a
663            // genuinely malformed head; `classify_parse_error` splits them so the transport ones stay
664            // retryable (as reqwest classifies them) and only a real parse failure is a decode error.
665            SendRequestError::Response(parse_err) => Self::classify_parse_error(parse_err),
666
667            // A bad URL, an un-buildable request, or an unsupported tunnel: retrying can't help.
668            // `SendRequestError` is `#[non_exhaustive]`, so `Custom` and any future variants land here.
669            _ => osc::HttpErrorKind::Unknown,
670        }
671    }
672
673    /// Maps a response-head [`ParseError`](actix_http::error::ParseError) to the object_store kind.
674    /// awc surfaces a connection lost *while the head is being received* as a `ParseError` rather than
675    /// a `Send`/`Connect` error, so we treat those transport variants as retryable (matching reqwest)
676    /// and only a structurally invalid head as a non-retryable decode error.
677    fn classify_parse_error(err: &actix_http::error::ParseError) -> osc::HttpErrorKind {
678        use actix_http::error::ParseError;
679        match err {
680            // The stream reached EOF before the head was complete — the peer closed mid-response.
681            ParseError::Incomplete => osc::HttpErrorKind::Interrupted,
682
683            // A timeout waiting for the head to arrive.
684            ParseError::Timeout => osc::HttpErrorKind::Timeout,
685
686            // A socket I/O error: refine by its kind, exactly as a `Send` error is.
687            ParseError::Io(io) => Self::classify_io_error(io),
688
689            // A structurally invalid head (bad status line, header, version, oversized, non-UTF-8, …):
690            // resending won't help.  `ParseError` is `#[non_exhaustive]`, so unknown future variants —
691            // most likely new parse failures — are treated as decode errors too.
692            _ => osc::HttpErrorKind::Decode,
693        }
694    }
695
696    /// Maps an I/O error by matching `std::io::Error::kind()`, the same conversion object_store's
697    /// reqwest connector applies when it finds a `std::io::Error` in the error source chain
698    /// (`HttpError::reqwest`):
699    /// <https://github.com/apache/arrow-rs-object-store/blob/6c5b299d4274219ecd406cc4828b94efe4a14f8d/src/client/http/connection.rs#L122-L132>
700    fn classify_io_error(err: &std::io::Error) -> osc::HttpErrorKind {
701        use std::io::ErrorKind;
702        match err.kind() {
703            ErrorKind::TimedOut => osc::HttpErrorKind::Timeout,
704            ErrorKind::ConnectionAborted
705            | ErrorKind::ConnectionReset
706            | ErrorKind::BrokenPipe
707            | ErrorKind::UnexpectedEof => osc::HttpErrorKind::Interrupted,
708            _ => osc::HttpErrorKind::Unknown,
709        }
710    }
711}
712
713/// Adapts object_store's request body to an awc [`awc::body::MessageBody`].
714///
715/// object_store already holds the whole body in memory (it hashed it for the SigV4 signature), so
716/// this does not buffer; reporting the known length lets actix-http set a Content-Length matching
717/// the signed bytes instead of chunking.
718struct RequestBody(osc::HttpRequestBody);
719
720impl awc::body::MessageBody for RequestBody {
721    type Error = osc::HttpError;
722
723    fn size(&self) -> awc::body::BodySize {
724        awc::body::BodySize::Sized(self.0.content_length() as u64)
725    }
726
727    fn poll_next(
728        self: Pin<&mut Self>,
729        cx: &mut std::task::Context<'_>,
730    ) -> Poll<Option<Result<Bytes, osc::HttpError>>> {
731        match Pin::new(&mut self.get_mut().0).poll_frame(cx) {
732            // object_store request bodies are in-memory `Bytes`/`PutPayload`, so they only ever
733            // yield data frames (never trailers) and `into_data` cannot fail.
734            Poll::Ready(Some(Ok(frame))) => Poll::Ready(Some(Ok(frame
735                .into_data()
736                .expect("object_store request body yielded a non-data frame")))),
737            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
738            Poll::Ready(None) => Poll::Ready(None),
739            Poll::Pending => Poll::Pending,
740        }
741    }
742}
743
744/// The response body object_store reads: it maps the [`SyncStream`] that relays awc's body chunks off
745/// the worker thread into `http_body` data frames.  A [`Truncated`] relay — the worker was torn down
746/// before the body finished — becomes a retryable error, so object_store never accepts a silently
747/// short body as complete.
748struct ResponseBody {
749    chunks: SyncStream<Result<Result<Bytes, Error>, Truncated>>,
750}
751
752impl http_body::Body for ResponseBody {
753    type Data = Bytes;
754    type Error = osc::HttpError;
755
756    fn poll_frame(
757        self: Pin<&mut Self>,
758        cx: &mut std::task::Context<'_>,
759    ) -> Poll<Option<Result<Frame<Bytes>, osc::HttpError>>> {
760        let chunk = std::task::ready!(self.get_mut().chunks.poll_next_unpin(cx));
761
762        Poll::Ready(match chunk {
763            Some(Ok(Ok(bytes))) => Some(Ok(Frame::data(bytes))),
764
765            // An awc body error (or our timeout/length error), already an `osc::HttpError`-convertible.
766            Some(Ok(Err(err))) => Some(Err(err.into())),
767
768            // The relay was cut short (worker torn down mid-stream); retryable, not a clean EOF.
769            Some(Err(Truncated)) => Some(Err(osc::HttpError::new(
770                osc::HttpErrorKind::Interrupted,
771                Truncated,
772            ))),
773
774            None => None,
775        })
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use actix_http::error::ParseError;
783    use awc::error::SendRequestError;
784
785    /// A connection lost *while the response head is being received* reaches us as
786    /// `SendRequestError::Response(ParseError::…)`.  Those transport failures must stay retryable
787    /// (matching reqwest); only a structurally invalid head is a non-retryable decode error.
788    #[test]
789    fn response_head_transport_failures_stay_retryable() {
790        use osc::HttpErrorKind::{Decode, Interrupted, Timeout};
791        use std::io::ErrorKind;
792
793        let cases = [
794            // the peer closed before the head was complete
795            ("incomplete", ParseError::Incomplete, Interrupted),
796            // timed out waiting for the head
797            ("head timeout", ParseError::Timeout, Timeout),
798            // a socket error is refined by its io kind, exactly as a `Send` error is
799            (
800                "io reset",
801                ParseError::Io(ErrorKind::ConnectionReset.into()),
802                Interrupted,
803            ),
804            (
805                "io timeout",
806                ParseError::Io(ErrorKind::TimedOut.into()),
807                Timeout,
808            ),
809            // a structurally invalid head: resending can't help
810            ("bad status", ParseError::Status, Decode),
811            ("oversized head", ParseError::TooLarge, Decode),
812        ];
813
814        for (label, parse_err, expected) in cases {
815            let kind = Error::classify_send_error(&SendRequestError::Response(parse_err));
816            assert_eq!(kind, expected, "{label}");
817        }
818    }
819}