diff --git a/caddytest/integration/forwardauth_test.go b/caddytest/integration/forwardauth_test.go index d0ecc2be153..513c8090682 100644 --- a/caddytest/integration/forwardauth_test.go +++ b/caddytest/integration/forwardauth_test.go @@ -190,7 +190,7 @@ func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) { // its own values. The backend must receive the auth service values. req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) req.Header.Set("Authorization", "Bearer token123") - req.Header.Set("X-User-Id", "forged-id") // must be overwritten + req.Header.Set("X-User-Id", "forged-id") // must be overwritten req.Header.Set("X-User-Role", "forged-role") // must be overwritten tester.AssertResponse(req, http.StatusOK, "ok") diff --git a/modules/caddyhttp/matchers.go b/modules/caddyhttp/matchers.go index 27e5c5ae694..97ade5fda8e 100644 --- a/modules/caddyhttp/matchers.go +++ b/modules/caddyhttp/matchers.go @@ -1554,6 +1554,15 @@ func (mre *MatchRegexp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { // ParseCaddyfileNestedMatcherSet parses the Caddyfile tokens for a nested // matcher set, and returns its raw module map value. func ParseCaddyfileNestedMatcherSet(d *caddyfile.Dispenser) (caddy.ModuleMap, error) { + return ParseCaddyfileNestedMatcherSetWithFilter(d, nil) +} + +// ParseCaddyfileNestedMatcherSetWithFilter is like ParseCaddyfileNestedMatcherSet +// but accepts an optional filter function. For each directive name encountered in +// the block, the filter is called first. If it returns true, the directive was +// handled by the filter and is not treated as a matcher. If it returns false, +// the directive is treated as a request matcher as usual. +func ParseCaddyfileNestedMatcherSetWithFilter(d *caddyfile.Dispenser, filter func(name string, d *caddyfile.Dispenser) (bool, error)) (caddy.ModuleMap, error) { matcherMap := make(map[string]any) // in case there are multiple instances of the same matcher, concatenate @@ -1562,8 +1571,17 @@ func ParseCaddyfileNestedMatcherSet(d *caddyfile.Dispenser) (caddy.ModuleMap, er // instances of the matcher in this set tokensByMatcherName := make(map[string][]caddyfile.Token) for nesting := d.Nesting(); d.NextArg() || d.NextBlock(nesting); { - matcherName := d.Val() - tokensByMatcherName[matcherName] = append(tokensByMatcherName[matcherName], d.NextSegment()...) + name := d.Val() + if filter != nil { + handled, err := filter(name, d) + if err != nil { + return nil, err + } + if handled { + continue + } + } + tokensByMatcherName[name] = append(tokensByMatcherName[name], d.NextSegment()...) } for matcherName, tokens := range tokensByMatcherName { diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index 7b0b052da66..fda324f87f5 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -67,7 +67,10 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) // lb_retries // lb_try_duration // lb_try_interval -// lb_retry_match +// lb_retry_match { +// +// status +// } // // # active health checking // health_uri @@ -323,14 +326,20 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { h.LoadBalancing.TryInterval = caddy.Duration(dur) case "lb_retry_match": - matcherSet, err := caddyhttp.ParseCaddyfileNestedMatcherSet(d) - if err != nil { - return d.Errf("failed to parse lb_retry_match: %v", err) - } if h.LoadBalancing == nil { h.LoadBalancing = new(LoadBalancing) } - h.LoadBalancing.RetryMatchRaw = append(h.LoadBalancing.RetryMatchRaw, matcherSet) + condSet, matcherSet, err := parseRetryMatchBlock(d) + if err != nil { + return err + } + if condSet == nil { + condSet = new(RetryConditionSet) + } + if matcherSet != nil { + condSet.MatchRaw = matcherSet + } + h.LoadBalancing.RetryConditionsRaw = append(h.LoadBalancing.RetryConditionsRaw, condSet) case "health_uri": if !d.NextArg() { @@ -1686,6 +1695,46 @@ func (u *MultiUpstreams) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return nil } +// parseRetryMatchBlock parses the lb_retry_match block, which may contain +// standard request matchers alongside "status" directives. +// It returns a RetryConditionSet (if status is present) and/or +// a matcher set (from request matchers). If only matchers are present, the +// condition set is nil (backward compatible with retry_match). +func parseRetryMatchBlock(d *caddyfile.Dispenser) (*RetryConditionSet, caddy.ModuleMap, error) { + var condSet RetryConditionSet + var hasConditions bool + + matcherSet, err := caddyhttp.ParseCaddyfileNestedMatcherSetWithFilter(d, func(name string, d *caddyfile.Dispenser) (bool, error) { + if name != "status" { + return false, nil + } + args := d.RemainingArgs() + if len(args) == 0 { + return false, d.ArgErr() + } + for _, arg := range args { + if len(arg) == 3 && strings.HasSuffix(arg, "xx") { + arg = arg[:1] + } + code, err := strconv.Atoi(arg) + if err != nil { + return false, d.Errf("bad status value '%s': %v", arg, err) + } + condSet.Status = append(condSet.Status, code) + } + hasConditions = true + return true, nil + }) + if err != nil { + return nil, nil, err + } + + if hasConditions { + return &condSet, matcherSet, nil + } + return nil, matcherSet, nil +} + const matcherPrefix = "@" // Interface guards diff --git a/modules/caddyhttp/reverseproxy/httptransport_test.go b/modules/caddyhttp/reverseproxy/httptransport_test.go index 88ac9d5916d..55ca3fd33c8 100644 --- a/modules/caddyhttp/reverseproxy/httptransport_test.go +++ b/modules/caddyhttp/reverseproxy/httptransport_test.go @@ -129,11 +129,11 @@ func TestHTTPTransport_DialTLSContext_ProxyProtocol(t *testing.T) { defer cancel() tests := []struct { - name string - tls *TLSConfig - proxyProtocol string + name string + tls *TLSConfig + proxyProtocol string serverNameHasPlaceholder bool - expectDialTLSContext bool + expectDialTLSContext bool }{ { name: "no TLS, no proxy protocol", @@ -194,4 +194,3 @@ func TestHTTPTransport_DialTLSContext_ProxyProtocol(t *testing.T) { }) } } - diff --git a/modules/caddyhttp/reverseproxy/retries_test.go b/modules/caddyhttp/reverseproxy/retries_test.go index 056223d4c40..7b0bb8e59ac 100644 --- a/modules/caddyhttp/reverseproxy/retries_test.go +++ b/modules/caddyhttp/reverseproxy/retries_test.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/http/httptest" + "strconv" "strings" "sync" "testing" @@ -13,6 +14,7 @@ import ( "go.uber.org/zap" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) @@ -255,3 +257,314 @@ func TestDialErrorBodyRetry(t *testing.T) { }) } } + +func TestRetryOnStatusCode(t *testing.T) { + // Good upstream: returns 200 + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + t.Cleanup(goodServer.Close) + + // Bad upstream: returns 503 + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("unavailable")) + })) + t.Cleanup(badServer.Close) + + tests := []struct { + name string + retryOn []string + method string + wantStatus int + wantBody string + }{ + { + name: "503 retried to good upstream", + retryOn: []string{"503"}, + method: http.MethodGet, + wantStatus: http.StatusOK, + wantBody: "ok", + }, + { + name: "5xx class retried to good upstream", + retryOn: []string{"5"}, + method: http.MethodGet, + wantStatus: http.StatusOK, + wantBody: "ok", + }, + { + name: "no retry_on configured passes 503 through", + retryOn: nil, + method: http.MethodGet, + wantStatus: http.StatusServiceUnavailable, + wantBody: "unavailable", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + upstreams := []*Upstream{ + {Host: new(Host), Dial: goodServer.Listener.Addr().String()}, + {Host: new(Host), Dial: badServer.Listener.Addr().String()}, + } + + h := minimalHandler(1, upstreams...) + if tc.retryOn != nil { + rc := &retryCondition{} + for _, s := range tc.retryOn { + code, _ := strconv.Atoi(s) + rc.statusCodes = append(rc.statusCodes, code) + } + h.LoadBalancing.retryConditions = []*retryCondition{rc} + } + + req := httptest.NewRequest(tc.method, "http://example.com/", nil) + req = prepareTestRequest(req) + + rec := httptest.NewRecorder() + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + gotStatus := rec.Code + if err != nil { + if herr, ok := err.(caddyhttp.HandlerError); ok { + gotStatus = herr.StatusCode + } + } + + if gotStatus != tc.wantStatus { + t.Errorf("status: got %d, want %d (err=%v)", gotStatus, tc.wantStatus, err) + } + if tc.wantBody != "" && rec.Body.String() != tc.wantBody { + t.Errorf("body: got %q, want %q", rec.Body.String(), tc.wantBody) + } + }) + } +} + +func TestRetryOnStatusCodeWithMatchers(t *testing.T) { + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + t.Cleanup(goodServer.Close) + + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("unavailable")) + })) + t.Cleanup(badServer.Close) + + tests := []struct { + name string + matchers caddyhttp.MatcherSet + method string + wantStatus int + wantBody string + }{ + { + name: "matching method retried", + matchers: caddyhttp.MatcherSet{caddyhttp.MatchMethod{"POST"}}, + method: http.MethodPost, + wantStatus: http.StatusOK, + wantBody: "ok", + }, + { + name: "non-matching method not retried", + matchers: caddyhttp.MatcherSet{caddyhttp.MatchMethod{"POST"}}, + method: http.MethodPut, + wantStatus: http.StatusServiceUnavailable, + wantBody: "unavailable", + }, + { + name: "no matchers retries any method", + matchers: nil, + method: http.MethodPut, + wantStatus: http.StatusOK, + wantBody: "ok", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + upstreams := []*Upstream{ + {Host: new(Host), Dial: goodServer.Listener.Addr().String()}, + {Host: new(Host), Dial: badServer.Listener.Addr().String()}, + } + + h := minimalHandler(1, upstreams...) + h.LoadBalancing.retryConditions = []*retryCondition{ + {statusCodes: []int{503}, matchers: tc.matchers}, + } + + req := httptest.NewRequest(tc.method, "http://example.com/", nil) + req = prepareTestRequest(req) + + rec := httptest.NewRecorder() + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + gotStatus := rec.Code + if err != nil { + if herr, ok := err.(caddyhttp.HandlerError); ok { + gotStatus = herr.StatusCode + } + } + + if gotStatus != tc.wantStatus { + t.Errorf("status: got %d, want %d (err=%v)", gotStatus, tc.wantStatus, err) + } + if tc.wantBody != "" && rec.Body.String() != tc.wantBody { + t.Errorf("body: got %q, want %q", rec.Body.String(), tc.wantBody) + } + }) + } +} + +func TestLbRetryMatchCaddyfileParsing(t *testing.T) { + tests := []struct { + name string + input string + wantStatus []int + wantErr bool + }{ + { + name: "status codes", + input: `reverse_proxy localhost:8080 { + lb_retry_match { + status 503 502 + } + }`, + wantStatus: []int{503, 502}, + }, + { + name: "5xx converted to class code", + input: `reverse_proxy localhost:8080 { + lb_retry_match { + status 5xx 4xx + } + }`, + wantStatus: []int{5, 4}, + }, + { + name: "status with no args rejected", + input: `reverse_proxy localhost:8080 { + lb_retry_match { + status + } + }`, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := caddyfile.NewTestDispenser(tc.input) + h := new(Handler) + err := h.UnmarshalCaddyfile(d) + + if tc.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if h.LoadBalancing == nil { + t.Fatal("LoadBalancing is nil") + } + + if len(h.LoadBalancing.RetryConditionsRaw) != 1 { + t.Fatalf("RetryConditionsRaw length: got %d, want 1", len(h.LoadBalancing.RetryConditionsRaw)) + } + rc := h.LoadBalancing.RetryConditionsRaw[0] + + gotStatus := rc.Status + if len(gotStatus) != len(tc.wantStatus) { + t.Fatalf("Status length: got %d, want %d (%v vs %v)", len(gotStatus), len(tc.wantStatus), gotStatus, tc.wantStatus) + } + for i := range tc.wantStatus { + if gotStatus[i] != tc.wantStatus[i] { + t.Errorf("Status[%d]: got %d, want %d", i, gotStatus[i], tc.wantStatus[i]) + } + } + + }) + } +} + +func TestLbRetryMatchBackwardCompatibility(t *testing.T) { + // Plain lb_retry_match blocks (without status) should go into + // RetryConditionsRaw with matchers but no status codes, preserving + // the pre-existing behavior. + t.Run("plain matcher block goes into RetryConditionsRaw", func(t *testing.T) { + d := caddyfile.NewTestDispenser(`reverse_proxy localhost:8080 { + lb_retry_match { + method POST + } + }`) + h := new(Handler) + if err := h.UnmarshalCaddyfile(d); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if h.LoadBalancing == nil { + t.Fatal("LoadBalancing is nil") + } + if len(h.LoadBalancing.RetryMatchRaw) != 0 { + t.Errorf("RetryMatchRaw should be empty, got %d entries", len(h.LoadBalancing.RetryMatchRaw)) + } + if len(h.LoadBalancing.RetryConditionsRaw) != 1 { + t.Fatalf("RetryConditionsRaw length: got %d, want 1", len(h.LoadBalancing.RetryConditionsRaw)) + } + rc := h.LoadBalancing.RetryConditionsRaw[0] + if len(rc.Status) != 0 { + t.Errorf("Status should be empty, got %v", rc.Status) + } + if len(rc.MatchRaw) == 0 { + t.Fatal("MatchRaw should contain the method matcher") + } + if _, ok := rc.MatchRaw["method"]; !ok { + t.Errorf("MatchRaw should have 'method' key, got keys: %v", rc.MatchRaw) + } + }) + + // Multiple blocks: plain + status, all go into RetryConditionsRaw + t.Run("mixed blocks all go into RetryConditionsRaw", func(t *testing.T) { + d := caddyfile.NewTestDispenser(`reverse_proxy localhost:8080 { + lb_retry_match { + method PUT + } + lb_retry_match { + status 503 + } + }`) + h := new(Handler) + if err := h.UnmarshalCaddyfile(d); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(h.LoadBalancing.RetryMatchRaw) != 0 { + t.Errorf("RetryMatchRaw should be empty, got %d entries", len(h.LoadBalancing.RetryMatchRaw)) + } + if len(h.LoadBalancing.RetryConditionsRaw) != 2 { + t.Fatalf("RetryConditionsRaw length: got %d, want 2", len(h.LoadBalancing.RetryConditionsRaw)) + } + // Block 1: plain matcher, no status + if len(h.LoadBalancing.RetryConditionsRaw[0].Status) != 0 { + t.Errorf("block 0 should have no status codes") + } + if _, ok := h.LoadBalancing.RetryConditionsRaw[0].MatchRaw["method"]; !ok { + t.Errorf("block 0 should have method matcher") + } + // Block 2: status, no matchers + if len(h.LoadBalancing.RetryConditionsRaw[1].Status) != 1 || h.LoadBalancing.RetryConditionsRaw[1].Status[0] != 503 { + t.Errorf("block 1 should have status [503], got %v", h.LoadBalancing.RetryConditionsRaw[1].Status) + } + }) +} diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 2169d17173f..35fd2699362 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/json" "errors" @@ -381,13 +382,40 @@ func (h *Handler) Provision(ctx caddy.Context) error { // defaulting to a sane wait period between attempts h.LoadBalancing.TryInterval = caddy.Duration(250 * time.Millisecond) } - lbMatcherSets, err := ctx.LoadModule(h.LoadBalancing, "RetryMatchRaw") - if err != nil { - return err + // convert legacy retry_match entries into retry_conditions for + // backward compatibility with the JSON API + for _, matcherSetRaw := range h.LoadBalancing.RetryMatchRaw { + h.LoadBalancing.RetryConditionsRaw = append(h.LoadBalancing.RetryConditionsRaw, &RetryConditionSet{ + MatchRaw: matcherSetRaw, + }) } - err = h.LoadBalancing.RetryMatch.FromInterface(lbMatcherSets) - if err != nil { - return err + h.LoadBalancing.RetryMatchRaw = nil + + // provision retry conditions + for i, rc := range h.LoadBalancing.RetryConditionsRaw { + cond := &retryCondition{ + statusCodes: rc.Status, + } + + // load request matchers + if len(rc.MatchRaw) > 0 { + loaded, err := ctx.LoadModule(rc, "MatchRaw") + if err != nil { + return fmt.Errorf("loading retry condition %d matchers: %v", i, err) + } + loadedMap := loaded.(map[string]any) + for _, matcher := range loadedMap { + if m, ok := matcher.(caddyhttp.RequestMatcherWithError); ok { + cond.matchers = append(cond.matchers, m) + } else if m, ok := matcher.(caddyhttp.RequestMatcher); ok { + cond.matchers = append(cond.matchers, m) + } else { + return fmt.Errorf("retry condition %d: matcher is not a RequestMatcher: %T", i, matcher) + } + } + } + + h.LoadBalancing.retryConditions = append(h.LoadBalancing.retryConditions, cond) } // set up upstreams @@ -1044,6 +1072,34 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe } } + // if the response status matches any retry condition's status codes + // (and the request matches the condition's matchers, if any), + // drain the body and return a retryable error so the request is retried + if h.LoadBalancing != nil { + for _, rc := range h.LoadBalancing.retryConditions { + if len(rc.statusCodes) == 0 { + continue + } + if len(rc.matchers) > 0 { + match, err := rc.matchers.MatchWithError(req) + if err != nil { + h.logger.Error("error matching request for retry status condition", zap.Error(err)) + continue + } + if !match { + continue + } + } + for _, code := range rc.statusCodes { + if caddyhttp.StatusCodeMatches(res.StatusCode, code) { + io.CopyN(io.Discard, res.Body, 1<<20) //nolint:errcheck + res.Body.Close() + return retryableStatusError{statusCode: res.StatusCode} + } + } + } + } + // if enabled, buffer the response body if h.ResponseBuffers != 0 { res.Body, _ = h.bufferedBody(res.Body, h.ResponseBuffers) @@ -1267,18 +1323,14 @@ func (lb LoadBalancing) tryAgain(ctx caddy.Context, start time.Time, retries int // retries need to be carefully decided, because some requests // are not idempotent if !isDialError && (!isHandlerError || !errors.Is(herr, errNoUpstream)) { - if lb.RetryMatch == nil && req.Method != "GET" { - // by default, don't retry requests if they aren't GET - return false - } - - match, err := lb.RetryMatch.AnyMatchWithError(req) - if err != nil { - logger.Error("error matching request for retry", zap.Error(err)) - return false - } - if !match { - return false + // retryableStatusError is always safe to retry (we control when it's generated) + if _, ok := proxyErr.(retryableStatusError); !ok { + if len(lb.retryConditions) == 0 && req.Method != "GET" { + return false + } + if len(lb.retryConditions) > 0 && !lb.matchesRetryCondition(req, logger) { + return false + } } } } @@ -1302,6 +1354,26 @@ func (lb LoadBalancing) tryAgain(ctx caddy.Context, start time.Time, retries int } } +// matchesRetryCondition checks whether any configured retry condition's +// request matchers match the given request. If a condition has no matchers, +// any request matches it. +func (lb LoadBalancing) matchesRetryCondition(req *http.Request, logger *zap.Logger) bool { + for _, rc := range lb.retryConditions { + if len(rc.matchers) == 0 { + return true + } + match, err := rc.matchers.MatchWithError(req) + if err != nil { + logger.Error("error matching request for retry condition", zap.Error(err)) + continue + } + if match { + return true + } + } + return false +} + // directRequest modifies only req.URL so that it points to the upstream // in the given DialInfo. It must modify ONLY the request URL. func (h *Handler) directRequest(req *http.Request, di DialInfo) { @@ -1552,17 +1624,44 @@ type LoadBalancing struct { // to spin if all backends are down and latency is very low. TryInterval caddy.Duration `json:"try_interval,omitempty"` - // A list of matcher sets that restricts with which requests retries are - // allowed. A request must match any of the given matcher sets in order - // to be retried if the connection to the upstream succeeded but the - // subsequent round-trip failed. If the connection to the upstream failed, - // a retry is always allowed. If unspecified, only GET requests will be - // allowed to be retried. Note that a retry is done with the next available - // host according to the load balancing policy. + // Deprecated: Use RetryConditionsRaw instead. Kept for backward + // compatibility with the JSON API. Entries are converted to + // RetryConditionsRaw during provisioning. RetryMatchRaw caddyhttp.RawMatcherSets `json:"retry_match,omitempty" caddy:"namespace=http.matchers"` - SelectionPolicy Selector `json:"-"` - RetryMatch caddyhttp.MatcherSets `json:"-"` + // A list of retry condition sets. Each set bundles request matchers + // with optional status codes. A request must match any of the given + // condition sets in order to be retried if the connection to the + // upstream succeeded but the subsequent round-trip failed. If the + // connection to the upstream failed, a retry is always allowed. If + // a condition has status codes, the upstream's response status must + // match one of them for a retry to be triggered. If unspecified, + // only GET requests will be retried. Note that a retry is done with + // the next available host according to the load balancing policy. + RetryConditionsRaw []*RetryConditionSet `json:"retry_conditions,omitempty"` + + SelectionPolicy Selector `json:"-"` + retryConditions []*retryCondition +} + +// RetryConditionSet is a JSON-serializable set of retry conditions. +// Each set may include request matchers (to determine if a request +// is safe to retry) and response status codes. Multiple sets are +// OR'd: if any set matches, the request is retried. +type RetryConditionSet struct { + // Request matchers that determine if the request is safe to retry. + // If empty, any request matches. + MatchRaw caddy.ModuleMap `json:"match,omitempty" caddy:"namespace=http.matchers"` + + // Status codes (e.g. 502, 503) or class codes (e.g. 5 for all 5xx) + // that trigger a retry when the upstream responds with a matching status. + Status []int `json:"status,omitempty"` +} + +// retryCondition is the parsed, runtime form of RetryConditionSet. +type retryCondition struct { + matchers caddyhttp.MatcherSet + statusCodes []int } // Selector selects an available upstream from the pool. @@ -1660,6 +1759,17 @@ type RequestHeaderOpsTransport interface { // roundtrip succeeded, but an error occurred after-the-fact. type roundtripSucceededError struct{ error } +// retryableStatusError is returned when an upstream response's status code +// matches a configured retry_on status code. It is treated as a retryable +// error (not a DialError and not a roundtripSucceededError). +type retryableStatusError struct { + statusCode int +} + +func (e retryableStatusError) Error() string { + return fmt.Sprintf("upstream responded with retryable status %d", e.statusCode) +} + // bodyReadCloser is a reader that, upon closing, will return // its buffer to the pool and close the underlying body reader. type bodyReadCloser struct {