# Rate Limits — Calibration & API Landscape

**TL;DR:** The per-principal rate limits are **derived from real production usage** (Datadog APM, 7-day window) and structured as **burst + sustained** multi-limits. That places them sensibly within the broader API landscape: **looser** than public third-party developer APIs (because ows-royalties is a first-party, internal API), and **much tighter** than hyperscale data planes. The *shape* matches industry best practice; the *numbers* are tuned to observed first-party traffic rather than picked by analogy.

---

## The limits

Keyed **per principal** — the `graphql-abacus` gateway forwards the end-user `identity_id`, so each authenticated user (≈900 of them) gets their own budget; an anonymous service or IP is bucketed the same way, with the same finite limit (no special tier).

| Category | Limits (enforced together) | Sustained equivalent |
|---|---|---|
| **READ** | `150/second` + `1000/minute` | ~17 req/s |
| **WRITE** | `30/second` + `200/minute` | ~3.3 req/s |
| **EXPENSIVE** | `25/minute` | ~0.4 req/s |
| Default (unannotated) | `1000/minute` | ~17 req/s |

**Strategy:** `sliding-window-counter` — smooth enforcement that avoids the fixed-window "2× at the window seam" edge burst. **Rollout:** values ship in `shadow` mode (count + log, never block) and are confirmed against would-be-429 counts before `enforce`.

---

## Derived from real usage (prod Datadog APM, 7 days)

- **Reads dominate and fan out on page load.** The SPA loads a page by firing many `*/dataloader` batch reads at once: a single principal **bursts to ~106 req/s** then settles. → the `150/second` burst clears legitimate fan-out with margin.
- **Sustained per-principal per-minute is usually 200–300**, with a **rare bulk-load spike of 1,342/min** observed once across the week. → `1000/minute` is sized to *pass heavy real minutes*, not tightened below them. A tighter cap (400–600) would 429 legitimate bulk loads.
- **Writes are sparse.** The busiest mutation endpoint runs ~45/day (well under 1/s) and never bursts from real traffic. → `200/minute` is the sustained guard; the `30/second` is generous defense-in-depth that still clears a multi-field "save all" fan-out.
- **Expensive operations** (Snowflake / report / bulk) are rare and heavy. → `25/minute` is the real protective cap; bursting a heavy op makes no sense, so it carries no `/second`.

The limits therefore **catch abuse and runaway clients, not normal load** — the heavy backend protection comes from the circuit breaker + per-resource timeouts + the tight `EXPENSIVE` cap.

## Why burst + sustained (the multi-limit shape)

A category may carry more than one limit, enforced together, so a tight sustained cap can coexist with a short burst — conceptually a **token bucket** (refill rate = sustained, bucket depth = burst). The `limits`/`flask-limiter` stack ships no token-bucket strategy, so two limits (`burst;sustained`) is the closest native equivalent, and the right engineering call over a custom bucket implementation (see DECISION_LOG).

---

## Where this sits in the API landscape

Public-doc figures, approximate (as of ~2025–2026), normalized toward per-principal where possible:

| API | Read limit | Burst | Unit |
|---|---|---|---|
| **ows-royalties (this kit)** | **1,000/min (~17/s)** | **150/s** | per user |
| GitHub REST | ~83/min (5k/hr) | secondary caps | per user/token |
| Slack Web API | 20–100/min (tiered) | minimal | per workspace/method |
| Shopify REST | 120/min (2/s) | bucket of 40 | per app |
| Google Sheets | 60/min/user (300/min/project) | — | per user/project |
| Stripe | 6,000/min (100/s) read & write | — | per account |
| Google Maps (some) | ~30,000/min (500/s) | — | per project |
| AWS API Gateway | 600,000/min (10k/s) | 5,000 burst | per account |
| AWS S3 (data plane) | ~330,000/min (5,500/s GET) | — | per prefix |

**Magnitude:** ows-royalties sits **in the middle** — looser than public developer APIs (GitHub, Slack, Shopify), much tighter than hyperscale data/infra planes (Stripe, Maps, API Gateway, S3).

**Why that's correct for us — the client is the deciding factor:**

- **GitHub / Slack / Shopify are tight** because they're *public* APIs facing untrusted third-party developers writing aggressive polling clients; the limit rations a shared commons and forces good client design.
- **Stripe / AWS / Maps are loose** because they're high-volume machine-to-machine data planes where throughput *is* the product.
- **ows-royalties is neither.** It is a **first-party internal API behind a gateway**, serving a known set of authenticated Orchard accounting users through our own SPA. The limit's job is narrow — stop a runaway/buggy client or a single user monopolizing capacity, not police a public commons — so generous, usage-calibrated limits are appropriate, and being looser than GitHub is correct rather than a red flag.

**Shape — aligned with best practice:**

- **Two-tier burst + sustained** mirrors the token-bucket pattern used by AWS API Gateway (10k/s steady + 5k burst), Shopify (2/s + 40 bucket), and Stripe.
- **Per-principal keying** matches GitHub (per-token) and is *finer* than Google/AWS (per-project/account).
- **Per-category tiers** (READ / WRITE / EXPENSIVE) mirror Slack's per-method tiers and Google's per-method quotas.
- **Smoothing strategy** (`sliding-window-counter`) serves the same purpose as their leaky/token buckets.

**Deliberately not adopted (YAGNI):** cost-based "points" budgets (GitHub GraphQL, Shopify, Atlassian) — potentially relevant for the upstream GraphQL gateway, overkill for this REST backend; and dozens of per-endpoint tiers — three categories cover our surface.

---

## Bottom line

Structurally we're squarely in line with how Google, AWS, Stripe, and Shopify build rate limits. Numerically we're mid-range — appropriately looser than untrusted-public-developer APIs and tighter than hyperscale data planes. Most importantly, the numbers are **empirically calibrated to observed first-party traffic** (the ~106 req/s fan-out, the 1,342/min bulk outlier, the sub-1/s write rate) rather than chosen by analogy — which is the right method regardless of where any single competitor draws its line.
