Skip to content

feat(0157): default key limits of 1 req/s + 100k monthly quota - #195

Open
adamkoot wants to merge 4 commits into
docs/0156_self-service-auth-assumptionsfrom
feat/0157_default-key-limits-1rps-monthly-quota
Open

feat(0157): default key limits of 1 req/s + 100k monthly quota#195
adamkoot wants to merge 4 commits into
docs/0156_self-service-auth-assumptionsfrom
feat/0157_default-key-limits-1rps-monthly-quota

Conversation

@adamkoot

@adamkoot adamkoot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Self-issued keys drop from 100 req/s + 10 000/day to 1 req/s, burst 5, 100 000 requests/month. One usage plan, renamed in place (pricing-api-free-<env>) rather than a second plan beside it — every property of AWS::ApiGateway::UsagePlan is No-interruption, and the partner key served 14 requests in 30 days, all our own verification curls.
  • Config fields renamed so scope comes first: apiKeyRateLimit/apiKeyBurstLimitselfServicePlanRateLimit/BurstLimit, apiGatewayPartnerDailyQuotaselfServicePlanMonthlyQuota. apiGateway* is now reserved for gateway- and stage-level settings.
  • Synth-time validation replaces apiGatewayThrottleRate >= apiKeyRateLimit with a proportionality ratio — a plan limit may be at most one tenth of the stage's default per-method limit. Its job is narrow: stop the design doc's 100 req/s being reinstated by typo.
  • Plan id published as /prices/{env}/pricing-api-free-plan-id so the onboarding backend (0160) can read it without closing the Compute → ApiGateway cycle.
  • New runbook docs/runbooks/manual-api-key-tier.md for the hand-issued tier above self-service, plus a docs sweep: the seven utoipa 429 descriptions ship in the public OpenAPI document, so "daily quota" was a public statement this deploy makes false.

Deploy impact

The production API key rotates. The construct id changes PartnerApiKeySelfServiceApiKey, so CloudFormation removes prices-production-partner-key and adds prices-production-selfservice-key with a new value. Every holder of the old key is cut off on deploy. Deliberate — the old key's value was exposed by get-usage-plan-keys during the usage check, so it needed rotating regardless.

Base

Stacked on #187, not develop — that PR rewrites this task's quota-reset section and is still open. Merge #187 first.

Branched from `docs/0156_self-service-auth-assumptions` rather than
`develop`: PR #187 rewrites this task's quota-reset section — the
calendar alignment of the monthly quota is unverified, so the
single-date rework boundary is our design intent until 0171 #7
measures it. Building from `develop` would implement the superseded
spec.

Committed here instead of pushed straight to `develop` (the usual
promote-task path) because PR #187 modifies the same file in
`backlog/`; moving it on `develop` would leave that PR with a
modify/delete conflict.
The deployed usage plan gave a key 100 req/s and 10 000/day — a shape
designed for one hand-issued partner key, not a default for keys anybody
can mint by signing in with Discord. Self-issued keys now get 1 req/s
sustained, burst 5, and 100 000 requests per calendar month.

One plan, renamed in place, rather than a second plan beside it. Every
property of AWS::ApiGateway::UsagePlan is Update-requires-No-interruption,
so the rename carries no replacement risk, and the partner key served 14
requests in 30 days — all our own verification curls — so there was no
working partner to protect. The plan's logical id (ApiUsagePlanDBBE8AB1)
is unchanged, so CloudFormation updates the deployed plan in place rather
than creating a second one.

Config fields renamed so scope comes first, then property:
apiKeyRateLimit and apiKeyBurstLimit become selfServicePlanRateLimit and
selfServicePlanBurstLimit, apiGatewayPartnerDailyQuota becomes
selfServicePlanMonthlyQuota. apiGateway* is now reserved for gateway- and
stage-level settings. CloudFormation sees only the values, so nothing in
AWS is touched by the rename.

Validation drops the old apiGatewayThrottleRate >= apiKeyRateLimit check
(justified as letting a key reach its per-key SLA — self-service carries
no SLA) for a proportionality ratio: a plan limit may be at most one
tenth of the stage's default per-method limit, applied to rate and burst.
At 200/400 that permits 1/5 and rejects 100. Its job is narrow — stop the
design doc's 100 req/s being reinstated by typo.

The plan id is published as /prices/{env}/pricing-api-free-plan-id
because the backend that will issue keys (0160) lives in ComputeStack, a
dependency of ApiGatewayStack, so it cannot read the plan object without
closing the cycle.

BREAKING CHANGE: the API key construct id changes from PartnerApiKey to
SelfServiceApiKey, which changes its logical id — CloudFormation removes
prices-production-partner-key and adds prices-production-selfservice-key
with a new value. On deploy every holder of the old key is cut off. This
is deliberate: the old key's value was exposed by get-usage-plan-keys
while checking its usage, so it needed rotating regardless.
The seven utoipa 429 descriptions are a consumer surface outside the
repo — they ship in the OpenAPI document served at GET /api-docs-json —
so "Rate limit or daily quota exceeded" was a public statement the deploy
makes false. Same for the auth and config doc comments that named 100
req/s as the gateway-side limit.

docs/runbooks/manual-api-key-tier.md is the procedure for the tier above
self-service: which plan a hand-made key attaches to, that payment is
handled by bank transfer outside the product, and a registry table for
what was issued. A hand-made plan never shows up in cdk diff and nobody
reviews it, so recording the resource is a required step rather than an
afterthought. The runbook also pins the credential-handling rule the
usage check ran into: read keys with get-api-keys without
--include-values, never get-usage-plan-keys, which returns the plaintext
value with no flag to suppress it.

The loadtest README gains the constraint 0121 now has to work around:
after this change no key in the account can sustain 100 req/s, so a run
against pricing-api-free-production measures our own throttle and spends
a third of the month's allowance. 0121 must provision its own plan.
The task shipped past its own Summary and the record now says so. Three
things the plan asserted turned out wrong and are corrected in place
rather than quietly dropped:

- Two plans became one, renamed. UsagePlanName is No-interruption, so the
  replacement risk that justified leaving partner-plan alone did not
  exist, and nobody was using the partner key anyway.
- The stage throttle is per-method, not an aggregate stage pool
  (DD 5b). Three pieces of reasoning built on the aggregate reading had
  to be rewritten, and the epic's "10 keys saturate the 1000 req/s global
  burst" premise joins db.t4g.micro and the $900 figure as premises that
  do not map onto this deployment. The conclusion is unaffected.
- The key rotates via the construct-id change (remove + add), not via
  ApiKey.Name replacement — corrected against the synthesized template.

Also separated two claims that were previously asserted as one fact: a
cached response still costs the caller a request is documented for
billing, but that the quota decrements before the cache lookup is our
inference — AWS's documented throttling order never mentions the cache.

0121 and 0047 pick up the consequence: no key in the account can sustain
100 req/s after this, so the load test has to provision its own plan and
say which plan the key was on.
@adamkoot
adamkoot requested a review from karczuRF August 11, 2026 11:29
@karczuRF

Copy link
Copy Markdown
Collaborator

Review — no correctness bug found

Verified by synthesizing both the base and head templates for Prices-production-ApiGateway and diffing the resources. The central claims hold: the usage plan updates in place under logical id ApiUsagePlanDBBE8AB1 (quota 10000/DAY100000/MONTH, throttle 100/2001/5, name → pricing-api-free-production); the API key is a remove + add (ApiPartnerApiKey8034B29DApiSelfServiceApiKey90815276 plus its UsagePlanKey); PricingApiFreePlanIdParamF6026654 is added. tsc --noEmit and prettier --check clean on all changed files.

All 7 apiKeyRequired: true methods in the synthesized template match the 7 updated utoipa 429 descriptions — the sweep is complete. cicd.json uses a separate CicdConfig type, so the config rename has no second env to miss, and nothing in the BE repo consumes the old key.

Findings below are all documentation/operability rather than logic.

Medium

1. docs/prices-api-general-overview.md:1096 — stale rate limit in the performance table

§6 still states Request throttling (100/s per API key, 1000/s global burst). That's the only remaining stale per-key figure in the design doc, and the rationale for sweeping the seven 429 strings — a public statement this deploy makes false — applies to it verbatim.

The pointer added at line 164 compounds it: it says 0157 supersedes the 100 req/s in §2.1/§7, but §2.1 is line 164 (already corrected) and §7 states no numeric limit. So a reader following the pointer finds nothing, while the number that is now 100× wrong sits in a section the pointer doesn't mention.

2. docs/runbooks/manual-api-key-tier.md:169 — rotation creates a duplicate key name

Step 7 re-runs step 2, which creates the replacement with the identical name prices-production-${CUSTOMER}-key. API Gateway does not enforce unique key names, so during the overlap two live keys share one name. The recovery block at line 143 (get-api-keys --name-query … --query 'items[0].id'), offered whenever "the row is missing or stale", then resolves ambiguously — and --name-query is a prefix match, which widens it further.

Concrete failure: a customer stops paying mid-rotation, the operator recovers KEY_ID by name and runs "Suspend without destroying" (line 158), and disables the new key while the old one keeps serving — or the reverse. Fix is one line: give the replacement a distinct name (date suffix) and record it in the registry table.

Low

3. infra/src/lib/types.ts:412 — proportionality guard emits an unsatisfiable instruction

Below a stage rate of 10 the error message cannot be satisfied. If an operator clamps apiGatewayThrottleRate to 5 to shed load during an incident, synth fails with selfServicePlanRateLimit (1) exceeds one 10th of apiGatewayThrottleRate (5) … so the maximum is 0, while the check at line 301 rejects anything below 1. The coupling is acknowledged in the comment at line 399, but the message should say so — clamp the reported maximum at 1 and name the real remedy (lower the plan limits too, or raise the stage back to ≥10).

4. docs/runbooks/manual-api-key-tier.md:140 — missing profile/region re-export

"Change or revoke" states it runs in a shell with none of step 1's variables, then re-exports only CUSTOMER. Against a different default profile, get-usage-plans matches nothing and --output text emits the literal None, which flows into update-usage-plan --usage-plan-id None. It fails loudly rather than mutating the wrong account, hence low — but re-exporting AWS_PROFILE / AWS_REGION costs nothing.

5. infra/src/lib/stacks/api-gateway-stack.ts:392 — the destructive rotation warning doesn't survive merge

make deploy-production runs cdk deploy --all --require-approval broadening, which does not prompt on resource deletion — so the next routine deploy of any stack silently destroys prices-production-partner-key. That hazard is currently documented only in the PR description and the lore task, one of which disappears on merge and the other of which gets archived. Neither infra/README.md nor infra/Makefile carries a note. A one-line comment on the deploy-production target would make it durable.

6. docs/scf/milestone-1-evidence.md:795 — confirmation, not a new issue

Still describes prices-production-partner-plan throttling "each key at 100 req/s (200 burst) with a 10,000/day quota" — a plan name that ceases to exist on deploy, in grant-facing evidence. Already an open AC in the task file; flagging only to confirm it's still genuinely open at PR head.

Notes

  • types.ts:309 — comment says "not redundant with the rate check below it"; that check is above (line 301). Cosmetic.
  • api-gateway-stack.ts:258-268 — the block says dropping the default method entry leaves "only the per-key usage-plan limit", then ten lines later correctly notes the account-level limit sits above the usage plan. Mildly self-contradictory within one comment.
  • The 429 description changes alter the served OpenAPI document, so they take effect only on a Compute redeploy plus a stage-cache flush (deploy-production-apigateway does not flush). Covered by the existing Makefile comment, but "Deploy impact" mentions only the key rotation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants