Bastion

Hook System

Extend gateway behavior with OnRequest, OnResponse, OnError, and lifecycle hooks.

Bastion's hook system provides extension points at every stage of the request lifecycle and for gateway management events. Hooks let you add custom logic without modifying the gateway core -- authentication, logging, metrics, header injection, error handling, and alerting are all common use cases.

Available Hooks

HookSignatureWhen Fired
OnRequestfunc(r *http.Request, route *Route) errorBefore a request is proxied to upstream. Return an error to reject the request.
OnResponsefunc(resp *http.Response, route *Route)After the upstream response is received, before returning to the client.
OnErrorfunc(err error, route *Route, w http.ResponseWriter)When an upstream error occurs (connection failure, timeout, etc.).
OnRouteChangefunc(event RouteEvent)When a route is added, updated, or removed. Runs asynchronously.
OnUpstreamHealthfunc(event UpstreamHealthEvent)When an upstream target's health status changes. Runs asynchronously.
OnCircuitBreakfunc(targetID string, from, to CircuitState)When a circuit breaker state changes. Runs asynchronously.

Hook Registration

Hooks must be registered after the gateway extension has started. Use Forge's AfterStart callback to ensure the gateway is fully initialized:

import (
    "github.com/xraph/forge"
    "github.com/xraph/bastion"
    "github.com/xraph/bastion/extension"
    "github.com/xraph/vessel"
)

app := forge.New()
app.Register(extension.New(
    bastion.WithDashboardEnabled(true),
))

app.AfterStart(func() {
    gw, err := vessel.Inject[*bastion.Gateway](app.Container())
    if err != nil {
        log.Fatal("bastion not registered:", err)
    }

    // Register hooks on the running gateway
    gw.Hooks().OnRequest(func(r *http.Request, route *bastion.Route) error {
        r.Header.Set("X-Gateway-Auth", "validated")
        return nil
    })
})

app.Run()

Multiple hooks of the same type can be registered. They execute in registration order.

OnRequest Hook

The OnRequest hook is called before every proxied request. It receives the outgoing *http.Request and the matched *Route. Returning a non-nil error rejects the request and short-circuits the proxy pipeline.

Adding Authentication Headers

gw.Hooks().OnRequest(func(r *http.Request, route *bastion.Route) error {
    r.Header.Set("X-Gateway-Auth", "validated")
    r.Header.Set("X-Request-ID", uuid.New().String())
    return nil
})

Rejecting Unauthorized Requests

gw.Hooks().OnRequest(func(r *http.Request, route *bastion.Route) error {
    token := r.Header.Get("Authorization")
    if token == "" {
        return fmt.Errorf("missing authorization header")
    }

    claims, err := validateToken(token)
    if err != nil {
        return fmt.Errorf("invalid token: %w", err)
    }

    // Inject claims into the request context
    r.Header.Set("X-User-ID", claims.UserID)
    return nil
})

Request Logging

gw.Hooks().OnRequest(func(r *http.Request, route *bastion.Route) error {
    log.Printf("[%s] %s %s -> route=%s", time.Now().Format(time.RFC3339),
        r.Method, r.URL.Path, route.ID)
    return nil
})

OnResponse Hook

The OnResponse hook is called after the upstream response is received. It receives the *http.Response and the matched *Route. Use it to modify response headers, record metrics, or transform responses.

Adding Gateway Headers

gw.Hooks().OnResponse(func(resp *http.Response, route *bastion.Route) {
    resp.Header.Set("X-Served-By", "bastion")
    resp.Header.Set("X-Route-ID", route.ID)
    resp.Header.Set("X-Upstream-Latency", resp.Header.Get("X-Response-Time"))
})

Recording Custom Metrics

gw.Hooks().OnResponse(func(resp *http.Response, route *bastion.Route) {
    metrics.RecordResponse(route.Path, resp.StatusCode)
    if resp.StatusCode >= 500 {
        metrics.IncrServerErrors(route.ServiceName)
    }
})

OnError Hook

The OnError hook is called when the proxy encounters an error communicating with an upstream target. It receives the error, the matched *Route, and the http.ResponseWriter for writing a custom error response.

Custom Error Responses

gw.Hooks().OnError(func(err error, route *bastion.Route, w http.ResponseWriter) {
    log.Printf("Gateway error on route %s: %v", route.Path, err)

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusBadGateway)
    json.NewEncoder(w).Encode(map[string]string{
        "error":   "upstream_unavailable",
        "message": "The upstream service is temporarily unavailable",
        "route":   route.Path,
    })
})

Error Alerting

gw.Hooks().OnError(func(err error, route *bastion.Route, w http.ResponseWriter) {
    if isCircuitBreakerError(err) {
        alerting.Send(alerting.Critical,
            "Circuit breaker open for route %s: %v", route.Path, err)
    }
})

OnRouteChange Hook

The OnRouteChange hook fires when the route table changes -- routes added, updated, or removed. It runs asynchronously in a goroutine. The RouteEvent contains the event type and the affected route.

gw.Hooks().OnRouteChange(func(event bastion.RouteEvent) {
    switch event.Type {
    case bastion.RouteEventAdded:
        log.Printf("Route added: %s (%s)", event.Route.Path, event.Route.Source)
    case bastion.RouteEventUpdated:
        log.Printf("Route updated: %s", event.Route.Path)
    case bastion.RouteEventRemoved:
        log.Printf("Route removed: %s", event.Route.Path)
    }
})

Route Event Types

TypeConstantDescription
AddedRouteEventAddedA new route was registered (manual, FARP, or API)
UpdatedRouteEventUpdatedAn existing route was modified
RemovedRouteEventRemovedA route was deleted

OnUpstreamHealth Hook

The OnUpstreamHealth hook fires when an upstream target's health status changes. Use it for alerting, logging, or triggering failover logic. It runs asynchronously.

gw.Hooks().OnUpstreamHealth(func(event bastion.UpstreamHealthEvent) {
    if !event.Healthy {
        alerting.Send(alerting.Warning,
            "Upstream %s (%s) is unhealthy", event.TargetID, event.TargetURL)
    } else if event.Previous == false && event.Healthy {
        alerting.Send(alerting.Info,
            "Upstream %s (%s) recovered", event.TargetID, event.TargetURL)
    }
})

UpstreamHealthEvent Fields

FieldTypeDescription
TargetIDstringUnique identifier of the upstream target
TargetURLstringURL of the upstream target
HealthyboolCurrent health status
PreviousboolPrevious health status
RouteIDstringID of the route this target belongs to
Timestamptime.TimeWhen the health change was detected

OnCircuitBreak Hook

The OnCircuitBreak hook fires when a circuit breaker transitions between states. Use it for alerting and observability. It runs asynchronously.

gw.Hooks().OnCircuitBreak(func(targetID string, from, to bastion.CircuitState) {
    log.Printf("Circuit breaker for %s: %s -> %s", targetID, from, to)

    if to == bastion.CircuitOpen {
        alerting.Send(alerting.Critical,
            "Circuit breaker OPEN for target %s", targetID)
    } else if to == bastion.CircuitClosed {
        alerting.Send(alerting.Info,
            "Circuit breaker CLOSED for target %s (recovered)", targetID)
    }
})

Circuit States

StateConstantDescription
ClosedCircuitClosedNormal operation, requests flow through
OpenCircuitOpenFail-fast, all requests rejected
Half-OpenCircuitHalfOpenLimited probe requests allowed

Execution Model

  • OnRequest and OnResponse run synchronously in the request path. Keep them fast to avoid adding latency.
  • OnError runs synchronously so you can write custom error responses.
  • OnRouteChange, OnUpstreamHealth, and OnCircuitBreak run asynchronously in goroutines. They do not block the request path.
  • Multiple hooks of the same type execute in registration order.
  • OnRequest hooks short-circuit on the first error -- subsequent hooks are not called.
  • All hooks are thread-safe. The hook engine uses read-write locks internally.

Complete Example

app.AfterStart(func() {
    gw, _ := vessel.Inject[*bastion.Gateway](app.Container())

    // Request pipeline
    gw.Hooks().OnRequest(func(r *http.Request, route *bastion.Route) error {
        r.Header.Set("X-Gateway-Auth", "validated")
        r.Header.Set("X-Request-Start", fmt.Sprint(time.Now().UnixMilli()))
        return nil
    })

    gw.Hooks().OnResponse(func(resp *http.Response, route *bastion.Route) {
        resp.Header.Set("X-Served-By", "bastion")
    })

    gw.Hooks().OnError(func(err error, route *bastion.Route, w http.ResponseWriter) {
        log.Printf("Gateway error on route %s: %v", route.Path, err)
    })

    // Lifecycle events
    gw.Hooks().OnRouteChange(func(event bastion.RouteEvent) {
        log.Printf("Route %s: %s (%s)", event.Type, event.Route.Path, event.Route.Source)
    })

    gw.Hooks().OnUpstreamHealth(func(event bastion.UpstreamHealthEvent) {
        if !event.Healthy {
            log.Printf("ALERT: Upstream %s is unhealthy", event.TargetURL)
        }
    })

    gw.Hooks().OnCircuitBreak(func(targetID string, from, to bastion.CircuitState) {
        log.Printf("Circuit %s: %s -> %s", targetID, from, to)
    })
})

On this page