Skip to content

Commit 2fcc4ec

Browse files
committed
🐛 fix(oci): answer a failed request body as a client error
An upload whose body failed answered 502 with "upstream transfer failed". No upstream serves a request body: the bytes were coming from the client and peryx is the server. The stall bound #2182 added made it reachable with the client still connected, so a client that paused for thirty seconds was told something upstream of peryx had gone wrong. 502 also carries a meaning to intermediaries, a bad response from an upstream server, which invites a retry against a different backend for a condition no backend change reaches. A stall now answers 408, which says the server gave up waiting for a message that never arrived, and names the offset a resumable session stands at so the client knows where to continue. Anything else the body ends with answers 400, since repeating it unchanged fails the same way. Both come from the shared classification rather than from each call site reading an opaque error. Four tests asserted the old status. Their intent survives unchanged: the stall pair asserted a cut chunk must not read as accepted, which 408 satisfies, and the monolithic case was named for the gateway status it happened to produce rather than for a gateway being involved.
1 parent dc1ee34 commit 2fcc4ec

7 files changed

Lines changed: 98 additions & 64 deletions

File tree

crates/peryx-driver/src/body.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::io::{Read as _, Seek as _, SeekFrom};
2+
use std::time::Duration;
23

34
use axum::body::Body;
45
use bytes::Bytes;
@@ -91,3 +92,61 @@ pub fn pipelined_file(file: std::fs::File, offset: u64, length: u64) -> Body {
9192
rx.recv().await.map(|chunk| (chunk, rx))
9293
}))
9394
}
95+
96+
/// The error a stalled body ends with, worded for the client that stopped sending rather than for the
97+
/// handler that was reading.
98+
#[derive(Debug)]
99+
pub struct Stalled(Duration);
100+
101+
impl std::error::Error for Stalled {}
102+
103+
impl std::fmt::Display for Stalled {
104+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105+
write!(formatter, "the request body sent nothing for {:?}", self.0)
106+
}
107+
}
108+
109+
impl Stalled {
110+
#[must_use]
111+
pub const fn new(after: Duration) -> Self {
112+
Self(after)
113+
}
114+
}
115+
116+
/// Why reading a request body ended without the bytes the handler was waiting for.
117+
///
118+
/// The bytes of a request body come from the client, so no failure reading one is an upstream fault
119+
/// and none of them may answer `502`. What the client should do next still differs, so the edge that
120+
/// bounds the body is where the two are told apart: a handler holding an opaque body error cannot
121+
/// recover the distinction, and every handler that tried would derive it again.
122+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123+
pub enum BodyFailure {
124+
/// The client sent no frame for the bound. The request never completed, so it may be repeated,
125+
/// and a resumable session picks up at the offset its bytes reached.
126+
Stalled(Duration),
127+
/// The body stopped for some other reason: a dropped connection, or framing the server could not
128+
/// read. Repeating it unchanged fails the same way.
129+
Interrupted,
130+
}
131+
132+
impl BodyFailure {
133+
/// Classify the error a request-body stream ended with.
134+
///
135+
/// The stall arrives wrapped by whatever read the body, so the whole source chain is searched
136+
/// rather than the outermost error alone.
137+
#[must_use]
138+
pub fn of(error: &(dyn std::error::Error + 'static)) -> Self {
139+
let mut current = Some(error);
140+
while let Some(error) = current {
141+
if let Some(stalled) = error.downcast_ref::<Stalled>() {
142+
return Self::Stalled(stalled.0);
143+
}
144+
current = error.source();
145+
}
146+
Self::Interrupted
147+
}
148+
}
149+
150+
#[cfg(test)]
151+
#[path = "../tests/unit/body_failure_tests.rs"]
152+
mod body_failure_tests;

crates/peryx-http/tests/unit/request_stall_tests.rs renamed to crates/peryx-driver/tests/unit/body_failure_tests.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,19 @@ impl std::fmt::Display for Wrapper {
1919
}
2020
}
2121

22+
/// A handler never sees the stall itself: whatever read the body wraps it, so the chain is what the
23+
/// classification has to walk.
2224
#[test]
2325
fn test_a_stall_is_recognized_through_the_reader_that_wrapped_it() {
24-
let stalled = Wrapper(Box::new(Stalled(Duration::from_secs(30))));
26+
let stalled = Wrapper(Box::new(Stalled::new(Duration::from_secs(30))));
2527

2628
assert_eq!(BodyFailure::of(&stalled), BodyFailure::Stalled(Duration::from_secs(30)));
2729
}
2830

2931
#[test]
3032
fn test_a_bare_stall_is_recognized() {
3133
assert_eq!(
32-
BodyFailure::of(&Stalled(Duration::from_secs(5))),
34+
BodyFailure::of(&Stalled::new(Duration::from_secs(5))),
3335
BodyFailure::Stalled(Duration::from_secs(5))
3436
);
3537
}

crates/peryx-ecosystem-oci/src/registry/uploads.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ use super::blobs::{
33
publish_acknowledged, release_reservation, upload_epoch,
44
};
55
use super::*;
6-
use crate::error::{ErrorCode, error_response};
6+
use crate::error::{ErrorCode, error_response, error_response_with_status};
7+
use peryx_driver::body::BodyFailure;
78
use crate::registry::acknowledge::BlobAck;
89
use crate::registry::authority::{EpochCommit, commit_epoch};
910
use crate::store::{self};
@@ -485,7 +486,7 @@ async fn append_to_stage(
485486
let mut stream = body.into_data_stream();
486487
let limit = index.policy.max_artifact_size();
487488
while let Some(chunk) = stream.next().await {
488-
let chunk = chunk.map_err(|err| UploadBodyError::Fault(ServeError::Transport(err.to_string())))?;
489+
let chunk = chunk.map_err(|err| UploadBodyError::ClientBody(BodyFailure::of(&err), Some(*offset)))?;
489490
let size = *offset + chunk.len() as u64;
490491
if limit.is_some_and(|limit| size > limit) {
491492
return Err(UploadBodyError::Denied(
@@ -516,16 +517,39 @@ async fn append_to_stage(
516517
enum UploadBodyError {
517518
Fault(ServeError),
518519
Denied(Response),
520+
/// The client's bytes stopped arriving, carrying the offset a resumable session reached.
521+
ClientBody(BodyFailure, Option<u64>),
519522
/// The session's row went while the chunk was being written, so the offset this append reached
520523
/// describes an upload the registry no longer has.
521524
Vanished,
522525
}
523526

527+
/// Answer a request body that failed on the client's side.
528+
///
529+
/// Nothing upstream serves a request body, so `502` would name the wrong party and invite an
530+
/// intermediary to retry against a different backend for a condition no backend change reaches. A
531+
/// stall is the server giving up on a message that never arrived, which is what `408` says, and it
532+
/// leaves the client free to repeat the request or resume the session at the offset its bytes reached.
533+
fn client_body_response(failure: BodyFailure, resume: Option<u64>) -> Response {
534+
match failure {
535+
BodyFailure::Stalled(after) => {
536+
let resumable = resume.map_or_else(String::new, |offset| format!(", resumable from byte {offset}"));
537+
error_response_with_status(
538+
StatusCode::REQUEST_TIMEOUT,
539+
ErrorCode::BlobUploadInvalid,
540+
&format!("the request body sent nothing for {after:?}{resumable}"),
541+
)
542+
}
543+
BodyFailure::Interrupted => error_response(ErrorCode::BlobUploadInvalid, "the request body ended early"),
544+
}
545+
}
546+
524547
impl UploadBodyError {
525548
fn into_response(self) -> Result<Response, ServeError> {
526549
match self {
527550
Self::Fault(err) => Err(err),
528551
Self::Denied(response) => Ok(response),
552+
Self::ClientBody(failure, resume) => Ok(client_body_response(failure, resume)),
529553
Self::Vanished => Ok(error_response(ErrorCode::BlobUploadUnknown, "upload unknown")),
530554
}
531555
}
@@ -556,7 +580,7 @@ async fn append_body(
556580
let mut stream = body.into_data_stream();
557581
let limit = index.policy.max_artifact_size();
558582
while let Some(chunk) = stream.next().await {
559-
let chunk = chunk.map_err(|err| UploadBodyError::Fault(ServeError::Transport(err.to_string())))?;
583+
let chunk = chunk.map_err(|err| UploadBodyError::ClientBody(BodyFailure::of(&err), None))?;
560584
let size = *offset + chunk.len() as u64;
561585
if limit.is_some_and(|limit| size > limit) {
562586
return Err(UploadBodyError::Denied(

crates/peryx-ecosystem-oci/tests/unit/tests/push_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,7 +1478,7 @@ async fn test_session_finish_reports_an_admission_store_fault() {
14781478
}
14791479

14801480
#[tokio::test]
1481-
async fn test_monolithic_upload_body_read_error_is_a_gateway_error() {
1481+
async fn test_monolithic_upload_body_read_error_is_a_client_error() {
14821482
let dir = tempfile::tempdir().unwrap();
14831483
let (_state, app) = hosted_writable(&dir, TOKEN);
14841484
let erroring = futures_util::stream::iter(vec![
@@ -1492,7 +1492,7 @@ async fn test_monolithic_upload_body_read_error_is_a_gateway_error() {
14921492
.body(Body::from_stream(erroring))
14931493
.unwrap();
14941494
let response = app.clone().oneshot(request).await.unwrap();
1495-
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
1495+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14961496
let _ = response.into_body().collect().await;
14971497
}
14981498

@@ -2256,7 +2256,7 @@ async fn test_patch_body_read_error_keeps_session_resumable() {
22562256
.body(Body::from_stream(chunks))
22572257
.unwrap();
22582258
let response = app.clone().oneshot(request).await.unwrap();
2259-
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
2259+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
22602260

22612261
let (status, headers, _) = send_with(&app, Method::GET, &location, &[("authorization", &auth(TOKEN))]).await;
22622262
assert_eq!(status, StatusCode::NO_CONTENT);

crates/peryx-ecosystem-oci/tests/unit/tests/upload_stall_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ async fn test_a_stalled_chunk_leaves_a_session_the_client_can_resume() {
8484

8585
assert_eq!(
8686
stalled,
87-
StatusCode::BAD_GATEWAY,
88-
"a cut chunk must not read as accepted"
87+
StatusCode::REQUEST_TIMEOUT,
88+
"a client that stopped sending is told the server gave up waiting, not that an upstream failed"
8989
);
9090
assert_eq!(
9191
landed.map(|record| record.offset),
@@ -183,7 +183,7 @@ async fn test_a_stalled_chunk_delays_the_reclaim_pass_by_one_bound() {
183183
let reclaimed = reclaim(&state).await;
184184
let waited = started.elapsed();
185185

186-
assert_eq!(appending.await.unwrap(), StatusCode::BAD_GATEWAY);
186+
assert_eq!(appending.await.unwrap(), StatusCode::REQUEST_TIMEOUT);
187187
assert_eq!(reclaimed, 2, "the stalled session and the one queued behind it both go");
188188
assert!(
189189
(STALL_BOUND..STALL_BOUND * 2).contains(&waited),

crates/peryx-http/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
pub mod handlers;
44
mod request_stall;
5-
pub use request_stall::{BodyFailure, Stalled};
65
mod response_framing;
76
pub mod response_security;
87
pub mod router;

crates/peryx-http/src/request_stall.rs

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::time::Duration;
88

99
use http_body::{Body, Frame, SizeHint};
1010
use pin_project_lite::pin_project;
11+
use peryx_driver::body::Stalled;
1112
use tokio::time::Sleep;
1213

1314
pin_project! {
@@ -32,19 +33,6 @@ impl<B> StallBounded<B> {
3233
}
3334
}
3435

35-
/// The error a stalled body ends with, worded for the client that stopped sending rather than for the
36-
/// handler that was reading.
37-
#[derive(Debug)]
38-
pub struct Stalled(Duration);
39-
40-
impl std::error::Error for Stalled {}
41-
42-
impl std::fmt::Display for Stalled {
43-
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44-
write!(formatter, "the request body sent nothing for {:?}", self.0)
45-
}
46-
}
47-
4836
impl<B> Body for StallBounded<B>
4937
where
5038
B: Body,
@@ -60,7 +48,7 @@ where
6048
}
6149
let idle = this.idle.as_mut().as_pin_mut().expect("the wait was just armed");
6250
if idle.poll(cx).is_ready() {
63-
return Poll::Ready(Some(Err(Box::new(Stalled(*this.stall)))));
51+
return Poll::Ready(Some(Err(Box::new(Stalled::new(*this.stall)))));
6452
}
6553
let frame = ready!(this.body.poll_frame(cx));
6654
this.idle.set(None);
@@ -74,41 +62,3 @@ where
7462
self.body.size_hint()
7563
}
7664
}
77-
78-
/// Why reading a request body ended without the bytes the handler was waiting for.
79-
///
80-
/// The bytes of a request body come from the client, so no failure reading one is an upstream fault
81-
/// and none of them may answer `502`. What the client should do next still differs, so the edge that
82-
/// bounds the body is where the two are told apart: a handler holding an opaque body error cannot
83-
/// recover the distinction, and every handler that tried would derive it again.
84-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85-
pub enum BodyFailure {
86-
/// The client sent no frame for the bound. The request never completed, so it may be repeated,
87-
/// and a resumable session picks up at the offset its bytes reached.
88-
Stalled(Duration),
89-
/// The body stopped for some other reason: a dropped connection, or framing the server could not
90-
/// read. Repeating it unchanged fails the same way.
91-
Interrupted,
92-
}
93-
94-
impl BodyFailure {
95-
/// Classify the error a request-body stream ended with.
96-
///
97-
/// The stall arrives wrapped by whatever read the body, so the whole source chain is searched
98-
/// rather than the outermost error alone.
99-
#[must_use]
100-
pub fn of(error: &(dyn std::error::Error + 'static)) -> Self {
101-
let mut current = Some(error);
102-
while let Some(error) = current {
103-
if let Some(stalled) = error.downcast_ref::<Stalled>() {
104-
return Self::Stalled(stalled.0);
105-
}
106-
current = error.source();
107-
}
108-
Self::Interrupted
109-
}
110-
}
111-
112-
#[cfg(test)]
113-
#[path = "../tests/unit/request_stall_tests.rs"]
114-
mod request_stall_tests;

0 commit comments

Comments
 (0)