Skip to content

The Dependency Gate

wiki edited this page Sep 4, 2026 · 1 revision

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.

Declaring dependencies

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")}
}

Hard vs soft

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
}

Minimum status

By default a hard requirement is satisfied by UP or DEGRADED. To demand UP:

health.NewHardRequirement("database").WithMinStatus(health.StatusUp)

Unknown serves, and why

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, and StatusUnknown happened to sort after StatusDown — 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.

The refusal

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.

How it attaches

The gate is a PerRouteMiddleware factory. The framework calls it once per route, when the table is built:

  • a route that is not a HealthDepRoute gets nil — 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.

Composing it yourself

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.

Clone this wiki locally