Commit 7868dce
fix(middleware): refund rate-limit counter on idempotency cache hit (#167)
* fix(middleware): refund rate-limit counter on idempotency cache hit
# Finding
CLAUDE.md FINDING API-1 — `POST /queue/new` (and every provision endpoint)
with the same `Idempotency-Key` retried after a transient 5xx burns the
per-fingerprint daily rate-limit counter on every retry. The published
Stripe-shape replay contract says replays should return the cached body
verbatim — the actual middleware honours that on the body side but the
upstream rate-limit slot is gone, so agents hit 429 inside their retry
budget and silently abandon.
# Root cause
Ordering, not a missing cache lookup.
- `internal/router/router.go:201` — `app.Use(middleware.RateLimit(...))`
at global scope, runs BEFORE every per-route middleware chain. The
per-fingerprint daily INCR fires here on every request, including
replays.
- `internal/middleware/idempotency.go:283-301` — the explicit-key cache
HIT branch (and 392-406 fingerprint-fallback HIT branch) correctly
serves the cached body verbatim, but by then RateLimit has already
burned the slot. The original design comment at idempotency.go:46-59
documented the conflict as deliberate "anti-abuse"; the OpenAPI
contract at openapi.go:204 explicitly told callers replays didn't
cost rate-limit budget. Those two contradicted; the contract loses.
# Fix (Option C, per CEO memo 2026-05-29)
Refund the rate-limit counter on cache HIT — single Redis `DECR` from
the idempotency cache-hit branches via a new `RefundRateLimitCounter`
helper in `rate_limit.go`. The first call still pays the INCR cost
(an attacker reusing one key cannot get free attempts, only
amortized-cheaper). The per-fingerprint provision-dedup cap
(CLAUDE.md rule 6, handler-internal `prov:<fp>:<date>` counter) is
NOT touched — that's where the abuse signal actually lives.
Self-contained: 1 helper + 2 call sites + 1 metric + 1 OpenAPI string.
No API contract change. No router rewiring. No new test fixtures.
# Files
- `internal/middleware/rate_limit.go` — add `RefundRateLimitCounter`,
stash the computed Redis key + configured limit into Fiber Locals so
the refund helper can DECR the exact key without re-deriving prefix.
- `internal/middleware/idempotency.go` — call refund from both cache-HIT
branches (explicit-key + body-fingerprint). 409 conflict path does
NOT refund (genuine error, agent pays for the mistake).
- `internal/metrics/metrics.go` — new `IdempotencyReplayRefunded`
CounterVec labelled by `route` so on-call sees which provision
endpoints absorb the most retry-storm traffic.
- `internal/handlers/openapi.go` — update Idempotency-Key parameter
description to reflect the new contract (replays NO LONGER consume
rate-limit budget; FIRST call still pays).
- `internal/middleware/idempotency_test.go` — 4 new regression tests:
- `TestIdempotencyCacheHitRefundsRateLimit` — headline boundary:
counter stays at 1 across 2 same-key calls, X-RateLimit-Remaining
reflects post-refund budget.
- `TestIdempotencyDifferentKeyDoesNotRefund` — negative: distinct
keys must burn 2 slots.
- `TestIdempotencyConflictDoesNotRefund` — 409 is a genuine error,
must NOT refund.
- `TestRefundRateLimitCounterSafeNoOps` — nil rdb + no LocalKey =
safe no-op (not panic / not corruption).
# Coverage block (per CLAUDE.md rule 17)
```
Symptom: POST /queue/new (and every provision endpoint) with
retried Idempotency-Key burns rate-limit slot,
eventually 429s the agent inside its retry budget.
Enumeration: grep -n 'c.Status(entry.StatusCode).Send(entry.Body)'
internal/middleware/idempotency.go
Sites found: 2 (explicit-key HIT @ idempotency.go:283-301,
fingerprint HIT @ idempotency.go:392-406)
Sites touched: 2 (both branches call RefundRateLimitCounter; conflict
path explicitly does NOT refund — see code comment)
Coverage test: TestIdempotencyCacheHitRefundsRateLimit (explicit),
TestIdempotencyDifferentKeyDoesNotRefund (negative).
Helper safety: TestRefundRateLimitCounterSafeNoOps.
Live verified: pending — fix-api-idem branch, awaiting CI green +
deploy + /healthz commit-sha grep.
```
# Surface checklist (per CLAUDE.md rule 22)
- [x] api/internal/middleware/* — fix + tests
- [x] api/internal/metrics/metrics.go — new counter
- [x] api/internal/handlers/openapi.go — contract description updated
- [x] infra companion PR — alert + dashboard tile + METRICS-CATALOG
row (rule 25): InstaNode-dev/infra branch
`fix/idempotency-replay-refund-observability`
- [n/a] content/llms.txt — current llms.txt doesn't mention the old
rate-limit-budget contract, no change needed.
- [n/a] dashboard upgradeCopy.ts — no pricing/upsell impact.
- [n/a] CHANGELOG — repo has no CHANGELOG.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(middleware): cover refund DECR-error + clamp branches
CI's diff-cover gate flagged rate_limit.go:190-197, 203-204, 213-214 as
uncovered patch lines (80% — fails the 100% per-PR floor). Add three
sub-tests under TestRefundRateLimitCounterSafeNoOps:
- redis_decr_error_fails_open — closes a Redis client mid-test, asserts
the WARN-log + RedisErrors metric path is hit without propagating the
error to the response.
- decr_below_zero_clamps — covers BOTH the newCount<0 clamp (DECR on a
non-existent key returns -1) AND the remaining<0 clamp (pre-seed a
counter > configured limit so DECR still leaves it over-cap; the
X-RateLimit-Remaining header must floor at 0, never go negative).
Brings the patch coverage on this PR to 100% per project memory rule
`feedback_coverage_95_floor_100_patch`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0cc49c5 commit 7868dce
5 files changed
Lines changed: 455 additions & 11 deletions
File tree
- internal
- handlers
- metrics
- middleware
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
201 | 201 | | |
202 | 202 | | |
203 | 203 | | |
204 | | - | |
| 204 | + | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
60 | 81 | | |
61 | 82 | | |
62 | 83 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
59 | 65 | | |
60 | 66 | | |
61 | 67 | | |
| |||
287 | 293 | | |
288 | 294 | | |
289 | 295 | | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
290 | 300 | | |
291 | 301 | | |
292 | 302 | | |
293 | 303 | | |
294 | 304 | | |
295 | 305 | | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
296 | 310 | | |
297 | 311 | | |
298 | 312 | | |
| |||
397 | 411 | | |
398 | 412 | | |
399 | 413 | | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
400 | 420 | | |
401 | 421 | | |
402 | 422 | | |
| |||
0 commit comments