Skip to content

feat(0188): usage against quota on the dashboard - #227

Merged
adamkoot merged 9 commits into
developfrom
feat/0188_usage-against-quota-on-the-dashboard
Aug 21, 2026
Merged

feat(0188): usage against quota on the dashboard#227
adamkoot merged 9 commits into
developfrom
feat/0188_usage-against-quota-on-the-dashboard

Conversation

@adamkoot

@adamkoot adamkoot commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • GET /api-tokens/api/usage (new portal/usage/ module): the signed-in caller's used / remaining / reconstructed limit from GetUsage, plus period boundaries rendered as our calendar-month UTC rule (ADR 0010 correction Feat/lore 0001 dump swap events #2 — AWS documents no reset instant). Behind 0183's gate, session-only, and read-only by construction: a dashboard load can never create, attach or delete a key
  • 60s in-process cache keyed by caller (covers both control-plane calls); throttling is backed off — SDK retries, then the last good answer re-served with its original as_of, so the page dates staleness honestly instead of erroring
  • Frontend: unstyled "Usage this period" section — three numbers, 1 req/s, the reset date, and the decided-once "last updated / AWS reports usage with a delay" wording (0193 restyles, does not re-decide). A successful key issue evicts a cached "no key"
  • IAM: one statement, apigateway:GET on /usageplans/{planId}/usage, added to 0187's standalone policy; pinned by a new CI assertion (exists, exactly one, no wildcard, gateway stack only). No gateway route change — {proxy+} already covers /usage
  • 25 new Rust tests (the mock control plane now serves GetUsage, wire-field values, with throttle/failure/pagination knobs) + 12 frontend tests; two independent review passes with all findings fixed and the trail recorded in the task file

GET /api-tokens/api/usage reads GetUsage for the caller's key —
used / remaining / reconstructed limit plus our calendar-month period
boundaries — behind the portal gate, read-only by construction (no
create, no attach, no delete on any path).

A 60s in-process cache keyed by caller keeps refresh loops off the
account-wide control-plane budget; a throttled control plane is served
the last good answer with its original as_of rather than an error
page. The lag wording is decided here once, for 0193 to restyle.

IAM: one statement (apigateway:GET on the free plan's /usage) in
0187's standalone policy, pinned by a new CI assertion. Cold-start
plan validation declined, reasoning at the grant.

24 new Rust tests (mock control plane now serves GetUsage), 9 new
frontend tests.
Spec review: fetchUsage no longer reads the gate's empty 404 as
'no key' (envelope code checked); 15s usage timeout so the backend's
503 wins the race against the page's own; CI 5b hardened (suffix
match, wildcard refusal, gateway-template-only); limits render in the
no-key state; the lag line spells UTC, not toUTCString's GMT.

Correctness review: usage failures route through describeFailure (an
expired session reads the same in both sections); a successful issue
evicts a cached 'no key' via a narrow UsageCache handle; a served
stale answer is re-stamped so a throttle event is actually backed off;
cache entries die at the month boundary; the GetUsage query ends
today rather than at a future endDate no live control plane ever
validated; a throttled key lookup is now assertable (throttle_list
knob) and asserted.

+7 Rust tests, +3 frontend tests; findings table in the task file.
…ce, flicker, malformed rows

Four confirmed findings from the branch review:

- the 10s deadline's 503 arm now serves the last good cached answer
  first — a throttle that manifests as SDK-retry latency is the same
  condition as a surfaced 429 and gets the same fallback
- per-caller eviction epochs close the write-after-eviction race: a
  'no key' snapshotted before a concurrent issue is discarded instead
  of cached, so the R2 fix holds under concurrency
- the usage section refetches only out of the no-key state, so
  revealing an existing key no longer blanks rendered numbers into a
  loading flicker
- a malformed GetUsage daily pair ([121], []) is warn-and-skipped
  instead of defaulting remaining to 0 and rendering a barely-used
  key as quota-exhausted; the mock serves raw rows so both paths are
  tested

+5 Rust tests, +1 frontend test; findings C1-C10 dispositioned in the
task file (C3 Instant-vs-frozen-Lambda accepted and noted for 0194).

@karczuRF karczuRF left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review of the usage-against-quota slice. The Rust caching layer and the throttle handling hold up well under scrutiny — I specifically checked and cleared: into_service_error() on a non-service error does not panic in aws-smithy-runtime-api 1.12.3 (it builds Unhandled), no Mutex guard is held across an .await anywhere in the cache, the CI /usage" suffix match does not collide with 0187's .../usageplans/ + /keys ARN, and {proxy+} does route /usage since PORTAL_API_METHODS includes GET.

Five findings below, all low severity — clustered on the frontend and on one honesty gap.

Reviewed as the base of a two-PR stack; #230 is built on this branch, so any fix here needs a rebase there before it merges.

Comment thread web/portal/src/app/app.tsx Outdated
const limits = (resetsAt?: string) => (
<>
<p>
Rate limit: <strong>1</strong> request per second.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded rate limit can silently go stale.

Rate limit: <strong>1</strong> request per second is a literal, but the real limit is config.pricingApiFreePlanRateLimit (infra/envs/production.json:6) — a per-env config value the gateway stack feeds to addUsagePlan.

Raise it to 5 in production.json and deploy: the plan enforces 5/s while the dashboard — the section whose whole stated theme is rendering honestly — keeps stating 1/s, with no test or CI check tying the two together.

Every other number on this panel comes from the backend. This is the only one that can drift.

Comment thread web/portal/src/api/portal.ts Outdated
}
throw new PortalApiError(`${url} answered 404`, 404);
}
if (!response.ok) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend's error message is discarded, defeating the timeout rationale.

For every non-404 status the error envelope is dropped and only answered ${status} is thrown, so the backend's usage_unavailable message ("AWS is rate-limiting the usage lookup right now; try again in a moment" / "taking too long to answer") never reaches the page. The visitor sees Could not load your usage: /api-tokens/api/usage answered 503.

This contradicts the rationale written for USAGE_TIMEOUT_MS at line 100 — "the page would report its own timeout instead of the backend's more useful answer; 15s leaves the answer time to arrive." The extra 5s is spent waiting for a message that is then thrown away.

Concrete case: a throttle event with nothing cached — the one situation the backend authored a distinct 503 for — renders as a bare status code.

Comment thread web/portal/src/app/app.tsx Outdated
// blanking it into a loading flicker for an identical body would make the
// press look like it broke something.
useEffect(() => {
if (keyOnScreen && viewState.current === 'no-key') load();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refetch-after-issue can never fire if the mount-time fetch is still in flight.

The refetch fires only on the false -> true transition of keyOnScreen and only when viewState.current === 'no-key'.

Press "Get my key" while the mount-time usage fetch is still in flight (view is 'loading') and the effect is a no-op. The in-flight request then resolves no_key — it was issued before the key existed — and settles the view to 'no-key', but the effect's deps (keyOnScreen, load) no longer change, so no refetch ever fires. A second press can't retrigger it either: setKeyOnScreen(true) on an already-true state bails out of re-render.

The section stays on "Your key is new — usage figures appear here with a delay" until the user finds the Refresh button.

The backend half of this race is handled correctly (remember discards the stale NoKey via the epoch guard), so a manual refresh does return real data.

};

let today = Utc::now().date_naive();
let period = current_period(today);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The period mismatch corrupts the reconstructed limit, not just the label.

limit is reconstructed as used + remaining, where used is summed over [period.start (our calendar-1st), today] but remaining is the last day's balance.

The module docs call an AWS/our-rule period mismatch "a UX wrinkle to word around, not a correctness bug in this module." But if AWS's MONTH quota rolls at any instant other than the calendar 1st 00:00 UTC — which ADR 0010 correction #2 says is undocumented — the sum spans two AWS periods while remaining reflects only the current one. The dashboard then renders a fabricated "Monthly limit": e.g. 150000 against a 100000 plan.

Worth either bounding the query start at the key's first observed row, or noting the limit-reconstruction exposure explicitly alongside the existing wrinkle note.

/// callers seen in the last [`STALE_KEEP`], which for this portal is a small
/// number — and a warm Lambda container is the only place they live long
/// enough to matter.
fn remember(state: &UsageState, sub: &str, answer: CachedAnswer, epoch: u64) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Epoch guard can discard a legitimate NoKey when a mark is pruned mid-lookup.

remember's guard compares against cache.epochs.get(sub).unwrap_or(0), but epoch marks are pruned at STALE_KEEP (15 min). If a caller's mark was bumped (nonzero) more than 15 minutes ago and any other request's remember prunes it during this lookup's ~10s window, the comparison becomes 0 != <stale nonzero epoch> and a perfectly legitimate NoKey is discarded instead of cached — costing a GetApiKeys on every subsequent load.

Only reachable for a caller who issued a key and no longer has one, so it's rare. The cleaner invariant is to treat "no mark" as "matches any epoch".

Related and equally minor: invalidate_no_key inserts into epochs without pruning, and remember is the only pruner — so a warm container where the usage route consistently errors accumulates one mark per key issuance.

The GetUsage query starts on our calendar 1st while `remaining` is the
last day's balance. If AWS's MONTH quota rolls at any other instant --
undocumented, ADR 0010 correction #2 -- the range spans two of its
periods: `used` sums both, `remaining` describes one, and the
reconstructed `used + remaining` renders a quota no plan has.

`remaining` is a running balance, so a day whose value RISES can only be
a reset. `summarize_days` counts from it, which puts both figures in one
period whatever instant that period began, and warns when it sees one --
the only evidence this system can produce about the instant ADR 0010 is
still open on.

Review finding O4 (PR #227). The review read this as a corrupted `limit`;
`used` is wrong first and `limit` is its consequence.
The guard read `unwrap_or(0)`, so a mark pruned at STALE_KEEP while a
lookup was in flight compared as a moved epoch and threw away a
legitimate `NoKey` -- buying a GetApiKeys on every load after it, for a
caller who genuinely has no key.

Absence is safe to trust rather than merely likelier to be right:
`invalidate_no_key` stamps `bumped_at` as it bumps, so any eviction
inside the window leaves a mark too fresh to prune. No mark at
`remember` time therefore proves no eviction happened.

`invalidate_no_key` now prunes too. It was a writer to `epochs` with no
pruner behind it on the paths where `remember` is never reached -- a
throttled or erroring container accumulated one mark per key issuance.

Review finding O5 (PR #227), correcting the epoch guard added for C2.
`Rate limit: 1 request per second` was a literal on the one panel whose
stated theme is rendering honestly, while the figure actually enforced
is `pricingApiFreePlanRateLimit` -- a per-env config value the gateway
stack hands to `addUsagePlan`. Raise it in production.json, deploy, and
the dashboard would keep stating the old number with nothing tying the
two together.

It travels the same value: compute-stack passes PORTAL_RATE_LIMIT from
that config key, AppConfig reads it, /config serves it as
`rate_limit_per_second`, and the page renders that. Not read back from
GetUsagePlan, which would cost a control-plane grant this slice
deliberately does not take.

Absent means the line is omitted, never defaulted -- a fallback figure
here would be the same silent staleness one layer down, wearing the
authority of a number.

Review finding O1 (PR #227), which the third pass had deferred to 0193
or 0194 with /config named as the likely home.
Every non-404 dropped the error envelope and threw `answered ${status}`,
so `usage_unavailable`'s authored message -- "AWS is rate-limiting the
usage lookup right now; try again in a moment" -- never reached the
page. A throttle with nothing cached, the one case the backend wrote a
distinct 503 for, rendered as a bare status code.

It also made USAGE_TIMEOUT_MS pointless: the extra five seconds over the
probe timeout exist so the backend's more useful answer has time to
arrive, and it was then discarded on arrival.

`readEnvelope` reads the body once (a Response body is a stream, and
fetchUsage needs the same read to recognise `no_key`), `failureMessage`
prefers its message and falls back to the URL and status when there is
none -- the gate's empty 404, or anything answered on the backend's
behalf. Applied to getJson and issueKey too: one wording per cause is
R1's rule, extended to the failures the backend words itself.

Review finding O2 (PR #227).
The refetch fired on the `keyOnScreen` transition and read the view
state through a ref. Press "Get my key" while the mount-time usage
fetch is still in flight and the transition happens with the view still
'loading', so the effect had nothing to refetch out of -- and never ran
again, because its dependencies had already settled. The in-flight
request, issued before the key existed, then resolved `no_key` and the
section sat on "your key is new" until the visitor found Refresh. A
second press could not help: setKeyOnScreen(true) on an already-true
state changes no dependency.

The effect now watches `view.state`, which is the honest dependency, and
a ref latch makes it fire at most once per mount. That is what the ref
read was really buying: the backend can legitimately keep answering
`no_key` while its own cache catches up, so "no-key -> load -> no-key"
would otherwise re-trigger itself forever. Both properties are tested.

Review finding O3 (PR #227), correcting the guard added for C4.
Five findings from PR #227, all fixed. Two of them corrected earlier
passes of this same task -- C2's epoch guard and C4's refetch guard were
each right about the race they were written for and wrong at one edge --
which is the part worth having written down.

Also strikes the third pass's deferral of the rate-limit literal: it
guessed /config as the number's home, and that is where it went.
@adamkoot
adamkoot merged commit a76d8a9 into develop Aug 21, 2026
3 checks passed
adamkoot added a commit that referenced this pull request Aug 21, 2026
…ship-and-account-age

#227 put 0188 on develop, so this branch retargets there — and develop had
moved eleven commits further (0190, 0204, 0213, 0120, 0135) while this slice
was in review.

One conflict, in 0189's own task file: develop carries the stub as it stood
at `chore(lore-0189): activate task`, this branch carries the implementation
record written on top of it. Ours wholesale — everything develop's copy has
is a subset, apart from the older parameter-table wording that decision #21
deliberately corrected (`stellar_test` as a guild id was the trap that
refused every visitor).

Workspace 610 passed, portal 76 passed, clippy clean, fmt/format, synth and
the openapi checks all green.
adamkoot added a commit that referenced this pull request Aug 21, 2026
rustc 1.98.0, released 2026-08-18, began passing
`-Wl,--fix-cortex-a53-843419` on aarch64-unknown-linux-gnu. Zig's linker
rejects the argument outright, so every aarch64 link fails and the
`Build Lambda bootstraps` step dies on the first crate that links —
`crc-fast`, pulled in by aws-smithy-checksums.

Nothing in this repo caused it: PR #227 passed on 1.97.1 and PR #230
failed on 1.98.0 with an identical Cargo.lock and the same
cargo-lambda 1.9.1 / zig 0.16.0 pair. `develop` did not show it because
its rust job has not run since 22 July.

cargo-zigbuild filters the argument since v0.23.0 (rust-cross/
cargo-zigbuild #451, fixed by #452), but cargo-lambda 1.9.1 still
vendors 0.20.1 — so upgrading cargo-lambda cannot reach the fix today.
Both pins come off together once a cargo-lambda release carries
zigbuild >= 0.23.

cargo-lambda is pinned as well because `pip3 install cargo-lambda`
resolves at run time, which is how the vendored zigbuild version — the
half that actually carries the fix — could change under a green build
without a commit.

Considered and rejected: `cargo lambda build --compiler cargo` on the
native ARM runner. ubuntu-24.04 ships glibc 2.39 against
provided.al2023's 2.34, so the failure would move from CI to the
deployed function.
adamkoot added a commit that referenced this pull request Aug 21, 2026
Both shipped to `develop` today: 0188 in PR #227 (`a76d8a9`), 0189 in
PR #230 (`99bca3a`, approved by Oskar Karcz). Workspace ends at 558 Rust
tests and 61 portal tests, 0 failures.

0188 closes seven of eight criteria; "N requests move the number" cannot
be closed from a keyboard and waits on the deploy with 0187's live curl.
Carried forward from its four review passes: the cached "no key" that
survived the issue falsifying it, the eviction race that followed, and
the running-balance reset detection that keeps `used` and `remaining` in
one AWS period — the only evidence this system can produce about the
instant ADR 0010 correction #2 is open on.

0189 closes all twelve criteria in code, and deliberately does not close
Step 0: the five Discord measurements are operator-owned, the tables keep
a dated deferral, and no result was invented. The two findings worth
carrying are written into the task — `prompt=none` asserted by three
comments and sent by no code, and one `is_snowflake` now guarding both
the cold-start probe and the member URL.

Still operator-owned before production: the Developer Portal scope, the
two SSM seeds, and 0179 step 4. Nothing spawned; every follow-up already
has an owner.
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