brownout is Go HTTP middleware for shedding some or all traffic to chosen routes at runtime. When a dependency is struggling, turn off an expensive endpoint or cut it to 10% of its traffic, then restore it, with no restart and no deploy.
Rules are swapped atomically. The request path takes no locks and doesn't allocate. With a handful of rules it adds well under a microsecond per request.
Let a set percentage of traffic through, so you can reduce load without switching a feature off entirely.
Give a rule a TTL and it lifts by itself, so a forgotten brownout doesn't outlast the incident.
Plain http.Handler middleware with no dependencies. It works with any router built on net/http.
Wrap your app in the middleware and expose the admin API somewhere private. That's the whole setup.
c := brownout.New()
// Wrap your app.
go http.ListenAndServe(":8080", c.Middleware(app))
// Admin API on an internal-only listener.
http.ListenAndServe("127.0.0.1:9090", c.AdminHandler())# Shed /reports/* for 10 minutes
curl -X PUT localhost:9090/rules/reports \
-d '{"path":"/reports/*","ttl":"10m"}'
# Let only 20% of searches through
curl -X PUT localhost:9090/rules/search \
-d '{"path":"/search","allow_percent":20}'
# Restore
curl -X DELETE localhost:9090/rules/reportsA rule matches requests by path and method and decides what share of them to let through. Rules are checked in order, and the first matching rule decides.
| JSON field | Go field | Meaning |
|---|---|---|
id | ID | Unique name. Setting a rule with an existing ID replaces it in place. |
path | Path | An exact path, or a prefix if it ends in * (/api/*). * on its own matches everything. |
methods | Methods | Optional. Only match these HTTP methods. |
allow_percent | AllowPercent | Percentage of matching requests still let through, from 0 to 100. The default of 0 sheds everything. |
status_code | StatusCode | Status for shed requests. Default 503. |
retry_after_seconds | RetryAfterSeconds | Sets the Retry-After header. |
message | Message | Response body. Defaults to the status text. |
expires_at / ttl | ExpiresAt | When the rule stops applying. The admin API also accepts "ttl": "15m". |
Call these from a feature-flag callback, a health check that watches a dependency, or anywhere else in your code.
c.Set(rule) // add or update one rule
c.Remove("reports") // remove one rule
c.Replace(rules) // swap the whole set; all rules must be valid or none are applied
c.Clear() // remove everything
c.Rules() // list active rules
c.Check(r) // decide yourself: (rule, shed)Mount c.AdminHandler() under a prefix with http.StripPrefix.
| Method | Path | Does |
|---|---|---|
GET | /rules | List active rules: {"rules": [...]} |
PUT | /rules | Replace all rules |
DELETE | /rules | Remove all rules |
GET | /rules/{id} | Get one rule |
PUT | /rules/{id} | Create or update one rule |
DELETE | /rules/{id} | Remove one rule |
Poll a JSON file and reload it when it changes. This suits Kubernetes ConfigMaps: edit the ConfigMap and every replica picks up the new rules. If the file is invalid or half-written, the current rules stay in place.
go c.WatchFile(ctx, "/etc/brownout/rules.json",
5*time.Second, func(err error) {
log.Printf("brownout: %v", err)
}){ "rules": [
{ "id": "reports",
"path": "/reports/*",
"retry_after_seconds": 120 }
] }Edit the rules and the request to see what the middleware would do. This runs the library's matching logic in your browser.
Each example is a small runnable server. Run one with go run ./examples/<name>; the curl commands to try are at the top of each file.
The minimal setup: the app on one port and the admin API on an internal one.
Puts the app and the admin API on one port, with bearer-token auth on the admin API. A broad rule can't lock you out of the admin API.
Uses WatchFile with a JSON rules file, the setup for a Kubernetes ConfigMap.
A health loop browns out an endpoint when its dependency fails and restores it when the dependency recovers. A TTL is the fallback if the loop stops.
Uses Check to skip an expensive step and serve a cheaper page instead of returning a 503.
Counts shed requests per rule with WithOnShed and returns JSON error bodies with WithResponder.
c := brownout.New(
brownout.WithDefaultStatus(http.StatusTooManyRequests),
brownout.WithOnShed(func(r *http.Request, rule brownout.Rule) {
shedCounter.WithLabelValues(rule.ID).Inc()
}),
brownout.WithResponder(func(w http.ResponseWriter, r *http.Request, rule brownout.Rule) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(rule.StatusCode)
json.NewEncoder(w).Encode(map[string]string{"error": "temporarily unavailable"})
}),
)Each process keeps its rules in memory. Share a watched file such as a ConfigMap, or call the admin API on every instance.
Otherwise a "*" rule will shed requests to the admin API too, and you can't remove the rule.
Because the first matching rule decides, put a rule with "allow_percent": 100 before a broader rule to exempt a path from it.