Skip to content

Commit babd0a7

Browse files
committed
fix(deploy): harden S3 upload with retries, size check, and header strip (DEVEX-713)
1 parent 6abd2ee commit babd0a7

2 files changed

Lines changed: 292 additions & 23 deletions

File tree

rust/src/application/client.rs

Lines changed: 281 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,33 @@ pub enum ClientError {
2424
Network(#[from] reqwest::Error),
2525
#[error("GraphQL errors: {0}")]
2626
GraphQL(String),
27+
#[error("file size {size} bytes exceeds maximum allowed ({max} bytes)")]
28+
TooLarge { size: u64, max: u64 },
29+
}
30+
31+
/// Result of a successful artifact upload.
32+
#[derive(Debug, Clone)]
33+
pub struct UploadResult {
34+
pub upload_id: String,
35+
pub etag: Option<String>,
36+
pub status: u16,
37+
pub size_bytes: u64,
38+
}
39+
40+
/// Retry/backoff tuning for [`ApplicationClient::upload_artifact`].
41+
#[derive(Debug, Clone)]
42+
pub struct UploadOptions {
43+
pub max_retries: u32,
44+
pub base_delay_ms: u64,
45+
}
46+
47+
impl Default for UploadOptions {
48+
fn default() -> Self {
49+
Self {
50+
max_retries: 3,
51+
base_delay_ms: 250,
52+
}
53+
}
2754
}
2855

2956
pub struct ApplicationClient {
@@ -188,36 +215,126 @@ impl ApplicationClient {
188215
.await
189216
}
190217

218+
/// Upload an artifact to a presigned S3 URL.
219+
///
220+
/// Validates the size up front, strips the unsigned `x-amz-meta-upload-id`
221+
/// header (it is not part of the S3 SigV4 signing string, so sending it can
222+
/// break the PUT), and retries transient failures — network errors and 5xx —
223+
/// with exponential backoff. 4xx responses are returned immediately.
191224
pub async fn upload_artifact(
192225
&self,
193226
url: &str,
227+
upload_id: &str,
194228
headers: &Value,
229+
max_size_bytes: Option<u64>,
195230
bytes: Vec<u8>,
196-
) -> Result<(), ClientError> {
197-
let mut req = self.client.put(url).body(bytes);
198-
if let Some(obj) = headers.as_object() {
199-
for (k, v) in obj {
200-
if let Some(s) = v.as_str() {
201-
req = req.header(k.as_str(), s);
202-
}
203-
}
231+
opts: UploadOptions,
232+
) -> Result<UploadResult, ClientError> {
233+
let size_bytes = bytes.len() as u64;
234+
if let Some(max) = max_size_bytes
235+
&& size_bytes > max
236+
{
237+
return Err(ClientError::TooLarge {
238+
size: size_bytes,
239+
max,
240+
});
204241
}
205-
let request = req.build()?;
206-
cli_engine::transport::debug_log_reqwest_request(&request);
207-
let resp = self.client.execute(request).await?;
208242

209-
let status = resp.status();
210-
let resp_headers = resp.headers().clone();
211-
let body = resp.bytes().await?;
212-
cli_engine::transport::debug_log_reqwest_response(status, &resp_headers, &body);
243+
let mut signed_headers = headers.as_object().cloned().unwrap_or_default();
244+
signed_headers.remove("x-amz-meta-upload-id");
213245

214-
if !status.is_success() {
215-
return Err(ClientError::Http {
216-
status: status.as_u16(),
217-
body: String::from_utf8_lossy(&body).into_owned(),
218-
});
246+
let header_map: reqwest::header::HeaderMap = signed_headers
247+
.iter()
248+
.filter_map(|(k, v)| {
249+
let name = reqwest::header::HeaderName::from_bytes(k.as_bytes()).ok()?;
250+
let value = reqwest::header::HeaderValue::from_str(v.as_str()?).ok()?;
251+
Some((name, value))
252+
})
253+
.collect();
254+
255+
let mut last_error: Option<ClientError> = None;
256+
257+
for attempt in 1..=opts.max_retries {
258+
let request = self
259+
.client
260+
.put(url)
261+
.body(bytes.clone())
262+
.headers(header_map.clone())
263+
.build()?;
264+
cli_engine::transport::debug_log_reqwest_request(&request);
265+
266+
match self.client.execute(request).await {
267+
Ok(resp) => {
268+
let status = resp.status();
269+
let resp_headers = resp.headers().clone();
270+
let etag = resp_headers
271+
.get(reqwest::header::ETAG)
272+
.and_then(|v| v.to_str().ok())
273+
.map(|s| s.to_owned());
274+
let body = resp.bytes().await?;
275+
cli_engine::transport::debug_log_reqwest_response(status, &resp_headers, &body);
276+
277+
if status.is_success() {
278+
let result = UploadResult {
279+
upload_id: upload_id.to_owned(),
280+
etag,
281+
status: status.as_u16(),
282+
size_bytes,
283+
};
284+
tracing::debug!(
285+
upload_id = %result.upload_id,
286+
status = result.status,
287+
etag = ?result.etag,
288+
size_bytes = result.size_bytes,
289+
attempt,
290+
"artifact upload succeeded"
291+
);
292+
return Ok(result);
293+
}
294+
295+
let snippet: String =
296+
String::from_utf8_lossy(&body).chars().take(200).collect();
297+
if !status.is_server_error() {
298+
return Err(ClientError::Http {
299+
status: status.as_u16(),
300+
body: snippet,
301+
});
302+
}
303+
tracing::warn!(
304+
%upload_id,
305+
status = status.as_u16(),
306+
attempt,
307+
max_retries = opts.max_retries,
308+
error_snippet = %snippet,
309+
"artifact upload failed with server error, retrying"
310+
);
311+
last_error = Some(ClientError::Http {
312+
status: status.as_u16(),
313+
body: snippet,
314+
});
315+
}
316+
Err(e) => {
317+
tracing::warn!(
318+
%upload_id,
319+
attempt,
320+
max_retries = opts.max_retries,
321+
error = %e,
322+
"artifact upload failed with network error, retrying"
323+
);
324+
last_error = Some(ClientError::Network(e));
325+
}
326+
}
327+
328+
if attempt < opts.max_retries {
329+
let delay = opts.base_delay_ms * 3u64.pow(attempt - 1);
330+
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
331+
}
219332
}
220-
Ok(())
333+
334+
Err(last_error.unwrap_or_else(|| ClientError::Http {
335+
status: 0,
336+
body: format!("upload failed after {} attempts", opts.max_retries),
337+
}))
221338
}
222339
}
223340

@@ -348,4 +465,147 @@ mod tests {
348465
mock.assert_async().await;
349466
assert_eq!(data["updateApplication"]["status"], "ACTIVE");
350467
}
468+
469+
// httpmock can't sequence responses, so retries are verified by hit count
470+
// (exhaustion) rather than a fail-then-succeed sequence.
471+
472+
#[tokio::test]
473+
async fn upload_rejects_oversize_file_without_uploading() {
474+
let server = MockServer::start_async().await;
475+
let mock = server
476+
.mock_async(|when, then| {
477+
when.method(PUT).path("/upload");
478+
then.status(200);
479+
})
480+
.await;
481+
482+
let err = ApplicationClient::new(server.base_url(), "test-token")
483+
.upload_artifact(
484+
&server.url("/upload"),
485+
"up-1",
486+
&json!({}),
487+
Some(4), // max 4 bytes
488+
b"way too big".to_vec(),
489+
UploadOptions {
490+
max_retries: 3,
491+
base_delay_ms: 0,
492+
},
493+
)
494+
.await
495+
.expect_err("oversize should fail before uploading");
496+
497+
assert!(
498+
matches!(err, ClientError::TooLarge { .. }),
499+
"unexpected: {err}"
500+
);
501+
assert_eq!(mock.hits_async().await, 0);
502+
}
503+
504+
#[tokio::test]
505+
async fn upload_does_not_retry_on_4xx() {
506+
let server = MockServer::start_async().await;
507+
let mock = server
508+
.mock_async(|when, then| {
509+
when.method(PUT).path("/upload");
510+
then.status(403).body("denied");
511+
})
512+
.await;
513+
514+
let err = ApplicationClient::new(server.base_url(), "test-token")
515+
.upload_artifact(
516+
&server.url("/upload"),
517+
"up-1",
518+
&json!({}),
519+
None,
520+
b"data".to_vec(),
521+
UploadOptions {
522+
max_retries: 3,
523+
base_delay_ms: 0,
524+
},
525+
)
526+
.await
527+
.expect_err("4xx should fail immediately");
528+
529+
assert!(
530+
matches!(err, ClientError::Http { status: 403, .. }),
531+
"unexpected: {err}"
532+
);
533+
assert_eq!(mock.hits_async().await, 1);
534+
}
535+
536+
#[tokio::test]
537+
async fn upload_retries_on_5xx_until_exhausted() {
538+
let server = MockServer::start_async().await;
539+
let mock = server
540+
.mock_async(|when, then| {
541+
when.method(PUT).path("/upload");
542+
then.status(503).body("try later");
543+
})
544+
.await;
545+
546+
let err = ApplicationClient::new(server.base_url(), "test-token")
547+
.upload_artifact(
548+
&server.url("/upload"),
549+
"up-1",
550+
&json!({}),
551+
None,
552+
b"data".to_vec(),
553+
UploadOptions {
554+
max_retries: 3,
555+
base_delay_ms: 0,
556+
},
557+
)
558+
.await
559+
.expect_err("exhausted retries should fail");
560+
561+
assert!(
562+
matches!(err, ClientError::Http { status: 503, .. }),
563+
"unexpected: {err}"
564+
);
565+
assert_eq!(mock.hits_async().await, 3);
566+
}
567+
568+
#[tokio::test]
569+
async fn upload_strips_meta_upload_id_and_returns_metadata() {
570+
let server = MockServer::start_async().await;
571+
let mock = server
572+
.mock_async(|when, then| {
573+
when.method(PUT)
574+
.path("/upload")
575+
.header("x-amz-signature", "sig")
576+
// assert x-amz-meta-upload-id was stripped
577+
.matches(|req| {
578+
!req.headers
579+
.iter()
580+
.flatten()
581+
.any(|(k, _)| k.eq_ignore_ascii_case("x-amz-meta-upload-id"))
582+
});
583+
then.status(200).header("etag", "\"abc123\"");
584+
})
585+
.await;
586+
587+
let result = ApplicationClient::new(server.base_url(), "test-token")
588+
.upload_artifact(
589+
&server.url("/upload"),
590+
"up-42",
591+
&json!({
592+
"x-amz-signature": "sig",
593+
"x-amz-meta-upload-id": "should-be-stripped",
594+
}),
595+
None,
596+
b"hello".to_vec(),
597+
UploadOptions {
598+
max_retries: 3,
599+
base_delay_ms: 0,
600+
},
601+
)
602+
.await
603+
.expect("upload should succeed");
604+
605+
mock.assert_async().await;
606+
assert_eq!(result.upload_id, "up-42");
607+
assert_eq!(result.status, 200);
608+
assert_eq!(result.size_bytes, 5);
609+
assert_eq!(result.etag.as_deref(), Some("\"abc123\""));
610+
}
351611
}

rust/src/application/commands/mod.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use cli_engine::{
44
};
55
use serde_json::json;
66

7-
use crate::application::client::{ApplicationClient, api_url_for_env};
7+
use crate::application::client::{ApplicationClient, UploadOptions, api_url_for_env};
88
use crate::next_action::next_action;
99
use crate::output_schema::output_schema;
1010
// App-registry mutations declare their scopes so `apps.app-registry:write` is
@@ -1078,6 +1078,8 @@ async fn deploy_extension(
10781078

10791079
let upload = &upload_data["generateReleaseUploadUrl"];
10801080
let upload_url = upload["url"].as_str().unwrap_or("").to_owned();
1081+
let upload_id = upload["uploadId"].as_str().unwrap_or("").to_owned();
1082+
let max_size_bytes = upload["maxSizeBytes"].as_u64();
10811083

10821084
// Parse required headers from ["key:value"] array
10831085
let mut headers = serde_json::Map::new();
@@ -1092,7 +1094,14 @@ async fn deploy_extension(
10921094
}
10931095

10941096
client
1095-
.upload_artifact(&upload_url, &json!(headers), bytes)
1097+
.upload_artifact(
1098+
&upload_url,
1099+
&upload_id,
1100+
&json!(headers),
1101+
max_size_bytes,
1102+
bytes,
1103+
UploadOptions::default(),
1104+
)
10961105
.await
10971106
.map_err(client_err)?;
10981107

0 commit comments

Comments
 (0)