Skip to content

Commit f7cba3e

Browse files
committed
🐛 fix(pypi): tell a stalled upload from a malformed one
Every multipart read error answered 400. That is the right family, since the bytes come from the client either way, but it merges two conditions whose correct next move differs. A malformed form repeated unchanged fails again. A stall says nothing about the form at all, so repeating the upload is exactly what the client should do, and 400 told it the opposite. A stalled body now answers 408 through the same classification the registry uses, so the two ecosystems cannot drift on what a stalled request body means.
1 parent 2fcc4ec commit f7cba3e

2 files changed

Lines changed: 50 additions & 4 deletions

File tree

crates/peryx-ecosystem-pypi/src/serving/upload_form.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use axum::extract::Multipart;
1+
use axum::extract::{Multipart, multipart};
2+
use peryx_driver::body::BodyFailure;
23
use axum::http::{StatusCode, header};
34
use axum::response::{IntoResponse, Response};
45
use blake2::Blake2bVar;
@@ -38,7 +39,7 @@ pub(super) async fn collect_form(
3839
let mut form = UploadForm::default();
3940
let mut staged = None;
4041
let mut budget = FormBudget::default();
41-
while let Some(field) = multipart.next_field().await.map_err(reject)? {
42+
while let Some(field) = multipart.next_field().await.map_err(|error| body_reject(&error))? {
4243
budget.add_part()?;
4344
let field_name = field.name().unwrap_or_default().to_owned();
4445
if field_name == "content" {
@@ -214,7 +215,7 @@ async fn read_text_field(
214215
budget: &mut FormBudget,
215216
) -> HttpResult<String> {
216217
let mut bytes = Vec::new();
217-
while let Some(chunk) = field.chunk().await.map_err(reject)? {
218+
while let Some(chunk) = field.chunk().await.map_err(|error| body_reject(&error))? {
218219
if bytes.len().saturating_add(chunk.len()) > limit {
219220
return Err((
220221
StatusCode::BAD_REQUEST,
@@ -230,7 +231,7 @@ async fn read_text_field(
230231
}
231232

232233
async fn drain_field(mut field: axum::extract::multipart::Field<'_>, budget: &mut FormBudget) -> HttpResult<()> {
233-
while let Some(chunk) = field.chunk().await.map_err(reject)? {
234+
while let Some(chunk) = field.chunk().await.map_err(|error| body_reject(&error))? {
234235
budget.add_text(chunk.len())?;
235236
}
236237
Ok(())
@@ -312,6 +313,24 @@ fn reject(err: impl std::fmt::Display) -> Response {
312313
(StatusCode::BAD_REQUEST, format!("bad upload: {err}")).into_response()
313314
}
314315

316+
/// A read of the multipart body that failed.
317+
///
318+
/// The bytes come from the client either way, so the only question is whether it stopped sending or
319+
/// sent something the server could not read. `BodyFailure` answers that once for every ecosystem
320+
/// instead of each guessing from the message, and the two answers differ for the client: a stall says
321+
/// nothing about the form, so repeating the upload is the right move, while a malformed form repeated
322+
/// unchanged fails again.
323+
fn body_reject(error: &multipart::MultipartError) -> Response {
324+
match BodyFailure::of(error) {
325+
BodyFailure::Stalled(after) => (
326+
StatusCode::REQUEST_TIMEOUT,
327+
format!("upload stopped: the request body sent nothing for {after:?}"),
328+
)
329+
.into_response(),
330+
BodyFailure::Interrupted => reject(error),
331+
}
332+
}
333+
315334
fn storage_reject(err: impl std::fmt::Display) -> Response {
316335
tracing::error!(error = %err, "upload staging failed");
317336
(

crates/peryx-ecosystem-pypi/tests/unit/tests/http/upload.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2190,3 +2190,30 @@ fn multipart_body_with_content_length(declared: u64, content: &[u8]) -> (String,
21902190
head.extend_from_slice(&body[header_end + 4..]);
21912191
(content_type, head)
21922192
}
2193+
2194+
/// A client that stops mid-form is told the server gave up waiting on a message that never arrived.
2195+
/// It is not told its form was malformed, which would be wrong about the form and would suggest
2196+
/// repeating the upload is pointless, and it is not told an upstream failed, since none serves a
2197+
/// request body.
2198+
#[tokio::test(start_paused = true)]
2199+
async fn test_a_stalled_upload_body_reports_a_timeout() {
2200+
let h = harness().await;
2201+
let (content_type, body) = multipart_body(&[], Some(("peryxpkg-1.0-py3-none-any.whl", b"ab")));
2202+
let opening = Bytes::copy_from_slice(&body[..body.len() / 2]);
2203+
let stalling = futures_util::StreamExt::chain(
2204+
futures_util::stream::once(async move { Ok::<_, Infallible>(opening) }),
2205+
futures_util::stream::once(std::future::pending::<Result<Bytes, Infallible>>()),
2206+
);
2207+
2208+
let (status, _) = post_upload_body_with_headers_response(
2209+
&h.state,
2210+
"/root/pypi/",
2211+
Some(&upload_auth()),
2212+
&content_type,
2213+
&[],
2214+
Body::from_stream(stalling),
2215+
)
2216+
.await;
2217+
2218+
assert_eq!(status, StatusCode::REQUEST_TIMEOUT);
2219+
}

0 commit comments

Comments
 (0)