Skip to content

Commit 520a3b8

Browse files
authored
feat: add configurable retryable HTTP statuses (#7)
1 parent 68725ab commit 520a3b8

6 files changed

Lines changed: 217 additions & 16 deletions

File tree

README.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Configure an `http.Client` to use rcpx as its transport. For each request, rcpx
99
* Tries upstreams sequentially, in priority order.
1010
* Tries each eligible upstream at most once per request.
1111
* By default, continues to the next upstream on any transport error, or on HTTP status `429`, `502`, `503`, `504`.
12-
* Treats HTTP statuses other than `429`, `502`, `503`, `504` as success and returns them unchanged, even if non-2xx.
12+
* By default, treats HTTP statuses other than `429`, `502`, `503`, `504` as success and returns them unchanged, even if non-2xx.
1313
* Does not inspect JSON-RPC response bodies; HTTP `200` with a JSON-RPC error body is returned unchanged.
1414
* If the request context is canceled or deadline exceeded, returns immediately and does not consult the retry policy.
1515
* Buffers the request body once per request so it can resend it across upstreams, capped by `BodyBufferBytes`.
@@ -211,6 +211,7 @@ Default behavior summary:
211211
| Setting | Default |
212212
|---|---|
213213
| Retryable HTTP statuses | `429`, `502`, `503`, `504` |
214+
| Additional retryable HTTP statuses | None |
214215
| Body buffer cap | `rcpx.DefaultBodyBufferBytes` (`1 MiB`) |
215216
| Cooldown | Enabled |
216217
| Cooldown threshold | `rcpx.DefaultCooldownFailAfterConsecutive` (`3`) consecutive failover-causing failures |
@@ -223,7 +224,7 @@ Default behavior summary:
223224
* Upstreams are tried sequentially, in priority order.
224225
* Each eligible upstream is tried at most once per request.
225226
* By default, rcpx continues to the next upstream on any transport error, or on HTTP status `429`, `502`, `503`, `504`.
226-
* An attempt succeeds when `err == nil` and the status code is not `429`, `502`, `503`, or `504`.
227+
* An attempt succeeds when `err == nil` and the status code is not retryable.
227228
* Other HTTP status codes are treated as success from rcpx's perspective and returned unchanged.
228229
* JSON-RPC response bodies are not inspected. A JSON-RPC error returned with HTTP `200` is returned unchanged.
229230
* If the request context is canceled or deadline exceeded, rcpx returns immediately and does not consult the retry policy.
@@ -274,6 +275,21 @@ rcpx handles misbehaving base transports defensively:
274275
* It will not return `(nil, nil)` from `RoundTrip`; if that happens, rcpx returns an error.
275276
* If a base transport returns both `resp != nil` and `err != nil`, rcpx closes `resp.Body` and treats it as an error to avoid leaks.
276277

278+
### Additional retryable HTTP statuses
279+
280+
```go
281+
type Config struct {
282+
AdditionalRetryableStatusCodes []int
283+
// ...
284+
}
285+
```
286+
287+
`AdditionalRetryableStatusCodes` adds status codes to the built-in retryable set: `429`, `502`, `503`, and `504`.
288+
289+
For example, `[]int{500}` makes HTTP `500` retryable in addition to the defaults. Duplicates are ignored, and values must be three-digit HTTP status codes.
290+
291+
Retryable status classification happens before `RetryPolicy` is called.
292+
277293
### Retry policy
278294

279295
```go
@@ -289,9 +305,9 @@ You can override the default retry or failover behavior with `Config.RetryPolicy
289305
* JSON-RPC info (best-effort): `Method`, `Batch`
290306
* `StatusCode` (0 if no HTTP response was obtained)
291307
* `Err` (the error from the base transport, if any)
292-
* `RetryableByDefault` (rcpx's default classification for this outcome)
308+
* `RetryableByDefault` (whether rcpx classifies the outcome as retryable)
293309

294-
`RetryableByDefault` is true for transport errors (`Err != nil`) and for HTTP status `429`, `502`, `503`, `504`.
310+
`RetryableByDefault` is true for transport errors (`Err != nil`), for HTTP status `429`, `502`, `503`, `504`, and for statuses configured with `AdditionalRetryableStatusCodes`.
295311

296312
The retry policy is only consulted after rcpx has classified an attempt as non-success and there is another eligible upstream to try.
297313

@@ -301,7 +317,7 @@ The retry policy is not called:
301317
* for the last eligible upstream;
302318
* when the request context is canceled or its deadline is exceeded.
303319

304-
Because HTTP statuses other than `429`, `502`, `503`, and `504` are treated as success from rcpx's perspective, `RetryPolicy` cannot make additional HTTP status codes, such as `500`, retryable under the current design.
320+
`RetryPolicy` cannot make a non-retryable HTTP status retryable by itself, because the policy is only called after rcpx has already classified an attempt as non-success. To make an additional HTTP status such as `500` retryable, configure `AdditionalRetryableStatusCodes`.
305321

306322
`RetryPolicy` receives `AttemptOutcome`; it cannot inspect response bodies or response headers. It can decide whether rcpx should continue after an already-classified non-success attempt, but it is not a general response validation hook.
307323

config.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ type Config struct {
3333

3434
// Retry/failover policy hook. If nil, the default policy is used.
3535
RetryPolicy RetryPolicy
36+
37+
// AdditionalRetryableStatusCodes are HTTP status codes retried in addition to
38+
// the defaults: 429, 502, 503, and 504.
39+
AdditionalRetryableStatusCodes []int
3640
}
3741

3842
// CooldownConfig configures cooldown behavior.
@@ -68,6 +72,8 @@ type resolvedConfig struct {
6872
allowNI bool
6973
bodyCap int
7074
policy RetryPolicy
75+
76+
retryableStatuses map[int]struct{}
7177
}
7278

7379
func resolveConfig(cfg Config) (resolvedConfig, error) {
@@ -120,13 +126,20 @@ func resolveConfig(cfg Config) (resolvedConfig, error) {
120126
policy = defaultRetryPolicy
121127
}
122128

129+
statuses, err := resolveRetryableStatusCodes(cfg.AdditionalRetryableStatusCodes)
130+
if err != nil {
131+
return resolvedConfig{}, err
132+
}
133+
123134
return resolvedConfig{
124135
upstreams: upstreams,
125136
base: base,
126137
cooldown: cooldown,
127138
allowNI: cfg.AllowNonIdempotent,
128139
bodyCap: bodyCap,
129140
policy: policy,
141+
142+
retryableStatuses: statuses,
130143
}, nil
131144
}
132145

@@ -166,3 +179,25 @@ func resolveCooldown(cc *CooldownConfig) (effectiveCooldown, error) {
166179
duration: dur,
167180
}, nil
168181
}
182+
183+
func resolveRetryableStatusCodes(additional []int) (map[int]struct{}, error) {
184+
statuses := map[int]struct{}{
185+
429: {},
186+
502: {},
187+
503: {},
188+
504: {},
189+
}
190+
191+
for i, code := range additional {
192+
if !validHTTPStatusCode(code) {
193+
return nil, fmt.Errorf("rcpx: invalid AdditionalRetryableStatusCodes[%d] %d", i, code)
194+
}
195+
statuses[code] = struct{}{}
196+
}
197+
198+
return statuses, nil
199+
}
200+
201+
func validHTTPStatusCode(code int) bool {
202+
return code >= 100 && code <= 999
203+
}

config_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,50 @@ func TestNewRoundTripper_validation(t *testing.T) {
8484
}
8585
})
8686
})
87+
88+
t.Run("invalid additional retryable status codes", func(t *testing.T) {
89+
tests := []struct {
90+
name string
91+
code int
92+
}{
93+
{name: "negative", code: -1},
94+
{name: "zero", code: 0},
95+
{name: "below three digits", code: 99},
96+
{name: "above three digits", code: 1000},
97+
}
98+
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
_, err := NewRoundTripper(Config{
102+
Upstreams: []string{upstream},
103+
AdditionalRetryableStatusCodes: []int{tt.code},
104+
})
105+
if err == nil {
106+
t.Fatalf("expected error for status code %d, got nil", tt.code)
107+
}
108+
})
109+
}
110+
})
111+
112+
t.Run("valid additional retryable status code is accepted", func(t *testing.T) {
113+
_, err := NewRoundTripper(Config{
114+
Upstreams: []string{upstream},
115+
AdditionalRetryableStatusCodes: []int{500},
116+
})
117+
if err != nil {
118+
t.Fatalf("expected valid status code to be accepted, got %v", err)
119+
}
120+
})
121+
122+
t.Run("duplicate additional retryable status codes are accepted", func(t *testing.T) {
123+
_, err := NewRoundTripper(Config{
124+
Upstreams: []string{upstream},
125+
AdditionalRetryableStatusCodes: []int{500, 500},
126+
})
127+
if err != nil {
128+
t.Fatalf("expected duplicate status codes to be accepted, got %v", err)
129+
}
130+
})
87131
}
88132

89133
func TestNewRoundTripper_defaults(t *testing.T) {

policy.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ type AttemptOutcome struct {
2525
StatusCode int
2626
Err error
2727

28-
RetryableByDefault bool // rcpx's default classification for this outcome
28+
RetryableByDefault bool // whether rcpx classifies this outcome as retryable
2929
}
3030

3131
func defaultRetryPolicy(out AttemptOutcome) bool {
3232
return out.RetryableByDefault
3333
}
3434

35-
func isRetryableStatus(code int) bool {
35+
func isBuiltInRetryableStatus(code int) bool {
3636
switch code {
3737
case 429, 502, 503, 504:
3838
return true
@@ -41,27 +41,36 @@ func isRetryableStatus(code int) bool {
4141
}
4242
}
4343

44-
func isAttemptSuccess(statusCode int, err error) bool {
45-
return err == nil && !isRetryableStatus(statusCode)
44+
func (cfg resolvedConfig) isRetryableStatus(code int) bool {
45+
if cfg.retryableStatuses == nil {
46+
return isBuiltInRetryableStatus(code)
47+
}
48+
49+
_, ok := cfg.retryableStatuses[code]
50+
return ok
51+
}
52+
53+
func (cfg resolvedConfig) isAttemptSuccess(statusCode int, err error) bool {
54+
return err == nil && !cfg.isRetryableStatus(statusCode)
4655
}
4756

48-
func defaultRetryableByOutcome(statusCode int, err error) bool {
57+
func (cfg resolvedConfig) retryableByOutcome(statusCode int, err error) bool {
4958
if err != nil {
5059
return true
5160
}
52-
return isRetryableStatus(statusCode)
61+
return cfg.isRetryableStatus(statusCode)
5362
}
5463

5564
// statusCode should be 0 when no HTTP response was obtained.
56-
func buildAttemptOutcome(attempt int, upstream, method string, batch bool, statusCode int, err error) AttemptOutcome {
65+
func (cfg resolvedConfig) buildAttemptOutcome(attempt int, upstream, method string, batch bool, statusCode int, err error) AttemptOutcome {
5766
return AttemptOutcome{
5867
Attempt: attempt,
5968
Upstream: upstream,
6069
Method: method,
6170
Batch: batch,
6271
StatusCode: statusCode,
6372
Err: err,
64-
RetryableByDefault: defaultRetryableByOutcome(statusCode, err),
73+
RetryableByDefault: cfg.retryableByOutcome(statusCode, err),
6574
}
6675
}
6776

transport.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
146146
status = resp.StatusCode
147147
}
148148

149-
// Success = err==nil and status is not one of retryable statuses.
149+
// Success = err==nil and status is not retryable.
150150
// Non-retryable HTTP statuses are treated as "success" from rcpx's
151151
// perspective and returned unchanged.
152-
if isAttemptSuccess(status, rerr) {
152+
if t.cfg.isAttemptSuccess(status, rerr) {
153153
if t.cooldown != nil {
154154
t.cooldown.recordSuccess(idx)
155155
}
@@ -162,7 +162,7 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
162162
cause = &httpStatusError{code: status, upstream: up.raw}
163163
}
164164

165-
out := buildAttemptOutcome(attemptNo, up.raw, method, batch, status, rerr)
165+
out := t.cfg.buildAttemptOutcome(attemptNo, up.raw, method, batch, status, rerr)
166166

167167
hasNext := pos < len(eligible)-1
168168
continueToNext := false

transport_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,103 @@ func TestRoundTrip_HTTP500_IsTreatedAsSuccess_NoFailover(t *testing.T) {
4747
assertCalls(t, base, u1)
4848
}
4949

50+
func TestRoundTrip_AdditionalRetryableStatus_FailsOver(t *testing.T) {
51+
u1 := "https://u1.test/rpc"
52+
u2 := "https://u2.test/rpc"
53+
54+
respBody := newTrackingBody("internal error")
55+
base := &scriptRT{
56+
results: map[string][]rtResult{
57+
u1: {
58+
{resp: &http.Response{StatusCode: 500, Body: respBody}, err: nil},
59+
},
60+
u2: {
61+
{resp: httpResp(200, "ok"), err: nil},
62+
},
63+
},
64+
}
65+
66+
rt := mustNewTransport(t, Config{
67+
Upstreams: []string{u1, u2},
68+
Base: base,
69+
AdditionalRetryableStatusCodes: []int{500},
70+
})
71+
72+
req := newRPCRequest(t, u1, "eth_blockNumber")
73+
resp := mustRoundTrip(t, rt, req)
74+
assertStatus(t, resp, 200)
75+
if !respBody.Closed() {
76+
t.Fatalf("expected 500 response body to be closed before failover")
77+
}
78+
assertCalls(t, base, u1, u2)
79+
}
80+
81+
func TestRoundTrip_AdditionalRetryableStatus_DoesNotReplaceDefaults(t *testing.T) {
82+
u1 := "https://u1.test/rpc"
83+
u2 := "https://u2.test/rpc"
84+
85+
respBody := newTrackingBody("service unavailable")
86+
base := &scriptRT{
87+
results: map[string][]rtResult{
88+
u1: {
89+
{resp: &http.Response{StatusCode: 503, Body: respBody}, err: nil},
90+
},
91+
u2: {
92+
{resp: httpResp(200, "ok"), err: nil},
93+
},
94+
},
95+
}
96+
97+
rt := mustNewTransport(t, Config{
98+
Upstreams: []string{u1, u2},
99+
Base: base,
100+
AdditionalRetryableStatusCodes: []int{500},
101+
})
102+
103+
req := newRPCRequest(t, u1, "eth_blockNumber")
104+
resp := mustRoundTrip(t, rt, req)
105+
assertStatus(t, resp, 200)
106+
if !respBody.Closed() {
107+
t.Fatalf("expected 503 response body to be closed before failover")
108+
}
109+
assertCalls(t, base, u1, u2)
110+
}
111+
112+
func TestRoundTrip_NonConfiguredStatus_IsTreatedAsSuccess_NoFailover(t *testing.T) {
113+
u1 := "https://u1.test/rpc"
114+
u2 := "https://u2.test/rpc"
115+
116+
respBody := newTrackingBody("not implemented")
117+
base := &scriptRT{
118+
results: map[string][]rtResult{
119+
u1: {
120+
{resp: &http.Response{StatusCode: 501, Body: respBody}, err: nil},
121+
},
122+
},
123+
}
124+
125+
rt := mustNewTransport(t, Config{
126+
Upstreams: []string{u1, u2},
127+
Base: base,
128+
AdditionalRetryableStatusCodes: []int{500},
129+
})
130+
131+
req := newRPCRequest(t, u1, "eth_blockNumber")
132+
resp, err := rt.RoundTrip(req)
133+
if err != nil {
134+
t.Fatalf("RoundTrip returned error: %v", err)
135+
}
136+
t.Cleanup(func() { resp.Body.Close() })
137+
138+
assertStatus(t, resp, 501)
139+
140+
if respBody.Closed() {
141+
t.Fatalf("expected returned response body to remain open")
142+
}
143+
144+
assertCalls(t, base, u1)
145+
}
146+
50147
func TestRoundTrip_HTTP200_WithJSONRPCErrorPayload_IsTreatedAsSuccess_NoFailover(t *testing.T) {
51148
u1 := "https://u1.test/rpc"
52149
u2 := "https://u2.test/rpc"

0 commit comments

Comments
 (0)