feat(circuit): progressive backoff and configurable trip conditions

The circuit breaker ignored the trip-on-timeout, trip-on-4xx, and
trip-on-5xx flags and never cleared its counts, so the failure ratio
became lifetime-cumulative. The backoff-multiplier and max-backoff
settings did nothing. Circuit-open surfaced as HTTP 500.

Failure classification now honours the trip flags, the breaker uses a
rolling count window, and repeated trips extend the open state up to the
configured maximum. A helper maps circuit-open to HTTP 503. The request
body is copied once per request instead of once per retry. Defaults
reproduce the prior behaviour: the breaker is off by default and a
backoff multiplier of one keeps a constant open window.
This commit is contained in:
2026-08-14 11:10:37 +01:00
parent 6751346796
commit 10e51daab3
5 changed files with 1070 additions and 44 deletions
+27
View File
@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"time"
"github.com/sony/gobreaker"
)
// Error codes for structured error responses
@@ -143,3 +145,28 @@ func GetStatusCode(err error) int {
return 500
}
// StatusCodeForError maps a proxy-path error to the HTTP status code an
// error handler must return for it. It recognises the circuit-breaker
// sentinel errors (ErrCircuitOpen, defined in proxy.go, and
// gobreaker.ErrOpenState) as 503 Service Unavailable -- previously these
// surfaced as a generic 500 because nothing mapped them -- defers to a
// wrapped *ProxyError's StatusCode when present (via errors.As, so it also
// unwraps errors returned through circuitBreakerOutcome), and otherwise
// falls back to 500. A nil error maps to 200.
func StatusCodeForError(err error) int {
if err == nil {
return 200
}
if errors.Is(err, ErrCircuitOpen) || errors.Is(err, gobreaker.ErrOpenState) {
return 503
}
var proxyErr *ProxyError
if errors.As(err, &proxyErr) {
return proxyErr.StatusCode
}
return 500
}