Language-agnostic spec for the Meraki proactive rate limiter. The Python
reference implementation is meraki/smart_flow.py; this document defines the
behavior so any implementation (Python, PowerShell, .NET, …) can be verified
against the same conformance vectors in smartflow-vectors.json.
Goal: prevent 429s before they happen by throttling per-organization request rates locally, plus a global per-source-IP ceiling, learning which org each network/device belongs to as traffic flows.
The primitive. One bucket = one rate limit.
State: rate (tokens/sec), capacity (max tokens), tokens (current, float),
last (monotonic timestamp of last refill).
Init: tokens = capacity, last = monotonic now at creation.
(The async reference lazily sets last on the first acquire; equivalent to
setting it at creation when no time passes before the first call.)
acquire() — the whole algorithm, executed atomically per bucket:
now = monotonic()
elapsed = now - last
tokens = min(capacity, tokens + elapsed * rate) # refill
last = now
tokens = tokens - 1 # deduct unconditionally
wait = (-tokens / rate) if tokens < 0 else 0 # deficit -> sleep
if wait > 0: sleep(wait)
Critical properties, all load-bearing for parity:
- Deduct unconditionally; tokens may go negative. The negative value is the
reservation. Concurrent callers each compute their own
waitagainst the accumulated deficit instead of colliding on the same instant. Do not clamp tokens at zero. - Reserve under lock, sleep outside it. Compute
waitwhile holding the per-bucket lock, release, then sleep. Serializing the sleeps destroys burst parallelism. ratehas a floor of 0.5. Settingrateto anything lower clamps to0.5(prevents AIMD decrease from stalling a bucket to zero throughput).
| Bucket | rate | capacity |
|---|---|---|
| Per-org | 10.0 (SDK config default surfaces 9) |
10 |
| Global (per source IP) | 100.0 |
100 (int(global_rate)) |
Meraki enforces ~10 req/s per org and ~100 req/s per source IP. Per-org default is set below the ceiling to reserve headroom for other apps.
Three regexes, searched (not full-match) against the request URL:
| Identifier | Pattern | Capture |
|---|---|---|
| org | /organizations/([^/]+) |
org id |
| network | /networks/([^/]+) |
network id |
| device | /devices/([^/]+) |
serial |
resolve_org(url) returns the first that resolves:
- org id directly from URL → return it
- else network id from URL → look up
network_to_org[net](may beNone) - else serial from URL → look up
serial_to_org[serial](may beNone) - else
None
global_bucket.acquire() # always, first
org = resolve_org(url)
if org:
bucket(org).acquire() # get-or-create org bucket
else:
resolve_inline(url) # sync ref; async fires a background task
org = resolve_org(url)
if org: bucket(org).acquire()
Global bucket is charged on every request regardless of org resolution. An unresolved request pays only the global cost until its org is learned.
Sync vs async divergence (the one intentional difference):
- Sync
resolve_inlinecalls the resolver inline and blocks, so a freshly resolved org's bucket is acquired in the same call. - Async
_trigger_background_resolvefires a one-shot background task and does not block; the org bucket is acquired on subsequent calls once learned.
An implementation without async (e.g. PowerShell sync) follows the sync path.
Additive-increase / multiplicative-decrease on top of the static limits.
on_rate_limited(url) (called on a 429):
org = resolve_org(url)
if org has a bucket:
bucket.rate *= 0.7 # multiplicative decrease
elif url is an "unresolved scoped url":
skip # do NOT penalize global
else:
global_bucket.rate *= 0.7
Unresolved scoped URL = URL has a network or device component but no
explicit /organizations/<id>. The offending org is specific but unknown;
penalizing the global bucket would punish every other org for one org's 429, so
skip and let resolution catch up.
on_success(url) (called on a 2xx):
org = resolve_org(url)
if org has a bucket and bucket.rate < configured_org_rate:
bucket.rate = min(configured_org_rate, bucket.rate + 0.2) # additive increase
if global_bucket.rate < configured_global_rate:
global_bucket.rate = min(configured_global_rate, global_bucket.rate + 0.5)
Increments: +0.2 per-org, +0.5 global. Decrease factor: ×0.7 both.
Remember the 0.5 rate floor from §1 bounds the decrease.
learn_from_response(url, body) — called on successful GET responses to
populate the net→org / serial→org caches.
Resolve the governing org first:
- org id from URL (
/organizations/<id>), else - org id from body:
body.organizationId, elsebody.organization.id - if neither → return (nothing learned)
Then record mappings (only counting ones that change an existing value):
- network id from URL →
network_to_org - serial from URL →
serial_to_org - from body:
body.networkId,body.serial,body.network.id
Each changed mapping increments a dirty counter (see §7).
Explicit registration API (used by eager hydration / resolver callbacks):
register_org(org), register_network(net, org), register_device(serial, org).
Persists learned mappings across sessions so warm runs skip re-learning.
Format (rate_limit_cache.json, default ~/.meraki/.cache/):
{
"saved_at": "2026-07-08T12:00:00Z",
"networks": [{ "id": "N_1", "organization": { "id": "O_1" } }],
"devices": [{ "serial": "Q2XX-XXXX-XXXX", "organization": { "id": "O_1" } }]
}saved_at is UTC, format %Y-%m-%dT%H:%M:%SZ (trailing Z, no offset).
Load / freshness:
- No file → start empty.
- TTL default
604800s (7 days).None= never expire. - Expired if
saved_atmissing, unparseable, ornow - saved_at > ttl→ ignore file, rebuild.cache_freshstays false. - On fresh load, populate both maps; set
cache_fresh = true. - Malformed JSON / missing keys / IO error → swallow, start empty.
Flush cadence: when dirty >= 50, save and reset the counter. Async saves on
a background thread and only subtracts the flushed count on success (unsaved
mappings are retained, not lost, if the write fails). Async shutdown() drains
in-flight resolves + pending flush, then does a final save.
Hooks the SDK wires in so the limiter can fill its cache without knowing about HTTP:
- resolver
(id_type, identifier) -> org_id | None,id_type ∈ {"network","device"}. Called for an unresolved id. Guarded by apending_lookupsset so the same id isn't resolved twice concurrently. - hydrator
(org_id) -> void. Called once per org (tracked inhydrated_orgs) after first resolution, to bulk-register all of that org's networks/devices via the register APIs.
smartflow-vectors.json encodes deterministic cases for each section above
(bucket math with injected timestamps, URL resolution, AIMD, learning, cache
freshness). An implementation is conformant if it reproduces every vector's
expected output. See that file's notes field per case.