@@ -8,7 +8,7 @@ export type UploadError =
88 | { kind : 'presign-bad-status' ; status : number ; body : string }
99 | { kind : 'presign-bad-response' ; message : string }
1010 | { kind : 's3-put-failed' ; message : string }
11- | { kind : 's3-bad-status' ; status : number ; body : string } ;
11+ | { kind : 's3-bad-status' ; status : number ; body : string ; s3Code ?: string ; requestId ?: string } ;
1212
1313export interface UploadInput {
1414 readonly bytes : Uint8Array ;
@@ -31,6 +31,8 @@ export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
3131interface UploaderImplOptions {
3232 readonly endpoint ?: string ;
3333 readonly fetch ?: FetchFn ;
34+ readonly maxAttempts ?: number ;
35+ readonly retryMinTimeoutMs ?: number ;
3436}
3537
3638interface PresignResponse {
@@ -42,18 +44,37 @@ interface PresignResponse {
4244export class UploaderImpl implements Uploader {
4345 readonly #endpoint: string ;
4446 readonly #fetch: FetchFn ;
47+ readonly #maxAttempts: number ;
48+ readonly #retryMinTimeoutMs: number ;
4549
4650 constructor ( options : UploaderImplOptions = { } ) {
4751 this . #endpoint = options . endpoint ?? DEFAULT_UPLOAD_ENDPOINT ;
4852 this . #fetch = options . fetch ?? fetch ;
53+ this . #maxAttempts = options . maxAttempts ?? 4 ;
54+ this . #retryMinTimeoutMs = options . retryMinTimeoutMs ?? 500 ;
4955 }
5056
5157 upload ( input : UploadInput ) : ResultAsync < UploadResult , UploadError > {
5258 return this . #requestPresign( input ) . andThen ( ( presign ) =>
53- this . #putToS3 ( presign . presignedUrl , input . bytes ) . map ( ( ) => ( { uploadId : presign . uploadId } ) ) ,
59+ this . #putToS3WithRetry ( presign . presignedUrl , input . bytes ) . map ( ( ) => ( { uploadId : presign . uploadId } ) ) ,
5460 ) ;
5561 }
5662
63+ // The bytes are buffered in memory, so the PUT is safely replayable. Retry only
64+ // the transient S3 outcomes (RequestTimeout/SlowDown/5xx, network errors) —
65+ // see isRetryableUploadError; 4xx signature/permission failures fail fast.
66+ // Backoff is exponential from retryMinTimeoutMs; attempts cap at maxAttempts.
67+ #putToS3WithRetry( url : string , bytes : Uint8Array , attempt = 1 ) : ResultAsync < void , UploadError > {
68+ return this . #putToS3( url , bytes ) . orElse ( ( err ) => {
69+ if ( attempt >= this . #maxAttempts || ! isRetryableUploadError ( err ) ) {
70+ return errAsync < void , UploadError > ( err ) ;
71+ }
72+ return delay ( this . #retryMinTimeoutMs * 2 ** ( attempt - 1 ) ) . andThen ( ( ) =>
73+ this . #putToS3WithRetry( url , bytes , attempt + 1 ) ,
74+ ) ;
75+ } ) ;
76+ }
77+
5778 #requestPresign( input : UploadInput ) : ResultAsync < PresignResponse , UploadError > {
5879 const body = JSON . stringify ( {
5980 owner : input . owner ,
@@ -95,7 +116,7 @@ export class UploaderImpl implements Uploader {
95116 ) . andThen ( ( res ) => {
96117 if ( ! res . ok ) {
97118 return ResultAsync . fromSafePromise ( res . text ( ) . catch ( ( ) => '' ) ) . andThen ( ( text ) =>
98- errAsync < void , UploadError > ( { kind : 's3-bad-status' , status : res . status , body : text } ) ,
119+ errAsync < void , UploadError > ( { kind : 's3-bad-status' , status : res . status , body : text , ... parseS3Error ( text ) } ) ,
99120 ) ;
100121 }
101122 return okAsync < void , UploadError > ( undefined ) ;
@@ -126,6 +147,48 @@ function isObject(value: unknown): value is Record<string, unknown> {
126147 return typeof value === 'object' && value !== null && ! Array . isArray ( value ) ;
127148}
128149
150+ const delay = ( ms : number ) : ResultAsync < void , never > =>
151+ ResultAsync . fromSafePromise ( new Promise < void > ( ( resolve ) => setTimeout ( resolve , ms ) ) ) ;
152+
153+ function isRetryableUploadError ( err : UploadError ) : boolean {
154+ switch ( err . kind ) {
155+ case 's3-put-failed' :
156+ return true ;
157+ case 's3-bad-status' :
158+ return err . status === 429 || err . status >= 500 || err . s3Code === 'RequestTimeout' ;
159+ default :
160+ return false ;
161+ }
162+ }
163+
164+ // S3 errors are XML; we pull the machine-readable <Code> and <RequestId> (cause + AWS
165+ // support handle) but drop the body, which echoes the owner/email object key.
166+ function parseS3Error ( body : string ) : { s3Code ?: string ; requestId ?: string } {
167+ const s3Code = body . match ( / < C o d e > ( [ ^ < ] + ) < \/ C o d e > / ) ?. [ 1 ] ;
168+ const requestId = body . match ( / < R e q u e s t I d > ( [ ^ < ] + ) < \/ R e q u e s t I d > / ) ?. [ 1 ] ;
169+ return { ...( s3Code ? { s3Code } : { } ) , ...( requestId ? { requestId } : { } ) } ;
170+ }
171+
172+ // Privacy-safe telemetry: omits the raw S3 body and presigned URL (both embed the
173+ // owner/email object key). Network error messages are safe to include.
174+ export function uploadErrorTelemetry ( err : UploadError ) : Record < string , string | number > {
175+ switch ( err . kind ) {
176+ case 'presign-request-failed' :
177+ case 'presign-bad-response' :
178+ case 's3-put-failed' :
179+ return { error_kind : err . kind , error_message : err . message . slice ( 0 , 300 ) } ;
180+ case 'presign-bad-status' :
181+ return { error_kind : err . kind , status : err . status } ;
182+ case 's3-bad-status' :
183+ return {
184+ error_kind : err . kind ,
185+ status : err . status ,
186+ ...( err . s3Code ? { s3_code : err . s3Code } : { } ) ,
187+ ...( err . requestId ? { request_id : err . requestId } : { } ) ,
188+ } ;
189+ }
190+ }
191+
129192export function formatUploadError ( err : UploadError ) : string {
130193 switch ( err . kind ) {
131194 case 'presign-request-failed' :
@@ -137,6 +200,6 @@ export function formatUploadError(err: UploadError): string {
137200 case 's3-put-failed' :
138201 return `failed to upload to S3: ${ err . message } ` ;
139202 case 's3-bad-status' :
140- return `S3 returned ${ err . status } : ${ err . body || '(empty body)' } ` ;
203+ return `S3 returned ${ err . status } ${ err . s3Code ? ` ( ${ err . s3Code } )` : '' } : ${ err . body || '(empty body)' } ` ;
141204 }
142205}
0 commit comments