@@ -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
2956pub 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}
0 commit comments