Documentation
¶
Overview ¶
Package brownout provides HTTP middleware for shedding traffic to selected endpoints at runtime ("browning out") without restarting the server.
A Controller holds a set of Rules. Each Rule matches requests by method and path and decides what fraction of matching traffic is allowed through; the rest is rejected with a configurable status code (503 by default). Rules can be changed at any time — programmatically, through the JSON admin API returned by AdminHandler, or by watching a file with WatchFile — and the change takes effect on the very next request.
c := brownout.New()
mux := http.NewServeMux()
mux.Handle("/admin/brownout/", http.StripPrefix("/admin/brownout", c.AdminHandler()))
http.ListenAndServe(":8080", c.Middleware(appHandler))
// Later, from anywhere:
c.Set(brownout.Rule{ID: "reports", Path: "/reports/*"})
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/rossedman/brownout"
)
func get(h http.Handler, method, path string) int {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
return rec.Code
}
func main() {
c := brownout.New()
app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h := c.Middleware(app)
fmt.Println(get(h, "GET", "/reports/q3"))
// Brown out every path under /reports/ without restarting.
_ = c.Set(brownout.Rule{ID: "reports", Path: "/reports/*"})
fmt.Println(get(h, "GET", "/reports/q3"))
fmt.Println(get(h, "GET", "/users/1"))
// And bring it back.
c.Remove("reports")
fmt.Println(get(h, "GET", "/reports/q3"))
}
Output: 200 503 200 200
Index ¶
- type Controller
- func (c *Controller) AdminHandler() http.Handler
- func (c *Controller) Check(req *http.Request) (Rule, bool)
- func (c *Controller) Clear()
- func (c *Controller) LoadFile(path string) error
- func (c *Controller) Middleware(next http.Handler) http.Handler
- func (c *Controller) Remove(id string) bool
- func (c *Controller) Replace(rules []Rule) error
- func (c *Controller) Rule(id string) (Rule, bool)
- func (c *Controller) Rules() []Rule
- func (c *Controller) Set(rule Rule) error
- func (c *Controller) WatchFile(ctx context.Context, path string, interval time.Duration, onError func(error)) error
- type Option
- type Responder
- type Rule
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Controller ¶
type Controller struct {
// contains filtered or unexported fields
}
Controller holds the active brownout rules and produces middleware that enforces them. It is safe for concurrent use; the request path is lock-free.
func (*Controller) AdminHandler ¶
func (c *Controller) AdminHandler() http.Handler
AdminHandler returns an http.Handler exposing a JSON API for managing rules at runtime. Mount it under a prefix with http.StripPrefix:
GET /rules list active rules → {"rules": [...]}
PUT /rules replace all rules ← {"rules": [...]}
DELETE /rules remove all rules
GET /rules/{id} fetch one rule
PUT /rules/{id} create or update a rule ← {"path": "/x/*", ...}
DELETE /rules/{id} remove a rule
Rule bodies may include "ttl" (e.g. "10m") instead of "expires_at".
The handler performs no authentication or authorization. Do not expose it publicly: serve it on an internal listener or wrap it with your own auth.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/rossedman/brownout"
)
func main() {
c := brownout.New()
admin := http.StripPrefix("/admin/brownout", c.AdminHandler())
body := `{"path": "/search", "allow_percent": 20, "ttl": "15m"}`
rec := httptest.NewRecorder()
admin.ServeHTTP(rec, httptest.NewRequest("PUT", "/admin/brownout/rules/search", strings.NewReader(body)))
fmt.Println(rec.Code)
r, _ := c.Rule("search")
fmt.Println(r.Path, r.AllowPercent)
}
Output: 200 /search 20
func (*Controller) Check ¶
func (c *Controller) Check(req *http.Request) (Rule, bool)
Check reports whether req should be shed and, if so, the rule responsible. Rules are evaluated in order and the first match decides: if it lets the request through (per AllowPercent), later rules are not consulted.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/rossedman/brownout"
)
func main() {
c := brownout.New()
_ = c.Set(brownout.Rule{ID: "personalization", Path: "/products/*"})
handler := func(w http.ResponseWriter, r *http.Request) {
if rule, degraded := c.Check(r); degraded {
fmt.Fprintf(w, "generic page (degraded by %s)", rule.ID)
return
}
fmt.Fprint(w, "personalized page")
}
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequest("GET", "/products/7", nil))
fmt.Println(rec.Code, rec.Body)
}
Output: 200 generic page (degraded by personalization)
func (*Controller) LoadFile ¶
func (c *Controller) LoadFile(path string) error
LoadFile replaces the controller's rules with those in a JSON file of the form {"rules": [...]}, using the same rule fields as the admin API (except "ttl"; use "expires_at" for an absolute deadline).
func (*Controller) Middleware ¶
func (c *Controller) Middleware(next http.Handler) http.Handler
Middleware wraps next so that requests matching an active rule are shed.
func (*Controller) Remove ¶
func (c *Controller) Remove(id string) bool
Remove deletes the rule with the given ID, reporting whether it existed.
func (*Controller) Replace ¶
func (c *Controller) Replace(rules []Rule) error
Replace atomically swaps the entire rule set. Either all rules are valid and installed, or an error is returned and nothing changes.
func (*Controller) Rule ¶
func (c *Controller) Rule(id string) (Rule, bool)
Rule returns the active rule with the given ID.
func (*Controller) Rules ¶
func (c *Controller) Rules() []Rule
Rules returns a copy of the current rules, in evaluation order, excluding any that have expired.
func (*Controller) Set ¶
func (c *Controller) Set(rule Rule) error
Set adds a rule, or replaces the existing rule with the same ID while keeping its position in the evaluation order.
Example (Exception) ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/rossedman/brownout"
)
func get(h http.Handler, method, path string) int {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
return rec.Code
}
func main() {
c := brownout.New()
h := c.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
// The first matching rule decides, so an earlier rule that allows 100%
// of traffic carves an exception out of a later, broader rule.
_ = c.Set(brownout.Rule{ID: "health", Path: "/api/health", AllowPercent: 100})
_ = c.Set(brownout.Rule{ID: "api", Path: "/api/*"})
fmt.Println(get(h, "GET", "/api/health"))
fmt.Println(get(h, "GET", "/api/orders"))
}
Output: 200 503
func (*Controller) WatchFile ¶
func (c *Controller) WatchFile(ctx context.Context, path string, interval time.Duration, onError func(error)) error
WatchFile loads path and then polls it every interval, reloading the rules whenever its contents change. It blocks until ctx is canceled and returns ctx.Err(), or returns early if the initial load fails.
Errors on later reloads (a missing file or invalid JSON, e.g. mid-write) leave the current rules in place and are passed to onError if non-nil. This works well with Kubernetes ConfigMap volumes, which update files in place without restarting the pod.
type Option ¶
type Option func(*Controller)
Option configures a Controller.
func WithClock ¶
WithClock overrides the time source used to evaluate rule expiry. Intended for tests.
func WithDefaultStatus ¶
WithDefaultStatus sets the status code used for rules that don't specify one. The default is 503 Service Unavailable.
func WithOnShed ¶
WithOnShed registers a callback invoked for every shed request, e.g. to record metrics or logs. It runs synchronously before the response is written, so it should be fast.
func WithResponder ¶
WithResponder replaces the default response writer for shed requests. The Rule passed to the responder has StatusCode already defaulted.
Example ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/rossedman/brownout"
)
func main() {
c := brownout.New(brownout.WithResponder(func(w http.ResponseWriter, r *http.Request, rule brownout.Rule) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(rule.StatusCode)
fmt.Fprintf(w, `{"error":"brownout","rule":%q}`, rule.ID)
}))
_ = c.Set(brownout.Rule{ID: "export", Path: "/export", StatusCode: http.StatusTooManyRequests})
rec := httptest.NewRecorder()
c.Middleware(http.NotFoundHandler()).ServeHTTP(rec, httptest.NewRequest("GET", "/export", nil))
fmt.Println(rec.Code, rec.Body)
}
Output: 429 {"error":"brownout","rule":"export"}
type Responder ¶
type Responder func(w http.ResponseWriter, req *http.Request, rule Rule)
Responder writes the response for a shed request.
type Rule ¶
type Rule struct {
// ID uniquely identifies the rule. Setting a rule with an existing ID
// replaces it in place.
ID string `json:"id"`
// Path is matched against the request's URL path. A trailing "*" makes
// it a prefix match ("/api/reports/*" matches "/api/reports/123"); a
// lone "*" matches every path. Otherwise the match is exact.
Path string `json:"path"`
// Methods restricts the rule to the given HTTP methods. Empty matches
// any method.
Methods []string `json:"methods,omitempty"`
// AllowPercent is the percentage (0–100) of matching requests that are
// still let through. The zero value sheds all matching traffic; 25 lets
// roughly one in four requests through.
AllowPercent float64 `json:"allow_percent,omitempty"`
// StatusCode is the response status for shed requests. Defaults to the
// Controller's default (503 Service Unavailable unless overridden).
StatusCode int `json:"status_code,omitempty"`
// RetryAfterSeconds, when positive, is sent as the Retry-After header.
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
// Message is written as the response body for shed requests. Defaults
// to the status text of StatusCode.
Message string `json:"message,omitempty"`
// ExpiresAt, when non-zero, automatically disables the rule after the
// given time.
ExpiresAt time.Time `json:"expires_at,omitzero"`
}
Rule describes an endpoint (or group of endpoints) to brown out.
Example (Methods) ¶
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"github.com/rossedman/brownout"
)
func get(h http.Handler, method, path string) int {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
return rec.Code
}
func main() {
c := brownout.New()
h := c.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
// Make the API read-only.
_ = c.Set(brownout.Rule{
ID: "readonly",
Path: "/api/*",
Methods: []string{"POST", "PUT", "PATCH", "DELETE"},
})
fmt.Println(get(h, "GET", "/api/orders"))
fmt.Println(get(h, "POST", "/api/orders"))
}
Output: 200 503
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
admin-auth
command
Command admin-auth serves the app and the brownout admin API on the same port, with the admin API behind a bearer token.
|
Command admin-auth serves the app and the brownout admin API on the same port, with the admin API behind a bearer token. |
|
auto
command
Command auto browns out an endpoint automatically when the dependency it relies on becomes unhealthy, and restores it when the dependency recovers.
|
Command auto browns out an endpoint automatically when the dependency it relies on becomes unhealthy, and restores it when the dependency recovers. |
|
basic
command
Command basic runs an app server on :8080 wrapped in brownout middleware, with the brownout admin API on a separate internal listener (:9090).
|
Command basic runs an app server on :8080 wrapped in brownout middleware, with the brownout admin API on a separate internal listener (:9090). |
|
degrade
command
Command degrade uses Controller.Check to serve a cheaper, degraded response instead of rejecting the request outright.
|
Command degrade uses Controller.Check to serve a cheaper, degraded response instead of rejecting the request outright. |
|
filewatch
command
Command filewatch loads brownout rules from a JSON file and reloads them whenever the file changes.
|
Command filewatch loads brownout rules from a JSON file and reloads them whenever the file changes. |
|
metrics
command
Command metrics counts shed requests per rule and returns JSON error bodies, using WithOnShed and WithResponder.
|
Command metrics counts shed requests per rule and returns JSON error bodies, using WithOnShed and WithResponder. |