Ownership and teardown
Every route, named middleware, and event subscription in Gas records the service that registered it. That bookkeeping is what makes the kill switch possible: one service can be closed at runtime, and everything it registered stops serving, without restarting the process.
What a service owns
Section titled “What a service owns”Ownership is set by the first argument of each registration call, which is the
service doing the registering; from inside a service’s Init, that is the
receiver.
| Registration | Owned by |
|---|---|
router.Handle(s, method, path, h), and Get, Post, Put, Patch, Delete, Head |
s |
router.Register(s, name, mw), a named middleware |
s |
bus.SubscribeWithOwner[E](s, fn) |
s |
bus.Subscribe[E](fn) |
Nobody; no teardown removes it |
gas.MiddlewareFunc(fn) or gas.MiddlewareFuncWithName(name, fn) used inline |
Nobody; no teardown disables it |
Ownership is recorded by the service’s Name(). Passing nil as the owner of a
route or a named middleware makes the router itself (gas/router) the owner,
so closing one of your services never removes it.
// Auth owns a named middleware and one route.type Auth struct{ router *gas.Router }
func NewAuth(router *gas.Router) *Auth { return &Auth{router: router} }
func (s *Auth) Name() string { return "auth" }
func (s *Auth) Init(context.Context) error { // Register ties the name "require-auth" to this service. s.router.Register(s, "require-auth", requireAuth) s.router.Get(s, "/auth/me", func(ctx gas.Context) error { return ctx.Text(http.StatusOK, "me") }) return nil}
func (s *Auth) Close(context.Context) error { return nil }// Billing owns its route, but guards it with auth's middleware.type Billing struct{ router *gas.Router }
func NewBilling(router *gas.Router) *Billing { return &Billing{router: router} }
func (s *Billing) Name() string { return "billing" }
func (s *Billing) Init(context.Context) error { s.router.Route("/api", func(sub *gas.Router) { sub.Use(gas.MiddlewareByName("require-auth")) sub.Get(s, "/invoices", func(ctx gas.Context) error { return ctx.Text(http.StatusOK, "invoices") }) }) return nil}
func (s *Billing) Close(context.Context) error { return nil }router.Routes() returns the registered routes grouped by owning service (the
HEAD route registered alongside each GET is left out), and
router.NamedMiddleware() maps each named middleware to its owner. app.Run()
logs the same information at startup: as one table in the development
environment, and as one route registered line per route otherwise.
Closing a service
Section titled “Closing a service”// Tear auth down at runtime. Its route and its named middleware now// answer 503, including on billing's route.if err := app.CloseService[*Auth](ctx); err != nil { log.Fatal(err)}CloseService[T](ctx) takes the type the service was registered under, matched
exactly. It is a method on the Worker, so an App has it too. In order, it:
- disables everything the service registered on the router (App only; see below),
- removes every subscription the service made with
SubscribeWithOwner, - calls the service’s
Close(ctx), - removes the service from the active services,
- emits
gas.SystemServiceClosedwith the service’s name, and waits for its subscribers.
Routes and subscriptions go first, so they are gone even if Close fails. An
error from Close is logged as service close failed and not returned, since
the teardown has already happened.
CloseService returns an error when T has no built instance (it was never
built, or it is registered under a different type), when the service is not
active (it was already closed), or once shutdown has begun. Only singletons,
including instances, have a built instance to close.
A closed service no longer appears in ActiveServices(), is left out of
health and readiness checks, and
is not closed a second time at shutdown.
What 503 means
Section titled “What 503 means”After the teardown, every route the closed service owns answers
503 Service Unavailable in the unified error shape,
including the HEAD route that came with each GET. The body does not name the
service:
{ "error": { "status": 503, "code": "service_unavailable", "message": "service unavailable" }}A client that asks for text/html without also accepting application/json
gets the message as plain text instead.
Every named middleware the service registered is replaced with a 503 as
well, wherever it is referenced: on routes owned by other services that are
still running, and inside Group and Route blocks. In the sample, billing is
still running, but its route uses auth’s require-auth:
GET /api/invoices -> 200 invoicesGET /auth/me -> 503{ "error": { "status": 503, "code": "service_unavailable", "message": "service unavailable" }}GET /api/invoices -> 503{ "error": { "status": 503, "code": "service_unavailable", "message": "service unavailable" }}The router applies the change by building a new routing tree and swapping it in atomically. Requests already in flight finish on the previous tree.
Restarting a service
Section titled “Restarting a service”// Run auth's Init again on the same instance. It registers its route and// middleware again, which brings both back.if err := app.RestartService[*Auth](ctx); err != nil { log.Fatal(err)}RestartService[T](ctx) runs Init again on the same instance, under the same
init deadline as at startup.
Because Init registers the service’s routes, named middleware, and
subscriptions again, they serve again: in the sample, GET /api/invoices
returns 200 once more. The service rejoins the active services, and
gas.SystemServiceInitialized is emitted with its name.
RestartService returns an error when T has no built instance, when the
service is already active, once shutdown has begun, or when Init fails; the
last one is wrapped as gas: re-init <name>: ....
Reacting to a teardown
Section titled “Reacting to a teardown”Other services can respond to gas.SystemServiceClosed, which is how a system
degrades on purpose rather than by accident:
// Status watches for services closing at runtime.type Status struct { bus *gas.EventBus logger gas.Logger}
func NewStatus(bus *gas.EventBus, logger gas.Logger) *Status { return &Status{bus: bus, logger: logger}}
func (s *Status) Name() string { return "status" }
func (s *Status) Init(context.Context) error { s.bus.SubscribeWithOwner[gas.SystemServiceClosed](s, func(p gas.SystemServiceClosedPayload) { if p.ServiceName == "auth" { s.logger.Warn("auth is down; entering degraded mode").Send() } }) return nil}
func (s *Status) Close(context.Context) error { return nil }The closed service’s own subscriptions are removed before the event is emitted,
so it does not receive its own SystemServiceClosed. All system events are
listed on Events.
When to use it
Section titled “When to use it”Rarely, and that is fine. The kill switch earns its place when one subsystem is
failing in a way that threatens the rest, when a dependency is down and its
routes should fail fast instead of piling up requests, or when one feature has
to be drained ahead of a deploy. If you never call CloseService, ownership
still makes the route map attributable: every route says which service owns it.