Error Handling
Gateway error types, error hooks, custom error pages, and upstream failure handling.
Bastion defines error types for each stage of the request pipeline. Errors can be intercepted and customized through the hook system, and upstream failures are handled automatically by the circuit breaker and retry mechanisms.
Gateway error types
Errors in Bastion fall into several categories based on where they occur in the pipeline.
Routing errors
| Error | HTTP status | Description |
|---|---|---|
| Route not found | 404 Not Found | No route matches the request path |
| Route disabled | 404 Not Found | The matching route has Enabled: false |
Rate limiting errors
| Error | HTTP status | Description |
|---|---|---|
| Global rate limit exceeded | 429 Too Many Requests | The global token bucket is exhausted |
| Route rate limit exceeded | 429 Too Many Requests | The per-route token bucket is exhausted |
| Client rate limit exceeded | 429 Too Many Requests | The per-client token bucket is exhausted |
Rate limit responses include a Retry-After header indicating how many seconds the client should wait before retrying.
Authentication errors
| Error | HTTP status | Description |
|---|---|---|
| Missing credentials | 401 Unauthorized | No API key, Bearer token, or auth header provided |
| Invalid credentials | 401 Unauthorized | The provided credentials do not match |
| Forward-auth rejected | 401 Unauthorized or 403 Forbidden | The forward-auth service rejected the request |
Upstream errors
| Error | HTTP status | Description |
|---|---|---|
| No healthy targets | 503 Service Unavailable | All targets for the route are unhealthy |
| Circuit breaker open | 503 Service Unavailable | The circuit breaker for the selected target is open |
| Upstream timeout | 504 Gateway Timeout | The upstream did not respond within the configured timeout |
| Upstream connection refused | 502 Bad Gateway | The upstream refused the TCP connection |
| Upstream TLS error | 502 Bad Gateway | TLS handshake with the upstream failed |
IP filtering errors
| Error | HTTP status | Description |
|---|---|---|
| IP denied | 403 Forbidden | The client IP is on the deny list or not on the allow list |
Error hooks
The OnError hook lets you intercept errors and customize the response. It is called whenever an error occurs during request processing after route matching.
gw.Hooks().OnError(func(err error, route *bastion.Route, w http.ResponseWriter) {
log.Printf("Gateway error on route %s: %v", route.Path, err)
})Checking error types
Use errors.Is to check for specific gateway error types:
gw.Hooks().OnError(func(err error, route *bastion.Route, w http.ResponseWriter) {
switch {
case errors.Is(err, bastion.ErrCircuitOpen):
// Circuit breaker is open for the selected target
metrics.IncrCounter("circuit_breaker.rejections", 1)
case errors.Is(err, bastion.ErrUpstreamTimeout):
// Upstream did not respond in time
metrics.IncrCounter("upstream.timeouts", 1)
case errors.Is(err, bastion.ErrNoHealthyTargets):
// All targets are unhealthy
alerting.Critical("No healthy targets for route %s", route.Path)
case errors.Is(err, bastion.ErrRateLimitExceeded):
// Rate limit hit
metrics.IncrCounter("rate_limit.exceeded", 1)
}
})Sentinel errors
| Error constant | Description |
|---|---|
bastion.ErrCircuitOpen | Circuit breaker is in the open state |
bastion.ErrUpstreamTimeout | Upstream request timed out |
bastion.ErrUpstreamConnectionRefused | TCP connection to upstream was refused |
bastion.ErrNoHealthyTargets | No healthy upstream targets available |
bastion.ErrRateLimitExceeded | Token bucket is exhausted |
bastion.ErrAuthFailed | Authentication check failed |
bastion.ErrIPDenied | Client IP is not allowed |
bastion.ErrTLSHandshakeFailed | TLS handshake with upstream failed |
Custom error pages
By default, Bastion returns plain text error responses. You can customize error responses using the OnError hook:
gw.Hooks().OnError(func(err error, route *bastion.Route, w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
switch {
case errors.Is(err, bastion.ErrRateLimitExceeded):
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(map[string]string{
"error": "rate_limit_exceeded",
"message": "Too many requests. Please retry later.",
})
case errors.Is(err, bastion.ErrCircuitOpen):
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"error": "service_unavailable",
"message": "The upstream service is temporarily unavailable.",
})
case errors.Is(err, bastion.ErrUpstreamTimeout):
w.WriteHeader(http.StatusGatewayTimeout)
json.NewEncoder(w).Encode(map[string]string{
"error": "gateway_timeout",
"message": "The upstream service did not respond in time.",
})
default:
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]string{
"error": "bad_gateway",
"message": "An unexpected error occurred.",
})
}
})Upstream timeout handling
Bastion enforces timeouts at multiple levels:
Request timeout
The per-request timeout controls how long Bastion waits for a complete response from the upstream:
bastion.WithTimeout(30 * time.Second) // global defaultPer-route override:
bastion.RouteConfig{
Path: "/reports/*",
Timeout: 60 * time.Second, // longer timeout for slow endpoints
}Connection timeout
The HTTP transport uses a separate dial timeout for establishing the TCP connection. This is shorter than the request timeout and prevents slow connection establishment from consuming the full request budget.
Timeout behavior
When a timeout occurs:
- The upstream connection is closed
- The failure is recorded for circuit breaker and passive health tracking
- If a retry policy is configured, the request is retried with the next target
- If all retries are exhausted,
504 Gateway Timeoutis returned to the client - The
OnErrorhook is called withbastion.ErrUpstreamTimeout
Circuit breaker errors
Circuit breakers protect the gateway from repeatedly sending requests to failing upstreams.
Three-state model
success
┌──────────────┐
│ │
▼ │
┌────────┐ failure count ┌──────┐ resetTimeout ┌───────────┐
│ Closed │ ───────────────▶ │ Open │ ──────────────▶ │ Half-Open │
└────────┘ >= threshold └──────┘ expires └───────────┘
▲ │ │
│ probe succeeds │ │
└─────────────────────────────────────────────────────┘ │
│
probe fails ──────────────────────▶ Open ◀──────┘Behavior per state
| State | Request handling |
|---|---|
| Closed | Requests are forwarded normally. Failures increment the counter. |
| Open | Requests are rejected immediately with 503 Service Unavailable. No traffic reaches the upstream. |
| Half-Open | A single probe request is allowed through. If it succeeds, the circuit closes. If it fails, the circuit re-opens. |
Circuit breaker scope
Circuit breakers are per-target, not per-route. If a target appears in multiple routes, the circuit breaker state is shared. This prevents a failing server from receiving traffic on any route.
Configuration
bastion.WithCircuitBreaker(bastion.CircuitBreakerConfig{
Enabled: true,
FailureThreshold: 5, // 5 consecutive failures to open
ResetTimeout: 30 * time.Second, // wait 30s before half-open
})Retry behavior on errors
When a retry policy is configured, Bastion automatically retries failed requests:
bastion.WithRetry(bastion.RetryConfig{
MaxRetries: 3,
Strategy: bastion.RetryExponential,
BaseDelay: 100 * time.Millisecond,
MaxDelay: 5 * time.Second,
Jitter: true,
})Retryable errors
| Error type | Retried? |
|---|---|
| Upstream connection refused | Yes |
| Upstream timeout | Yes |
| Circuit breaker open (target-level) | Yes (selects a different target) |
| 5xx upstream response | Yes |
| 4xx upstream response | No |
| Rate limit exceeded | No |
| Authentication failure | No |
Backoff strategies
| Strategy | Delay pattern |
|---|---|
RetryExponential | baseDelay * 2^attempt (capped at maxDelay) |
RetryLinear | baseDelay * attempt (capped at maxDelay) |
RetryFixed | baseDelay (constant delay) |
When Jitter is enabled, a random value between 0 and the computed delay is added to prevent retry storms across multiple clients.