[WSAPI-8] Add rolling-hour rate ceiling + 401/429 cooldown #10

Merged
chris merged 9 commits from feature/WSAPI-8 into main 2026-09-21 13:13:22 -06:00
Owner

Ticket

WSAPI-8 Add a rolling-hour rate ceiling + 401/429 cooldown to /sync/trigger

Summary

  • Adds a rolling-hour sync rate ceiling (WS_MAX_SYNCS_PER_HOUR, default 12) that bounds the on-demand POST /sync/trigger path (immediate + trailing debounced runs) — the nightly cron is deliberately exempt from the check, since it only fires once a day and could never spend a 12/hour budget in practice.
  • Adds a 401/429 cooldown: a sustained Wealthsimple refusal that outlasts the client's retry ladder puts the activity-feed sync into an hour-long cooldown, persisted on the syncStatus document. Unlike the ceiling, the cooldown gates both the on-demand trigger and the nightly cron — it's a "Wealthsimple said stop" signal that protects the account regardless of which path is asking.
  • GET /status gains a rateLimit: { maxSyncsPerHour, syncsRemainingThisHour } field, and jobs.activityFeed now also carries cooldownUntil/cooldownReason.
  • POST /sync/trigger widens its reason field to debounced | rateLimited | cooldown — all three are still 200, not failures.
  • Mirrors budget-tracker-api's own rate-discipline design (server/lib/wealthsimple/sync, WS_MAX_SYNCS_PER_HOUR/COOLDOWN_STATUSES) adapted to this repo's simpler, mutex-free, per-job-status model — this closes the gap BTAPI-76's own pre-PR review flagged: without a ceiling, a stuck or repeated doorbell forwarder could drive far more than the 12/hour design budget; without a cooldown, a Wealthsimple refusal would just be retried at normal cadence, risking a locked account on a live credit card.
  • This repo's own mutex/coalescing gap (a cron tick and a debounced trigger can still race to write the same cache/status) remains the one open hardening item, unchanged by this ticket — called out in sync/activity-feed/index.ts's own header comment.

Verification

  • bun run type-check, bun run lint, bun run format:check — all clean.
  • bun test — 384 passed, 0 failed, across 37 files.

🤖 Generated with Claude Code

## Ticket [WSAPI-8](http://192.168.2.100:7123/home/browse/WSAPI-8/) Add a rolling-hour rate ceiling + 401/429 cooldown to `/sync/trigger` ## Summary - Adds a rolling-hour sync rate ceiling (`WS_MAX_SYNCS_PER_HOUR`, default 12) that bounds the on-demand `POST /sync/trigger` path (immediate + trailing debounced runs) — the nightly cron is deliberately exempt from the check, since it only fires once a day and could never spend a 12/hour budget in practice. - Adds a 401/429 cooldown: a sustained Wealthsimple refusal that outlasts the client's retry ladder puts the activity-feed sync into an hour-long cooldown, persisted on the `syncStatus` document. Unlike the ceiling, the cooldown gates **both** the on-demand trigger and the nightly cron — it's a "Wealthsimple said stop" signal that protects the account regardless of which path is asking. - `GET /status` gains a `rateLimit: { maxSyncsPerHour, syncsRemainingThisHour }` field, and `jobs.activityFeed` now also carries `cooldownUntil`/`cooldownReason`. - `POST /sync/trigger` widens its `reason` field to `debounced | rateLimited | cooldown` — all three are still `200`, not failures. - Mirrors budget-tracker-api's own rate-discipline design (`server/lib/wealthsimple/sync`, `WS_MAX_SYNCS_PER_HOUR`/`COOLDOWN_STATUSES`) adapted to this repo's simpler, mutex-free, per-job-status model — this closes the gap BTAPI-76's own pre-PR review flagged: without a ceiling, a stuck or repeated doorbell forwarder could drive far more than the 12/hour design budget; without a cooldown, a Wealthsimple refusal would just be retried at normal cadence, risking a locked account on a live credit card. - This repo's own mutex/coalescing gap (a cron tick and a debounced trigger can still race to write the same cache/status) remains the one open hardening item, unchanged by this ticket — called out in `sync/activity-feed/index.ts`'s own header comment. ## Verification - `bun run type-check`, `bun run lint`, `bun run format:check` — all clean. - `bun test` — 384 passed, 0 failed, across 37 files. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds the rolling-hour sync rate ceiling knob, mirroring budget-tracker-api's
own direct-Wealthsimple engine (WS_MAX_SYNCS_PER_HOUR, default 12/hour), so
POST /sync/trigger can bound a stuck or repeated caller.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Widens lib/sync-status with cooldownUntil/cooldownReason, a single
activeCooldown() derivation shared by the reported status and the actual
skip decision (getCooldown), and recordFailure/recordSuccess semantics that
set/clear it without clobbering an unrelated cooldown already in force.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds recentSyncAttempts (a rolling-hour ceiling on the on-demand /sync/trigger
path only) and cooldown detection from WealthsimpleApiError.status, unified
behind a new attemptSync() chokepoint used by both the immediate trigger and
the trailing-run path. The nightly cron checks (but does not charge against)
the cooldown, since it's a "Wealthsimple said stop" signal that protects the
account regardless of which path is asking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a rateLimit.maxSyncsPerHour/syncsRemainingThisHour field; the existing
jobs.activityFeed already carries cooldownUntil/cooldownReason once Step 2/3
land, so no extra route-side mapping was needed for that half.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Notes the WSAPI-8 rate ceiling/cooldown in sync/activity-feed's architecture-
tree entry and the new rateLimit/cooldownUntil/cooldownReason fields on
GET /status. sync/activity-feed/index.ts's own header comment already
credits WSAPI-8 and names the missing mutex/coalescing as the one remaining
open item, added alongside the code in Step 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Whole-suite verification (Step 8a) surfaced a lint failure the per-step
type-check/test run didn't catch: eslint-plugin-promise's always-return rule
flags a bare .then() callback with no return. Replaced with an async IIFE,
same behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HIGH:
- sync/activity-feed: the trailing run's async IIFE now wraps attemptSync()
  in a try/catch. Its cooldown check (SyncStatus.getCooldown) is an unguarded
  DB read that ran ahead of performSync()'s own double try/catch, so a Mongo
  rejection there was an unhandled rejection that could crash the process.

MEDIUM:
- sync/activity-feed: the rolling-hour ceiling charge moved from performSync()
  (unconditional) into attemptSync() (the on-demand chokepoint), so the
  nightly cron — which calls performSync() directly and is deliberately
  ceiling-exempt — no longer spends on-demand budget it was never charged
  against by contract.
- Exported cooldownReasonFor() as a direct, unit-testable seam for the
  401/429 COOLDOWN_STATUSES mapping (401->rejected, 429->rateLimited, and
  the undefined cases), replacing a comment that claimed coverage which
  didn't actually exist.
- Added cooldown-field assertions to the existing non-cooldown failure tests
  (a 400, and a session-halted run) proving they leave cooldownUntil/
  cooldownReason alone.
- lib/sync-status: extracted a named, readonly Cooldown interface used
  everywhere the { until, reason } pair traveled as an unnamed inline type.

LOW:
- lib/sync-status: widened cooldownUntil's document type to Date | string |
  null, matching the runtime defensive branch that already handles a raw
  string.
- sync/activity-feed: dropped the redundant CooldownReason import (now
  referenced as SyncStatus.CooldownReason), wrapped COOLDOWN_STATUSES in
  Object.freeze for runtime parity with budget-tracker-api's reference, and
  had SyncTriggerResult.reason reference the SyncDecline type instead of
  repeating its members.
- routes/status test now resets the rate-ceiling state per test so the
  rateLimit assertion is exact rather than bounds-only; added a ceiling-
  recovery assertion that a trigger actually succeeds again, and a cron test
  proving it resumes once an expired cooldown clears.
- lib/sync-status test: collapsed two cooldown assertions each into one
  full-object toEqual, matching this file's existing no-partial-match style.
- routes/sync: added two route-level tests exercising a rate-limited and a
  cooldown decline through the real POST /sync/trigger endpoint (both 200),
  and widened the route's and CLAUDE.md's docs to describe the three-valued
  decline reason and the new config/status surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- CLAUDE.md: the 401/429 cooldown gates both the on-demand trigger and the
  nightly cron, not just the on-demand path (only the ceiling is on-demand
  only) — reworded the architecture-tree entry to say so.
- sync/activity-feed/.test.ts: the trailing-run-crash test now sets
  Config.wsMaxSyncsPerHour = 12 explicitly, so it can't silently stop
  exercising anything if the default were ever lowered to 1 (the ceiling
  check would short-circuit ahead of the rejecting getCooldown stub).
- Same test: moved the unhandledRejection listener registration inside the
  try block, alongside the spies it pairs with, so a throw from either spy
  can't leave the listener attached into later tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
[WSAPI-8] Compress this ticket's CLAUDE.md additions (Step 8d).
All checks were successful
server / check (pull_request) Successful in 27s
28384efd0a
Tightened the sync/activity-feed/ and lib/config/ architecture-tree entries
this ticket added to — folded the WSAPI-8 addition into the existing
sentence and traded verbose verbs ("bounds ... specifically", "gates both
... and the nightly cron") for parentheticals, net one line shorter with no
loss of information.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chris merged commit a29f182589 into main 2026-09-21 13:13:22 -06:00
chris deleted branch feature/WSAPI-8 2026-09-21 13:13:22 -06:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chris/wealthsimple-api!10
No description provided.