04 - Budget engine¶
The budget engine is the heart of Tapyr. Its contract: no request leaves Tapyr unless every applicable limit allows it, and quota is spent according to priorities, with a reserve for retries.
Concepts¶
- Limit: a constraint declared in a provider definition (
spec.limits). - Scope: the key that partitions a limit. A limit with scope
[account, var:organizationId]is a separate counter for every (account, organization) pair. - Scope key: the concrete value of a scope for a request, e.g.
acme-meraki/org=549236. - Request descriptor: what the executor passes to the engine for each request (provider, account, target, credential, templated endpoint, variables, job, priority, retry flag, auth-request flag).
- Reservation: a grant to send one request, possibly obtained after waiting. Reservations are released (concurrency) and settled (quota sync) after the response.
Limit kinds¶
rate - token bucket¶
- name: org-rate
kind: rate
rate: 10/s # refill rate
burst: 10 # bucket capacity (default: rate per second, minimum 1)
scope: { by: [account, "var:organizationId"] }
In-memory. Requests wait for a token (bounded by the request's deadline). When the burst is 2 or more, a request also leaves one token in the bucket: requests leave at the pace of the limit but reach the provider with a varying latency, and a provider bucket of the same size would refuse one that arrives slightly early. The token in hand keeps the provider's bucket one token ahead, which absorbs up to one interval of jitter; the sustained rate is unchanged and a burst is one request shorter. The simulator enforces the full burst.
concurrency¶
- name: ip-concurrency
kind: concurrency
max: 10
scope: { by: [instance] }
In-memory semaphore. Released when the response is fully read or the request fails.
quota - fixed window¶
- name: daily
kind: quota
max: 5000 # or "auto" (learned from headers, see Header sync)
# initialMax: 5000 # required with max: auto, used until a header is seen
window:
period: day # minute | hour | day | week | month
resetAt: "00:00" # for day/week/month
timezone: UTC # IANA name
weekStart: monday # for week
monthDay: 1 # for month
scope: { by: [account] }
sync:
limitHeader: X-RateLimit-Limit-day
remainingHeader: X-RateLimit-Remaining-day
resetHeader: "" # optional: header carrying reset time (epoch seconds or delta)
Persistent. Window boundaries are computed from the calendar in the declared timezone (DST-safe).
- Defaults:
resetAt: "00:00",timezone: UTC,weekStart: monday,monthDay: 1. resetAtisHH:MM(24h). A reset time that does not exist on a given day (DST gap) is normalized forward, following Go'stime.Datesemantics.monthDayis 1 to 31; in shorter months the window resets on the last day of the month.minuteandhourwindows are aligned on the clock in the declared timezone.max: autorequiresinitialMax(a conservative value used until the first limit header is observed) andsync.limitHeader.
quota - rolling window¶
- name: rolling-24h
kind: quota
max: 10000
window: { rolling: 24h }
scope: { by: [credential] }
Persistent. Implemented with per-minute buckets (bounded memory, 1-minute precision).
Applicability filters¶
A limit applies to a request only if its scope can be resolved and its filters match:
appliesTo:
endpoints: ["/organizations/**"] # glob on templated path
methods: [GET]
requestsWithVar: organizationId # only if this variable is set
authRequests: include # include (default) | only | exclude, see Auth limits
Endpoint globs match the templated path segment by segment: * matches within one segment, ** matches zero or more whole segments. /organizations/* matches /organizations/{organizationId} but not /organizations/{organizationId}/networks; /organizations/** matches both.
Auth limits¶
Token requests (OAuth, login) are budgeted too. appliesTo.authRequests selects which requests a limit counts:
include(default): both regular and auth requests,only: auth requests only (for example a dedicated limit on token creations per hour),exclude: regular requests only.
Without a dedicated limit, auth requests therefore count against every generic limit that does not exclude them.
Scope dimensions¶
| Dimension | Meaning |
|---|---|
instance |
The whole Tapyr process (approximates "per source IP") |
provider |
All accounts of the provider in this process |
account |
One Account |
target |
One target of a multi-target Account |
credential |
One credential set (accounts sharing the same secret values share this key; computed as a salted hash, never logged; the salt is generated randomly once and stored in the state store so ledger keys stay stable across restarts) |
endpoint |
The templated path |
var:<name> |
Value of a request variable (from discovery or params) |
An empty by is equivalent to [instance].
Reservation algorithm¶
For each request:
- Collect applicable limits and compute scope keys.
- Reject immediately if:
- a scope is penalized (after a 429 with
Retry-After) and the penalty outlasts the request deadline, - a quota scope is exhausted for the current window (taking budget share and reserves into account),
- the account's circuit breaker is open.
The refusal carries a reason (
quota_exhausted,penalized,circuit_open,deadline). - Otherwise, acquire every applicable limit atomically under the engine lock: quota units (check and increment), concurrency slots and rate tokens. When a concurrency slot or a rate token is not available, the request acquires nothing and waits; if the wait would exceed the deadline, it is refused with
deadline. - Return the reservation.
Waiting is priority-aware: when several requests wait on the same scope, higher priority goes first (critical > high > normal > low), FIFO within a priority.
Quota accounting with share and reserve¶
For a quota limit with effective maximum M (after header learning) and account budget.share s:
- Usable budget:
U = floor(M * s). - Retry reserve:
R = floor(U * retryReserve)(global setting, default 10%). - Normal requests may consume up to
U - R. - Retries may consume up to
U. criticaljobs may dip into the reserve for normal requests once the normal budget is exhausted (they keep running, retries for others stop first).- When a scope key is shared by several accounts with different shares (scopes without
accountortarget, such asinstance,providerorcredential),sis the smallest share among the accounts contributing to that key.
Header sync¶
After each response, the engine reads the sync headers of every applicable quota limit:
- Learn the limit: if
max: auto(or if the header disagrees with the declared max), the effective max becomes the header value. Disagreement is logged once per window and exposed as a metric. - Learn the remaining: with
Mthe effective max (whole quota, as reported by the provider),U = floor(M * s)the usable budget,cTapyr's own consumption in the window andr_obsthe observed remaining (whole quota): - observed total consumption:
C_obs = M - r_obs, - external consumption (other tools using the same quota):
external = max(0, C_obs - c), exposed astapyr_budget_external_usage, - Tapyr's remaining budget:
remaining = min(U - c, r_obs). Tapyr never exceeds its share and never exceeds what is actually left. - Reset: if a reset header is declared, it overrides the computed window end.
Headers are matched case-insensitively. Missing headers are ignored silently.
429 handling¶
When a response matches errors.rateLimited:
- Determine the penalty duration from
Retry-After(seconds or HTTP date), else from the provider default (errors.rateLimited.defaultPenalty, default 2s), with exponential growth on consecutive 429s for the same scope (cap 10 minutes). - Apply the penalty to the narrowest scope among applicable limits by default, or to all applicable scopes if
errors.rateLimited.penalize: all. - If the response carries quota headers showing remaining = 0 for a quota limit, mark that quota scope exhausted until window end.
- Count
tapyr_budget_rate_limited_totalwith the limit name. An unexpected 429 (no internal limit predicted exhaustion) is also counted astapyr_budget_unexpected_429_total: it means the provider definition is wrong or the quota is shared.
Ledger persistence¶
- Quota counters are persisted:
(limit, scope_key, window_start) -> used, observed_remaining, observed_max, updated_at. - Rolling windows persist per-minute buckets for their duration.
- On startup, current-window counters are loaded. Expired windows are discarded.
- Flush cadence: every 5 seconds and on shutdown. The engine tolerates losing the last seconds (header sync corrects).
Planner¶
The planner turns desired intervals into effective intervals that fit every quota.
Inputs¶
- Jobs with desired interval, priority, fan-out (number of child instances, from inventory cache or estimate), estimated requests per run (pages, from run history: rolling median of the last 20 runs; else
estimate.pagesPerRun; else 1), retry overhead estimate (rolling ratio, default 5%). - Quota limits with usable budget for the current window and time remaining in the window.
- Rate limits (to check feasibility of bursts: a run's requests must fit in the interval at the limit's rate).
Algorithm¶
- For each quota scope, compute the projected consumption per window of every job contributing to it:
calls_per_run * fanout * runs_per_window. - If the sum fits
U - R, effective intervals equal desired intervals. - Otherwise, stretch intervals starting from the lowest priority class: within a class, apply a common multiplier
k >= 1to all jobs of that class contributing to the scope, choose the smallestkthat makes the sum fit (or stretch up tosettings.budget.maxStretch, default 12x, then move to the next class). criticaljobs are never stretched. If critical jobs alone do not fit, the plan is infeasible:validateandplanreport an error,runstarts anyway with a loud warning and the status API flags it.- A job contributing to several quota scopes takes the largest stretch required by any of them.
- Mid-window replanning (every
replanInterval, on reload, on inventory change, on header-learned max change): recompute using the remaining budget and the remaining time in the window, so a burst of failures in the morning tightens the afternoon instead of exhausting the quota. The remaining budget ismin(U - c, r_obs)(see Header sync), less the reserve, less the consumption of other clients projected to the end of a calendar window at their pace so far (external * time_left / time_elapsed, the elapsed time counting as at least 1/24 of the window): another integration sharing the quota keeps spending until the reset. - Rate-limit feasibility: if a run's estimated calls cannot complete within its interval given applicable rate limits, the interval is stretched to the minimum feasible value.
Outputs¶
For each job: desired interval, effective interval, stretch factor, contributing scopes, projected calls per window. For each quota scope: budget, projected consumption, projected exhaustion time (if any), feasibility.
The same planner code runs offline in tapyr plan, which can take inventory size hints (--fanout meraki-organizations=3) when no inventory cache exists.
Projections¶
For each quota scope, the engine continuously computes:
- consumption rate over the last 15 minutes and since window start,
- projected end-of-window consumption,
- projected exhaustion time (
tapyr_budget_exhaustion_eta_seconds, absent if not projected to exhaust).
Implementation notes¶
Decisions taken when implementing the engine:
- Single engine lock. All limit state lives behind one mutex. API polling volumes are low enough, and a single lock makes multi-limit acquisition trivially atomic (no partial acquisition, no lock ordering).
- Waiting. A request that cannot acquire every applicable limit registers as a waiter and sleeps until a limit is released, a token refills or a penalty ends (timers on the injected clock), or its deadline. A waiter may acquire only when no waiter of higher priority (or of the same priority and older) is waiting on one of its scope keys. Waiters hold nothing while they wait, so there is no deadlock.
- Deadline. When the next token of a rate limit or the end of a penalty comes after the request deadline, the request is refused at once with
deadlineinstead of waiting in vain. - Quota counting. Quota units are counted when the reservation is granted, not when the response arrives: a request that fails still consumed provider quota.
- Remaining after an observation.
r_obsis valid at the time of the response that carried it. Until the next observation, the engine usesr_obs - (c_now - c_obs), wherec_obsis Tapyr's consumption whenr_obswas observed. - Reset header. A value greater than
1e9is an epoch in seconds; otherwise it is a delay in seconds. - Circuit breaker. The breakers live in the engine (it refuses with
circuit_open); the executor reports successes and transient failures. - Credential key.
HMAC-SHA256(salt, secret values), hex-truncated to 16 characters; the salt is stored in the state store. - Enforcer mode (simulator). The same limit evaluation answers, without waiting, whether a request is allowed now; otherwise the simulator answers
429withRetry-After(time to the next token, end of the penalty or end of the quota window). Server-side scope keys:account,targetandcredentialmap to the presented credential,instanceandproviderto the whole simulator,endpointto the route path,var:<name>to the route variable. - Timeline. Every quota scope keeps its consumption per bucket (1 minute for windows up to one day, 1 hour beyond) for the current and the previous window, for the status API. Fixed windows persist the buckets of the current window in
ledger_minutes, as rolling windows already do. - Observer. The engine reports grants (with the wait), refusals and rate-limited responses to an optional observer, the source of the budget self-metrics.
Testing requirements¶
- Fake clock for everything. Daily windows across DST changes, month boundaries, leap years, timezone offsets.
- Property tests: under random request streams, no limit is ever exceeded.
- Concurrency tests with the race detector: no partial acquisition, no deadlock with overlapping scopes.
- Planner golden tests: given inputs, expected effective intervals.
- Simulation test (with the simulator, accelerated fake clock): 24 simulated hours, undersized quota, verify priorities and zero quota-related 429s.