Configure quota checks and accounting
When to use this guide
Section titled “When to use this guide”Use these tasks to add tenant budgets and weighted accounting to authenticated Echo routes. Install Echo Pro and authenticate and select the tenant first. Run the source examples with:
go test -run Example ./quota/...The quota overview explains atomic limit checks and remote failure policy. The configuration reference includes all middleware, memory, Redis, telemetry and export options.
Add quota to your Echo group
Section titled “Add quota to your Echo group”Construct the limiter and its middleware after building an authentication and tenant guard.
The callback from authbind.Quota() reads the verified principal and selected tenant.
lim, err := quota.New(quota.Config{ Store: quota.NewMemoryStore(quota.MemoryConfig{MaxKeys: 100}), Plans: []quota.Plan{{ID: "free", Limits: []quota.Limit{ quota.PerDay("requests", 3), }}},})if err != nil { return err}defer func() { closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() result = errors.Join(result, lim.Close(closeCtx))}()identity, err := authbind.Quota()if err != nil { return err}quotaMW, err := (quota.MiddlewareConfig{ Limiter: lim, Identity: identity,}).ToMiddleware()if err != nil { return err}Run the guard before quota on the same group:
api := e.Group("/v1/tenants/:tenant", guard, quotaMW)api.GET("/invoices", func(c *echo.Context) error { id, _ := tenant.From(c.Request().Context()) return c.JSON(http.StatusOK, map[string]any{ "tenant": id, "invoices": []string{"inv-1"}, })})During shutdown, call lim.Close(ctx) after HTTP requests stop. See getting started for the complete program.
Select an application-owned subscription plan
Section titled “Select an application-owned subscription plan”MiddlewareConfig.Plan has signature func(*echo.Context, quota.Identity) (string, error).
This example selects each tenant’s plan from an immutable subscription map. It returns 200 twice, then 429. An administrative route without its required scope returns 403; a request without a credential returns 401.
// Application-owned subscription data; replace with your plan repository.// This demo map is immutable while requests run.planByTenant := map[string]string{"acme": "team"}cfg := quota.MiddlewareConfig{ Limiter: lim, Identity: func(c *echo.Context) (quota.Identity, error) { p, ok := auth.FromEcho(c) if !ok { return quota.Identity{}, echo.ErrUnauthorized } tenant := p.TenantRestriction() if tenant == "" { return quota.Identity{}, echo.ErrForbidden } return quota.Identity{Tenant: tenant, User: p.ID(), APIKeyID: p.KeyID()}, nil }, Plan: func(_ *echo.Context, id quota.Identity) (string, error) { plan, ok := planByTenant[id.Tenant] if !ok { return "", echo.ErrForbidden } return plan, nil },}mw, err := cfg.ToMiddleware()if err != nil { return err}api := e.Group("/v1", authMW, mw)api.GET("/hello", func(c *echo.Context) error { return c.NoContent(http.StatusOK) })Replace the immutable map with your subscription repository, using the Echo request context for blocking lookups. For unrestricted users, compose tenant membership first and use authbind.Quota() instead of reading TenantRestriction directly. Protect usage reads with the same chain. Quota uses the plan name you return as-is. It does not check it.
Assign weighted costs to routes
Section titled “Assign weighted costs to routes”Set MiddlewareConfig.Cost to RouteCosts. Keys use registered paths and methods. Call CheckRouteCosts in your route-registration test so a renamed route cannot silently fall back to cost one. The example consumes five units from a 100-unit plan and prints 200 95.
func ExampleRouteCosts() { lim := quota.MustNew(quota.Config{Store: quota.NewMemoryStore( quota.MemoryConfig{}, ), Plans: []quota.Plan{{ID: "free", Limits: []quota.Limit{ quota.PerDay("requests", 100), }}}}) defer closeExampleLimiter(lim) costs := map[string]int64{"GET /v1/search": 5, "POST /v1/export": 100} e := echo.New() e.Use(quota.MustMiddleware(quota.MiddlewareConfig{ Limiter: lim, Identity: func( *echo.Context, ) (quota.Identity, error) { return quota.Identity{Tenant: "acme"}, nil }, Cost: quota.RouteCosts(costs), })) handler := func(c *echo.Context) error { d, ok := quota.DecisionFrom(c) if !ok { panic("missing quota decision") } return c.String(200, fmt.Sprint(d.Results[0].Remaining)) } e.GET("/v1/search", handler) e.POST("/v1/export", handler) if err := quota.CheckRouteCosts(e, costs); err != nil { panic(err) } rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequestWithContext( context.Background(), "GET", "/v1/search", nil, )) fmt.Println(rec.Code, rec.Body.String()) // Output: 200 95}Use custom meters when requests and work have different budgets. Return explicit costs for each configured meter. Keep custom operation labels bounded, and test the unknown-path behavior of protected Echo groups.
Reserve estimated work and settle actual use
Section titled “Reserve estimated work and settle actual use”Use Reserve for work whose final cost is unknown before work starts. Retain the reservation handle across settlement retries. This example reserves token units and settles to the actual amount:
func ExampleLimiter_Settle() { l := quota.MustNew(quota.Config{Store: quota.NewMemoryStore( quota.MemoryConfig{}, ), Plans: []quota.Plan{{ID: "pro", Limits: []quota.Limit{ quota.PerMonth("tokens", 1000).Meter("tokens"), }}}}) ctx := context.Background() r, d, err := l.Reserve(ctx, quota.Request{ Identity: quota.Identity{Tenant: "acme"}, Cost: quota.Cost{"tokens": 100}, }) if err != nil { panic(err) } fmt.Println("allowed:", d.Allowed) settled, err := l.Settle(ctx, r, quota.Cost{"tokens": 60}) if err != nil { panic(err) } fmt.Println("final tokens:", settled.Actual["tokens"]) if err = l.Close(ctx); err != nil { panic(err) } // Output: // allowed: true // final tokens: 60}For HTTP handlers, set Settle: true and call quota.Charge with final amounts. An explicit zero refunds work. Omitted meters retain their reservations. Call Close during shutdown to drain asynchronous settlement. The execution task demonstrates keeping two request attempts charged while charging actual token work once.
For MCP tools, the default settled policy keeps the billable reservation when SDK argument validation fails before the handler runs. Use quotamcp.Policy{Settle: true, ChargeFirst: true} and call quotamcp.Charge(ctx, nil) before billable work to refund such failures. Attempt meters remain charged. See the MCP accounting example.
Bound dimensions and adjust customer quotas
Section titled “Bound dimensions and adjust customer quotas”Use a controlled dimension vocabulary such as small, large and other. Set ByDimension on the limit and provide every configured dimension. The example also applies a numeric override without changing the subject’s stored usage:
func ExampleLimit_ByDimension() { l := quota.MustNew(quota.Config{Store: quota.NewMemoryStore( quota.MemoryConfig{}, ), Plans: []quota.Plan{{ID: "team", Limits: []quota.Limit{ quota.PerDay("model", 10).ByDimension("model"), }}}}) d, err := l.Allow(context.TODO(), quota.Request{Identity: quota.Identity{ Tenant: "acme", }, Dimensions: map[string]string{"model": "small"}, Overrides: []quota.Override{{ Limit: "model", N: 20, }}}) if err != nil { panic(err) } fmt.Println(d.Allowed, d.Results[0].Quota) // Output: true 20}Map unknown request values to a bounded value before quota. If override values come from a database, cache them in your application. Overrides run for each limit check and usage read. They do not provide a subscription repository or cache.
For billing plans, use PerBillingPeriod and return the trusted subscription start and end from MiddlewareConfig.Period. End must exceed Start by at least one millisecond. A newer start resets the period; the same start retains use.
func ExamplePerBillingPeriod() { ctx := context.Background() lim, err := quota.New(quota.Config{ Store: quota.NewMemoryStore(quota.MemoryConfig{}), Plans: []quota.Plan{{ID: "subscription", Limits: []quota.Limit{ quota.PerBillingPeriod("requests", 2), }}}, }) if err != nil { panic(err) } defer closeExampleLimiter(lim) // Trusted subscription data, immutable in this fixture. A repository lookup // uses c.Request().Context(); the request body never selects billing bounds. period := quota.Period{Start: time.Date( 2026, 10, 15, 0, 0, 0, 0, time.UTC, ), End: time.Date(2026, 11, 15, 0, 0, 0, 0, time.UTC)} middleware, err := (quota.MiddlewareConfig{ Limiter: lim, Identity: func( *echo.Context, ) (quota.Identity, error) { return quota.Identity{Tenant: "acme"}, nil }, Period: func(*echo.Context, quota.Identity) ( quota.Period, error, ) { return period, nil }, }).ToMiddleware() if err != nil { panic(err) } e := echo.New() e.GET("/invoices", func(c *echo.Context) error { return c.NoContent( http.StatusNoContent, ) }, middleware) for range 3 { w := httptest.NewRecorder() e.ServeHTTP(w, httptest.NewRequestWithContext( ctx, http.MethodGet, "/invoices", nil, )) fmt.Println(w.Code) } // Output: // 204 // 204 // 429}The billing fixture returns 204 twice, then 429. Its period is trusted subscription data. The store does not compute the period from the clock. It uses the dates you return. Calendar limits reset at their configured local times:
func ExampleLimit_In() { loc, err := time.LoadLocation("America/New_York") if err != nil { panic(err) } _, err = quota.New(quota.Config{Store: quota.NewMemoryStore( quota.MemoryConfig{}, ), Plans: []quota.Plan{{ID: "team", Limits: []quota.Limit{ quota.PerDay("daily", 100).In(loc), quota.PerMonth("monthly", 1000).In(loc).Anchor(31), }}}}) fmt.Println(err) // Output: <nil>}Release concurrent work and replay credit adjustments
Section titled “Release concurrent work and replay credit adjustments”Use Concurrent for in-flight jobs. If you call the limiter directly, release the decision when the job ends. The fixture admits one job, refuses a second and admits a third after release. The middleware releases it when the handler returns.
func ExampleConcurrent() { ctx := context.Background() lim, err := quota.New(quota.Config{Store: quota.NewMemoryStore(quota.MemoryConfig{}), Plans: []quota.Plan{{ID: "jobs", Limits: []quota.Limit{ quota.Concurrent("running", 1), }}}, }) if err != nil { panic(err) } defer closeExampleLimiter(lim) request := quota.Request{Identity: quota.Identity{Tenant: "acme"}} first, err := lim.Allow(ctx, request) if err != nil { panic(err) } second, err := lim.Allow(ctx, request) if err != nil { panic(err) } if err := lim.Release(ctx, first); err != nil { panic(err) } third, err := lim.Allow(ctx, request) if err != nil { panic(err) } fmt.Println(first.Allowed, second.Allowed, third.Allowed) if err := lim.Release(ctx, third); err != nil { panic(err) } // Output: true false true}Fund Credits with an authorized AddCredits call. Keep its ID and amount across a lost response. This fixture replays the same 100-unit adjustment without applying it twice, then detects a changed amount with ErrCreditConflict:
func ExampleCredits() { ctx := context.Background() lim, err := quota.New(quota.Config{Store: quota.NewMemoryStore(quota.MemoryConfig{}), Plans: []quota.Plan{{ID: "paid", Limits: []quota.Limit{ quota.Credits("balance").Meter("tokens"), }}}, }) if err != nil { panic(err) } defer closeExampleLimiter(lim) credit := quota.CreditRequest{ Identity: quota.Identity{Tenant: "acme"}, Plan: "paid", Limit: "balance", ID: "invoice-42", Amount: 100, } for range 2 { result, err := lim.AddCredits(ctx, credit) if err != nil { panic(err) } fmt.Println(result.Balance, result.Applied) } credit.Amount = 200 _, err = lim.AddCredits(ctx, credit) fmt.Println(errors.Is(err, quota.ErrCreditConflict)) // Output: // 100 true // 100 false // true}Credit adjustment IDs deduplicate for 30 days. Retain permanent business deduplication when the same invoice can reappear after that window. When the store is unavailable, credit writes fail. They never succeed without the store. The credit conflict page describes client recovery.
Reload plans without replacing the limiter
Section titled “Reload plans without replacing the limiter”Parse a plan document with ParsePlans and handle file close errors.
Call Reload from your file watcher or configuration callback. Invalid input preserves the old set. This executable reload example replaces a daily cap:
func ExampleLimiter_Reload() { l := quota.MustNew(quota.Config{Store: quota.NewMemoryStore( quota.MemoryConfig{}, ), Plans: []quota.Plan{{ID: "team", Limits: []quota.Limit{ quota.PerDay("daily", 10), }}}}) if err := l.Reload([]quota.Plan{{ID: "team", Limits: []quota.Limit{ quota.PerDay("daily", 20), }}}); err != nil { panic(err) } fmt.Println(l.Plans()[0].ID) // Output: team}Keep shared limit IDs compatible when changing plans. Do not reuse an ID for another meter or scope to reset usage implicitly. To reset usage, use the reset action.
Share budgets through Redis or Valkey
Section titled “Share budgets through Redis or Valkey”Create a dedicated client through redisstore.Options. The helper enables context deadlines and disables hidden retries. New validates effective settings without connecting. This example prints true for a remote store; it does not establish backend connectivity:
func ExampleNew() { client := redis.NewClient(redisstore.Options(&redis.Options{Addr: "localhost:6379"})) defer func() { if err := client.Close(); err != nil { panic(err) } }() store, err := redisstore.New(client, redisstore.Config{}) if err != nil { panic(err) } fmt.Println(store.Remote()) // Output: true}Use the corresponding helper for Sentinel or Cluster:
func ExampleFailoverOptions() { o := redisstore.FailoverOptions(&redis.FailoverOptions{ MasterName: "primary", SentinelAddrs: []string{"localhost:26379"}, }) fmt.Println(o.MasterName, o.ContextTimeoutEnabled, o.MaxRetries) // Output: primary true -1}func ExampleClusterOptions() { o := redisstore.ClusterOptions(&redis.ClusterOptions{Addrs: []string{ "localhost:7001", "localhost:7002", "localhost:7003", }}) fmt.Println(len(o.Addrs), o.ContextTimeoutEnabled, o.MaxRetries) // Output: 3 true -1}Supply the resulting store to quota.Config.Store. Use a scoped Prefix, Redis noeviction, and an application-owned client lifecycle. Close limiter-owned work before closing that client. Mark paid limits strict so a store outage produces 503 instead of unrecorded usage. Exercise your backend with authenticated, namespaced test resources before enabling billed traffic.
Observe decisions and respond to problems
Section titled “Observe decisions and respond to problems”Attach hooks for decisions and lifecycle events to your own telemetry providers. This example prints event names and bounded result values:
func ExampleHooks() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() hooks := quota.JoinHooks(quota.Hooks{ OnDecision: func(_ context.Context, _ quota.Request, d quota.Decision) { fmt.Println( "decision", d.Op, d.Allowed, ) }, OnConsumption: func(_ context.Context, e quota.ConsumptionEvent) { fmt.Println( "consumption", e.Meter, e.Amount, ) }, OnThreshold: func(_ context.Context, e quota.ThresholdEvent) { fmt.Println( "threshold", e.Limit, e.Percent, ) }, }, quota.Hooks{ OnSettlement: func(_ context.Context, o quota.SettlementObservation) { fmt.Println("settlement", o.Source, o.Outcome, o.Refunded) }, OnCredit: func(_ context.Context, o quota.CreditObservation) { fmt.Println( "credit", o.Outcome, ) }, OnRelease: func(_ context.Context, o quota.ReleaseObservation) { fmt.Println( "release", o.Outcome, ) }, OnReset: func(_ context.Context, o quota.ResetObservation) { fmt.Println( "reset", o.Outcome, len(o.Limits), ) }, OnOperation: func(_ context.Context, o quota.OperationObservation) { fmt.Println("operation", o.Op, o.Outcome, o.CrossingCount) }, }) l, err := quota.New(quota.Config{Store: quota.NewMemoryStore( quota.MemoryConfig{}, ), Plans: []quota.Plan{{ID: "p", Limits: []quota.Limit{ quota.PerDay("day", 100).Thresholds(50), quota.PerDay("tokens", 100).Meter("tokens"), quota.Credits("credits").Meter("credits"), }}}, Hooks: hooks}) if err != nil { panic(err) } defer func() { if err := l.Close(ctx); err != nil { panic(err) } }() id := quota.Identity{Tenant: "example"} if _, err := l.Allow(ctx, quota.Request{ Identity: id, Cost: quota.Cost{}, }); err != nil { panic(err) } if _, err := l.AddCredits(ctx, quota.CreditRequest{ Identity: id, Limit: "credits", Amount: 10, ID: "invoice", }); err != nil { panic(err) } res, d, err := l.Reserve(ctx, quota.Request{Identity: id, Cost: quota.Cost{ "requests": 60, "tokens": 10, }}) if err != nil || !d.Allowed { panic("reserve failed") } for range 2 { if _, err := l.Settle(ctx, res, quota.Cost{ "requests": 50, "tokens": 5, }); err != nil { panic(err) } } if err := l.Release(ctx, d); err != nil { panic(err) } if _, err := l.Reset(ctx, quota.ResetRequest{Identity: id, Plan: "p"}); err != nil { panic(err) } // Output: // decision allow true // operation allow local_noop 0 // credit applied // operation credit applied 0 // decision reserve true // operation reserve applied 0 // consumption requests 50 // consumption tokens 5 // threshold day 50 // settlement call applied true // operation settle applied 1 // settlement call replayed true // operation settle replayed 0 // release local_noop // operation release local_noop 0 // reset applied 2 // operation reset applied 0}Use quota telemetry for provider setup and shutdown. Use Stripe export for durable event delivery. Add quotaadmin for operator inspection; enable credit/reset actions only with explicit operator and audit policy.
On 429, retain Retry-After and show the violated budget. On 402 insufficient credits, fund the balance or reduce work. On 403 cost exceeds limit, split or reduce the request; waiting cannot make it fit. Show a strict-store 503 as an outage, not as the customer running out of quota.