Bastion

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

ErrorHTTP statusDescription
Route not found404 Not FoundNo route matches the request path
Route disabled404 Not FoundThe matching route has Enabled: false

Rate limiting errors

ErrorHTTP statusDescription
Global rate limit exceeded429 Too Many RequestsThe global token bucket is exhausted
Route rate limit exceeded429 Too Many RequestsThe per-route token bucket is exhausted
Client rate limit exceeded429 Too Many RequestsThe 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

ErrorHTTP statusDescription
Missing credentials401 UnauthorizedNo API key, Bearer token, or auth header provided
Invalid credentials401 UnauthorizedThe provided credentials do not match
Forward-auth rejected401 Unauthorized or 403 ForbiddenThe forward-auth service rejected the request

Upstream errors

ErrorHTTP statusDescription
No healthy targets503 Service UnavailableAll targets for the route are unhealthy
Circuit breaker open503 Service UnavailableThe circuit breaker for the selected target is open
Upstream timeout504 Gateway TimeoutThe upstream did not respond within the configured timeout
Upstream connection refused502 Bad GatewayThe upstream refused the TCP connection
Upstream TLS error502 Bad GatewayTLS handshake with the upstream failed

IP filtering errors

ErrorHTTP statusDescription
IP denied403 ForbiddenThe 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 constantDescription
bastion.ErrCircuitOpenCircuit breaker is in the open state
bastion.ErrUpstreamTimeoutUpstream request timed out
bastion.ErrUpstreamConnectionRefusedTCP connection to upstream was refused
bastion.ErrNoHealthyTargetsNo healthy upstream targets available
bastion.ErrRateLimitExceededToken bucket is exhausted
bastion.ErrAuthFailedAuthentication check failed
bastion.ErrIPDeniedClient IP is not allowed
bastion.ErrTLSHandshakeFailedTLS 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 default

Per-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:

  1. The upstream connection is closed
  2. The failure is recorded for circuit breaker and passive health tracking
  3. If a retry policy is configured, the request is retried with the next target
  4. If all retries are exhausted, 504 Gateway Timeout is returned to the client
  5. The OnError hook is called with bastion.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

StateRequest handling
ClosedRequests are forwarded normally. Failures increment the counter.
OpenRequests are rejected immediately with 503 Service Unavailable. No traffic reaches the upstream.
Half-OpenA 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 typeRetried?
Upstream connection refusedYes
Upstream timeoutYes
Circuit breaker open (target-level)Yes (selects a different target)
5xx upstream responseYes
4xx upstream responseNo
Rate limit exceededNo
Authentication failureNo

Backoff strategies

StrategyDelay pattern
RetryExponentialbaseDelay * 2^attempt (capped at maxDelay)
RetryLinearbaseDelay * attempt (capped at maxDelay)
RetryFixedbaseDelay (constant delay)

When Jitter is enabled, a random value between 0 and the computed delay is added to prevent retry storms across multiple clients.

On this page