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}