Skip to content

Commit 53a83ff

Browse files
committed
fix: preserve response body in retryError for structured error messages
The retryError function was consuming the HTTP response body without restoring it for subsequent readers. This caused structured registry error messages to be lost during retries, falling back to generic HTTP status messages instead. This change buffers the response body and creates a new readable stream so that both retryError and subsequent error handling can access the response content. Before: - Without retry: MANIFEST_UNKNOWN: manifest unknown; unknown tag=v1.0.0 - With retry: unexpected status code 404 Not Found After: - Both cases: MANIFEST_UNKNOWN: manifest unknown; unknown tag=v1.0.0 Fixes #2125
1 parent 59a4b85 commit 53a83ff

File tree

3 files changed

+134
-0
lines changed

3 files changed

+134
-0
lines changed

pkg/v1/remote/error_roundtrip_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,52 @@ func TestBlobStatusCodeReturned(t *testing.T) {
125125
t.Errorf("Incorrect status code received, got %v, wanted %v", terr.StatusCode, http.StatusTeapot)
126126
}
127127
}
128+
129+
func TestRetryPreservesStructuredErrors(t *testing.T) {
130+
// Test that structured registry errors are preserved when retries are enabled
131+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132+
if r.URL.Path == "/v2/" {
133+
return
134+
}
135+
// Return a structured registry error
136+
w.Header().Set("Content-Type", "application/json")
137+
w.WriteHeader(http.StatusNotFound)
138+
w.Write([]byte(`{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown","detail":"unknown tag=v1.0.0"}]}`))
139+
})
140+
141+
server := httptest.NewServer(handler)
142+
defer server.Close()
143+
144+
ref, err := name.NewTag(strings.TrimPrefix(server.URL+"/test:v1.0.0", "http://"))
145+
if err != nil {
146+
t.Fatalf("Unable to parse tag: %v", err)
147+
}
148+
149+
// Test without retry - should get structured error
150+
_, err = remote.Image(ref)
151+
if err == nil {
152+
t.Fatal("Expected error, got nil")
153+
}
154+
withoutRetryMsg := err.Error()
155+
156+
// Test with retry - should still get structured error (not generic 404)
157+
_, err = remote.Image(ref, remote.WithRetryStatusCodes(http.StatusNotFound))
158+
if err == nil {
159+
t.Fatal("Expected error, got nil")
160+
}
161+
withRetryMsg := err.Error()
162+
163+
// Both should contain the structured error message
164+
expectedSubstring := "MANIFEST_UNKNOWN: manifest unknown; unknown tag=v1.0.0"
165+
if !strings.Contains(withoutRetryMsg, expectedSubstring) {
166+
t.Errorf("Without retry error %q should contain %q", withoutRetryMsg, expectedSubstring)
167+
}
168+
if !strings.Contains(withRetryMsg, expectedSubstring) {
169+
t.Errorf("With retry error %q should contain %q", withRetryMsg, expectedSubstring)
170+
}
171+
172+
// The retry case should NOT contain generic "unexpected status code 404"
173+
if strings.Contains(withRetryMsg, "unexpected status code 404 Not Found") {
174+
t.Errorf("With retry error should not contain generic 404 message, got: %q", withRetryMsg)
175+
}
176+
}

pkg/v1/remote/transport/error.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package transport
1616

1717
import (
18+
"bytes"
1819
"encoding/json"
1920
"fmt"
2021
"io"
@@ -189,6 +190,8 @@ func retryError(resp *http.Response) error {
189190
if err != nil {
190191
return err
191192
}
193+
_ = resp.Body.Close()
194+
resp.Body = io.NopCloser(bytes.NewReader(b))
192195

193196
rerr := makeError(resp, b)
194197
rerr.temporary = true

pkg/v1/remote/transport/error_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,85 @@ func (e *errReadCloser) Read(_ []byte) (int, error) {
234234
func (e *errReadCloser) Close() error {
235235
return e.err
236236
}
237+
238+
func TestRetryError(t *testing.T) {
239+
tests := []struct {
240+
name string
241+
status int
242+
body string
243+
wantError string
244+
}{
245+
{
246+
name: "returned error",
247+
status: http.StatusInternalServerError,
248+
body: "boom",
249+
wantError: "unexpected status code 500 Internal Server Error: boom",
250+
},
251+
{
252+
name: "empty body",
253+
status: http.StatusInternalServerError,
254+
body: "",
255+
wantError: "unexpected status code 500 Internal Server Error",
256+
},
257+
}
258+
259+
for _, test := range tests {
260+
t.Run(test.name, func(t *testing.T) {
261+
resp := &http.Response{
262+
StatusCode: test.status,
263+
Body: io.NopCloser(bytes.NewBufferString(test.body)),
264+
}
265+
266+
// Call retryError
267+
err := retryError(resp)
268+
if err == nil {
269+
t.Fatal("retryError() = nil, wanted error")
270+
}
271+
272+
// Verify the error is marked as temporary
273+
var terr *Error
274+
if !errors.As(err, &terr) {
275+
t.Fatalf("retryError() = %T, wanted *Error", err)
276+
}
277+
if !terr.Temporary() {
278+
t.Error("retryError() should return temporary error")
279+
}
280+
281+
// Verify error message
282+
if terr.Error() != test.wantError {
283+
t.Errorf("retryError().Error() = %q, want %q", terr.Error(), test.wantError)
284+
}
285+
286+
// Verify the response body can still be read
287+
body, err := io.ReadAll(resp.Body)
288+
if err != nil {
289+
t.Fatalf("Failed to read response body after retryError: %v", err)
290+
}
291+
if string(body) != test.body {
292+
t.Errorf("Response body after retryError = %q, want %q", string(body), test.body)
293+
}
294+
295+
// Verify we can read it again (body should be rewindable)
296+
body2, err := io.ReadAll(resp.Body)
297+
if err != nil {
298+
t.Fatalf("Failed to read response body second time: %v", err)
299+
}
300+
if string(body2) != "" {
301+
t.Errorf("Second read should be empty, got %q", string(body2))
302+
}
303+
})
304+
}
305+
}
306+
307+
func TestRetryErrorReadError(t *testing.T) {
308+
expectedErr := errors.New("read error")
309+
resp := &http.Response{
310+
StatusCode: http.StatusInternalServerError,
311+
Body: &errReadCloser{expectedErr},
312+
}
313+
314+
err := retryError(resp)
315+
if !errors.Is(err, expectedErr) {
316+
t.Errorf("retryError() = %v, want %v", err, expectedErr)
317+
}
318+
}

0 commit comments

Comments
 (0)