-
Notifications
You must be signed in to change notification settings - Fork 0
The Dependency Gate
A route declares what it needs. The gate refuses requests to it when a hard dependency is unavailable — before the handler runs, before any work is done.
app.RegisterRoute(health.NewRouteWithDeps("GET", "/users/{id}", getUser,
health.NewHardRequirement("database"),
health.NewSoftRequirement("cache"),
))Enabled by default (EnableDependencyGate: true), attached at
rextension.PriorityHealthGate (600) — late in the chain, because it is about
this route's backing services rather than about the caller.
Two forms. Wrap an existing route:
rt := rxroute.New("GET", "/users/{id}", getUser)
app.RegisterRoute(health.NewHealthDepRoute(rt,
health.NewHardRequirement("database"),
))Or build it in one call:
health.NewRouteWithDeps("GET", "/users/{id}", getUser,
health.NewHardRequirement("database"),
)Or implement the interface on your own route type:
type UserRoute struct{ rxroute.Route }
func (r *UserRoute) Dependencies() []health.DepRequirement {
return []health.DepRequirement{health.NewHardRequirement("database")}
}| Dependency down | Purpose | |
|---|---|---|
NewHardRequirement(id) |
503, handler never runs | the route cannot work without it |
NewSoftRequirement(id) |
request proceeds; state is in the context | the route degrades |
A soft requirement is a signal, not a refusal. The handler reads it and adapts:
if health.IsDegraded(ctx, "cache") {
// skip the cache, go straight to the database
}By default a hard requirement is satisfied by UP or DEGRADED. To demand UP:
health.NewHardRequirement("database").WithMinStatus(health.StatusUp)TreatUnknownAs defaults to StatusUp — the gate serves when it does not
yet know.
That is deliberately the opposite of the security extension's fail-closed stance, and the difference is the point:
- Authorization failing open grants access nobody granted. There is no acceptable version of that.
- Availability-gating failing closed 503s every request for the first check interval after every boot and every deploy — because a check has not run yet, not because anything is wrong. A rolling deploy then fails its own readiness probes and rolls back.
What makes serving safe rather than merely convenient is the synchronous
check pass in OnStart: every registered check runs once, before the
listeners bind. By the time a request can arrive, the states are real.
"Unknown" then means a check that has genuinely never produced a result, which
is worth a warning rather than a refusal.
The pass adds its own duration to startup. That is the trade — a listener that binds a moment later, in exchange for never serving on a guess. Individual checks have their own timeouts, so a hanging dependency delays the boot by its timeout rather than indefinitely.
To refuse instead:
health.WithTreatUnknownAs(health.StatusDown)Before this was a setting, the gate compared
state.Status > dep.MinStatus, andStatusUnknownhappened to sort afterStatusDown— so unknown fell through to "refuse". That was not a decision, it was the order the constants were declared in, and reordering them would have silently inverted the behaviour.
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
{
"type": "urn:rex:problem:dependency-unavailable",
"title": "Service Unavailable",
"status": 503,
"detail": "a required dependency is unavailable"
}
The dependency is not named. Doing so maps your internal service topology for any caller who probes endpoints during an outage. The identifier and its state go to the log instead:
health.MiddlewareConfig{
FailureMessage: "a required dependency is unavailable", // SAFE TEXT ONLY
Logger: r.Logger(), // gets the specifics
}Without a logger the specifics go nowhere and an operator has nothing to correlate — so set it, which the extension does for you by default.
FailureStatusCode defaults to 503 and is configurable.
The gate is a PerRouteMiddleware
factory. The framework calls it once per route, when the table is built:
- a route that is not a
HealthDepRoutegetsnil— no middleware attached, so it costs nothing rather than costing a lookup that always misses; - a route that is one has its dependencies read there, once, and closed over. There is no per-request map lookup.
This is the bug the per-route primitive was built to fix. The gate used to be one global middleware that built a route identifier from the live URL —
"GET:/users/42"— and looked it up in an index keyed at registration time by the route's pattern —"GET:/users/{id}". Those never match for a parameterized route. So the gate silently did nothing for every route with a path parameter: a route declaring a hard database dependency was served normally with the database down, for as long as the extension had existed. Nothing logged it, and any test using a static path passed.
DependencyGateMiddleware and RouteResolverMiddleware remain for applications
that compose middleware themselves, both deprecated. Prefer
DependencyGateFactory, or just leave EnableDependencyGate on.
cfg := ext.MiddlewareConfig()
cfg.TreatUnknownAs = health.StatusDown
cfg.FailureStatusCode = http.StatusServiceUnavailable
app.UsePerRoute(health.DependencyGateFactory(cfg), rextension.PriorityHealthGate)Set UseCache: true to read through the snapshot cache rather than the state
store directly — cheaper under load, at the cost of up to SnapshotTTL of
staleness in the gate's decision.
rextension-health — health, readiness and dependency gating for Rex · MIT · © 2026 Kryovyx