@@ -15,6 +15,7 @@ import (
1515 "net/http"
1616 "os"
1717 "slices"
18+ "strings"
1819 "sync"
1920 "time"
2021
@@ -55,6 +56,7 @@ func createRetryableClient(ctx context.Context, config RetryConfig) *retryableht
5556 client .RetryMax = config .MaxAttempts - 1 // go-retryablehttp counts retries, not total attempts
5657 client .RetryWaitMin = config .InitialBackoff
5758 client .RetryWaitMax = config .MaxBackoff
59+ client .CheckRetry = retryOnCompleteError
5860
5961 // Custom backoff function with full jitter to avoid thundering herd
6062 client .Backoff = func (start , maxBackoff time.Duration , attemptNum int , _ * http.Response ) time.Duration {
@@ -95,6 +97,40 @@ func createRetryableClient(ctx context.Context, config RetryConfig) *retryableht
9597 return client
9698}
9799
100+ // retryOnCompleteError extends the default retry policy so a transient
101+ // CompleteMultipartUpload failure is retried like a 5xx. S3 and the GCS XML
102+ // API can return HTTP 200 with an <Error> body (e.g. InternalError) for a
103+ // commit that didn't happen; the default policy only looks at the status code,
104+ // so without this such a response fails on the first attempt and ignores the
105+ // configured retry budget. Only the complete request (URL query "uploadId=…",
106+ // small XML body) is inspected, so buffering the body to peek + restore it is
107+ // cheap.
108+ func retryOnCompleteError (ctx context.Context , resp * http.Response , err error ) (bool , error ) {
109+ if retry , rerr := retryablehttp .DefaultRetryPolicy (ctx , resp , err ); retry || rerr != nil {
110+ return retry , rerr
111+ }
112+
113+ if resp == nil || resp .StatusCode != http .StatusOK || resp .Request == nil ||
114+ ! strings .HasPrefix (resp .Request .URL .RawQuery , "uploadId=" ) || resp .Body == nil {
115+ return false , nil
116+ }
117+
118+ body , readErr := io .ReadAll (resp .Body )
119+ resp .Body .Close ()
120+ // Restore the body so the caller (or a retry) can read it.
121+ resp .Body = io .NopCloser (bytes .NewReader (body ))
122+
123+ // Retry when the body couldn't be fully read (transient truncation/reset
124+ // after the headers) or when it carries a transient <Error> (e.g.
125+ // InternalError). Crucially, a read failure must not fall through as
126+ // no-retry: that would hand completeUpload a clean, buffered partial body
127+ // and let it commit on a truncated response.
128+ var apiErr completeMultipartError
129+ retry := readErr != nil || (xml .Unmarshal (body , & apiErr ) == nil && apiErr .Code != "" )
130+
131+ return retry , nil //nolint:nilerr // decision is carried by the bool; returning readErr would abort the retry loop instead of retrying
132+ }
133+
98134// zapLogger adapts zap.Logger to retryablehttp.LeveledLogger interface
99135var _ retryablehttp.LeveledLogger = & leveledLogger {}
100136
@@ -134,6 +170,15 @@ type Part struct {
134170 ETag string `xml:"ETag"`
135171}
136172
173+ // completeMultipartError matches the S3/GCS XML error payload that can arrive
174+ // with an HTTP 200 status on CompleteMultipartUpload. Unmarshal only succeeds
175+ // (Code populated) when the response root is <Error>.
176+ type completeMultipartError struct {
177+ XMLName xml.Name `xml:"Error"`
178+ Code string `xml:"Code"`
179+ Message string `xml:"Message"`
180+ }
181+
137182type MultipartUploader struct {
138183 bucketName string
139184 objectName string
@@ -257,13 +302,20 @@ func (m *MultipartUploader) uploadPart(ctx context.Context, uploadID string, par
257302 url := fmt .Sprintf ("%s/%s?partNumber=%d&uploadId=%s" ,
258303 m .baseURL , m .objectName , partNumber , uploadID )
259304
260- req , err := retryablehttp .NewRequestWithContext (ctx , "PUT" , url , bytes .NewReader (data ))
305+ // A non-nil zero-length body counts as "unknown length" to net/http and
306+ // is sent chunked, which S3-compatible XML backends reject with 411. Pass
307+ // no body for empty parts so Content-Length: 0 is sent instead.
308+ var body any
309+ if len (data ) > 0 {
310+ body = bytes .NewReader (data )
311+ }
312+
313+ req , err := retryablehttp .NewRequestWithContext (ctx , "PUT" , url , body )
261314 if err != nil {
262315 return "" , err
263316 }
264317
265318 req .Header .Set ("Authorization" , "Bearer " + m .token )
266- req .Header .Set ("Content-Length" , fmt .Sprintf ("%d" , len (data )))
267319 sum := md5 .Sum (data ) //nolint:gosec // GCS multipart uses Content-MD5 for transport integrity.
268320 req .Header .Set ("Content-MD5" , base64 .StdEncoding .EncodeToString (sum [:]))
269321
@@ -297,18 +349,26 @@ func (m *MultipartUploader) uploadPartSlices(ctx context.Context, uploadID strin
297349 url := fmt .Sprintf ("%s/%s?partNumber=%d&uploadId=%s" ,
298350 m .baseURL , m .objectName , partNumber , uploadID )
299351
300- // Use a ReaderFunc so the retryable client can replay the body on retries
301- bodyFn := func () (io.Reader , error ) {
302- return newMultiSliceReader (slices ), nil
352+ // Use a ReaderFunc so the retryable client can replay the body on
353+ // retries. The multiSliceReader's Len makes retryablehttp set the
354+ // request's ContentLength, so parts are sent with an explicit
355+ // Content-Length rather than chunked transfer encoding (which GCS
356+ // tolerates but S3-compatible XML backends reject with 411). Empty parts
357+ // send no body at all for the same reason: a zero-length reader still
358+ // counts as "unknown length" to net/http and would be chunked.
359+ var body any
360+ if totalLen > 0 {
361+ body = retryablehttp .ReaderFunc (func () (io.Reader , error ) {
362+ return newMultiSliceReader (slices ), nil
363+ })
303364 }
304365
305- req , err := retryablehttp .NewRequestWithContext (ctx , "PUT" , url , retryablehttp . ReaderFunc ( bodyFn ) )
366+ req , err := retryablehttp .NewRequestWithContext (ctx , "PUT" , url , body )
306367 if err != nil {
307368 return "" , err
308369 }
309370
310371 req .Header .Set ("Authorization" , "Bearer " + m .token )
311- req .Header .Set ("Content-Length" , fmt .Sprintf ("%d" , totalLen ))
312372 h := md5 .New () //nolint:gosec // GCS multipart uses Content-MD5 for transport integrity.
313373 for _ , s := range slices {
314374 _ , _ = h .Write (s )
@@ -365,12 +425,24 @@ func (m *MultipartUploader) completeUpload(ctx context.Context, uploadID string,
365425 }
366426 defer resp .Body .Close ()
367427
368- if resp .StatusCode != http .StatusOK {
369- body , _ := io .ReadAll (resp .Body )
428+ body , readErr := io .ReadAll (resp .Body )
429+ if readErr != nil {
430+ return fmt .Errorf ("read complete upload response (status %d): %w" , resp .StatusCode , readErr )
431+ }
370432
433+ if resp .StatusCode != http .StatusOK {
371434 return fmt .Errorf ("failed to complete upload (status %d): %s" , resp .StatusCode , string (body ))
372435 }
373436
437+ // S3 and the GCS XML API can return HTTP 200 with an <Error> body when the
438+ // commit fails server-side (documented for internal errors/timeouts).
439+ // Treating that as success would record a frame table for an object that
440+ // was never committed, so reject any response whose root is an Error.
441+ var apiErr completeMultipartError
442+ if xml .Unmarshal (body , & apiErr ) == nil && apiErr .Code != "" {
443+ return fmt .Errorf ("failed to complete upload (status %d, code %s): %s" , resp .StatusCode , apiErr .Code , apiErr .Message )
444+ }
445+
374446 return nil
375447}
376448
0 commit comments