Skip to content

Commit c55facd

Browse files
authored
transport: retry HTTP 429 (Too Many Requests) (#2301)
Fixes #2111. As filed by knqyf263, 429 responses are never retried by the default retry transport even though TooManyRequestsErrorCode is already classified temporary in transport/error.go. The retry transport in pkg/v1/remote/transport/retry.go short-circuits on its codes list before consulting Temporary(); since 429 isn't in defaultRetryStatusCodes, the conversion to a temporary transport.Error never happens and retry.IsTemporary never gets called. The fix is symmetric across the two parallel classifications: * defaultRetryStatusCodes in pkg/v1/remote/options.go now includes http.StatusTooManyRequests, so the retry transport wraps a 429 response into a temporary *transport.Error and retries it through the existing backoff (Duration 1s, Factor 3, Steps 3). * temporaryStatusCodes in pkg/v1/remote/transport/error.go also gains http.StatusTooManyRequests, bringing the raw-status fallback path into alignment with temporaryErrorCodes which has already been classifying TooManyRequestsErrorCode as temporary. The exhausted *transport.Error still carries StatusCode == 429, so pkg/gcrane/copy.go's hasStatusCode helper continues to route 429s through GCRBackoff at the application layer. The two retry layers compose: ~1.3s of transport-level retry per request, then ~6s+ of copy-level retry per image. No existing test expectations change. Out of scope here: honoring the Retry-After response header. The retry backoff is currently static; honoring server-dictated delays would need plumbing through the retry/wait package. Worth a follow-up. Tests: * pkg/v1/remote/transport/retry_test.go gains TestRetryTransport_TooManyRequests, which exercises three 429s through the retry transport and asserts both that all three attempts are made and that the final response surfaces with StatusCode == 429 (so gcrane's hasStatusCode keeps matching). It also pins the new entry in temporaryStatusCodes. * pkg/v1/remote/options_test.go (new file) pins 429 in the default retry status code list. A future refactor that drops 429 will fail loudly before the regression ships. Verified locally: go test ./... (full suite green) gofmt + goimports clean golangci-lint v2.11: 0 issues woke clean on touched files reviewdog/action-misspell US: clean on the diff
1 parent 68a569e commit c55facd

4 files changed

Lines changed: 88 additions & 0 deletions

File tree

pkg/v1/remote/options.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ var fastBackoff = Backoff{
9393

9494
var defaultRetryStatusCodes = []int{
9595
http.StatusRequestTimeout,
96+
http.StatusTooManyRequests, // 429: OCI distribution-spec rate limit; TooManyRequestsErrorCode is already classified temporary in transport/error.go
9697
http.StatusInternalServerError,
9798
http.StatusBadGateway,
9899
http.StatusServiceUnavailable,

pkg/v1/remote/options_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright 2018 Google LLC All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package remote
16+
17+
import (
18+
"net/http"
19+
"testing"
20+
)
21+
22+
// TestDefaultRetryStatusCodes_Includes429 pins HTTP 429 (Too Many
23+
// Requests) in the default retry status code list. The retry transport
24+
// short-circuits on this list before consulting Temporary(), so 429 has
25+
// to live here even though TooManyRequestsErrorCode is already classified
26+
// temporary in transport/error.go. If a future refactor drops 429 this
27+
// test fails loudly before the regression ships.
28+
func TestDefaultRetryStatusCodes_Includes429(t *testing.T) {
29+
for _, c := range defaultRetryStatusCodes {
30+
if c == http.StatusTooManyRequests {
31+
return
32+
}
33+
}
34+
t.Fatal("defaultRetryStatusCodes should include http.StatusTooManyRequests so registries returning 429 are retried by the default transport")
35+
}

pkg/v1/remote/transport/error.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ var temporaryErrorCodes = map[ErrorCode]struct{}{
151151

152152
var temporaryStatusCodes = map[int]struct{}{
153153
http.StatusRequestTimeout: {},
154+
http.StatusTooManyRequests: {}, // matches TooManyRequestsErrorCode in temporaryErrorCodes
154155
http.StatusInternalServerError: {},
155156
http.StatusBadGateway: {},
156157
http.StatusServiceUnavailable: {},

pkg/v1/remote/transport/retry_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,57 @@ func TestRetryDefaults(t *testing.T) {
142142
}
143143
}
144144

145+
// TestRetryTransport_TooManyRequests covers two invariants that consumers
146+
// (including pkg/gcrane/copy.go's outer retry loop) depend on once 429 is
147+
// in the default retry list:
148+
//
149+
// 1. A 429 response is wrapped into a temporary *Error and retried up to
150+
// the configured Steps.
151+
// 2. After retries exhaust, the *Error surfaced to the caller still has
152+
// StatusCode == 429 so callers like gcrane's hasStatusCode helper can
153+
// route their own application-level backoff (GCRBackoff) on top.
154+
func TestRetryTransport_TooManyRequests(t *testing.T) {
155+
mt := mockTransport{
156+
resps: []*http.Response{
157+
resp(http.StatusTooManyRequests),
158+
resp(http.StatusTooManyRequests),
159+
resp(http.StatusTooManyRequests),
160+
},
161+
}
162+
163+
tr := NewRetry(&mt,
164+
WithRetryBackoff(retry.Backoff{Steps: 3}),
165+
WithRetryPredicate(retry.IsTemporary),
166+
WithRetryStatusCodes(http.StatusTooManyRequests),
167+
)
168+
169+
req, err := http.NewRequestWithContext(context.Background(), "GET", "example.com", nil)
170+
if err != nil {
171+
t.Fatal(err)
172+
}
173+
out, _ := tr.RoundTrip(req)
174+
175+
if mt.count != 3 {
176+
t.Errorf("expected 3 attempts (1 + 2 retries), got %d", mt.count)
177+
}
178+
if out == nil || out.StatusCode != http.StatusTooManyRequests {
179+
t.Errorf("final response should still surface 429 status; got %v", out)
180+
}
181+
}
182+
183+
// TestTemporaryStatusCodes_Includes429 keeps temporaryStatusCodes (the
184+
// raw-status fallback path) in sync with temporaryErrorCodes (the
185+
// parsed-body path), which already contains TooManyRequestsErrorCode.
186+
// A registry returning 429 with no structured body should still be
187+
// classified temporary so downstream consumers retrying on
188+
// transport.Error.Temporary() behave consistently with consumers that
189+
// retry on a TOOMANYREQUESTS error code in the body.
190+
func TestTemporaryStatusCodes_Includes429(t *testing.T) {
191+
if _, ok := temporaryStatusCodes[http.StatusTooManyRequests]; !ok {
192+
t.Fatal("temporaryStatusCodes should contain http.StatusTooManyRequests for parity with temporaryErrorCodes[TooManyRequestsErrorCode]")
193+
}
194+
}
195+
145196
func TestTimeoutContext(t *testing.T) {
146197
tr := NewRetry(http.DefaultTransport)
147198

0 commit comments

Comments
 (0)