Skip to content

Commit 7868dce

Browse files
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/openapi.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ const openAPISpec = `{
201201
"post": {
202202
"summary": "Provision a Postgres database",
203203
"description": "Returns a real postgres:// connection string with pgvector pre-installed. Anonymous tier: 10MB, 2 connections, 24h TTL.\n\nSupports Stripe/AWS-style idempotency via the optional Idempotency-Key request header — see the parameter description below.",
204-
"parameters": [{ "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string", "maxLength": 255 }, "description": "Opaque client-supplied key (1-255 ASCII printable chars) that makes this POST safe to retry. The first response is cached for 24h; subsequent calls carrying the same key return the cached response verbatim with X-Idempotent-Replay: true. Reusing a key with a different body returns 409. Replays still consume rate-limit budget (anti-abuse) but do NOT consume quota budget (the original call already did)." }],
204+
"parameters": [{ "name": "Idempotency-Key", "in": "header", "required": false, "schema": { "type": "string", "maxLength": 255 }, "description": "Opaque client-supplied key (1-255 ASCII printable chars) that makes this POST safe to retry. The first response is cached for 24h; subsequent calls carrying the same key return the cached response verbatim with X-Idempotent-Replay: true. Reusing a key with a different body returns 409. Replays do NOT consume rate-limit budget — the per-fingerprint daily counter is refunded on every cache hit so an agent retrying transient 5xx with the same key gets the documented replay (FINDING API-1, 2026-05-29). The FIRST call still pays the rate-limit cost; replays are refunded. The per-fingerprint provision-dedup cap (5 fresh resources/day, anti-abuse) is unchanged." }],
205205
"requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProvisionRequest" } } } },
206206
"responses": {
207207
"201": { "description": "Database provisioned", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DBProvisionResponse" } } } },

internal/metrics/metrics.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ var (
5757
Help: "Requests blocked by fingerprint rate limiting",
5858
})
5959

60+
// IdempotencyReplayRefunded counts the rate-limit counter refunds the
61+
// Idempotency middleware issues on a cache HIT — one increment per
62+
// replayed response that successfully DECR'd the per-fingerprint
63+
// daily counter (CLAUDE.md FINDING API-1, fix Option C).
64+
//
65+
// Labelled by route_path so on-call can see which endpoints absorb the
66+
// most retry-storm traffic. A steady non-zero rate is healthy (agents
67+
// are retrying transient 5xx and we're honoring the published Stripe-
68+
// shape replay contract). A sudden spike on one route correlates with
69+
// upstream brownouts; flip to NR and check the corresponding 5xx rate
70+
// for the same route.
71+
//
72+
// Companion alert (infra repo): "idempotency replay refund spike (1h)"
73+
// fires when rate(idempotency_replay_refunded_total[1h]) > 5×7d
74+
// baseline — points the operator at a brownout in the underlying
75+
// provisioner before agents start abandoning.
76+
IdempotencyReplayRefunded = promauto.NewCounterVec(prometheus.CounterOpts{
77+
Name: "instant_idempotency_replay_refunded_total",
78+
Help: "Rate-limit counter refunds issued by Idempotency middleware on cache hit",
79+
}, []string{"route"})
80+
6081
// RecycleGateBlocked counts anonymous provision attempts blocked by the
6182
// free-tier recycle gate (Option B from FREE-TIER-RECYCLE-2026-05-12).
6283
// Labelled by resource_type so we can see which services see the most

internal/middleware/idempotency.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,22 @@ import (
4646
// Middleware ordering (see internal/router/router.go for the per-route
4747
// wiring): RateLimit runs at app.Use scope (global, before OptionalAuth),
4848
// so by the time this middleware runs the per-fingerprint daily counter
49-
// has already incremented. THIS IS DELIBERATE: a malicious agent must NOT
50-
// be able to bypass rate limiting via Idempotency-Key reuse, so replays
51-
// still consume rate budget. The original-call cost is borne by the
52-
// counter on the FIRST request; replays add an extra increment, which is
53-
// the conservative choice — the customer paid for the first call (in
54-
// quota terms) but a key-reuse attacker doesn't get free attempts.
55-
// Quota-walls inside handlers (CheckAndIncrementToken) similarly continue
56-
// to fire on replay paths, but the replay short-circuits BEFORE the
57-
// handler so the quota counter is unaffected — the cached response simply
58-
// goes out the wire. Net effect: rate-limit budget = abuse-protected;
49+
// has already incremented. To honor the published Stripe-shape replay
50+
// contract — same Idempotency-Key replays the cached response without
51+
// burning a fresh rate-limit slot — every cache HIT path below calls
52+
// RefundRateLimitCounter (single Redis DECR) BEFORE sending the cached
53+
// response. The FIRST call still pays the cost (the original INCR), so
54+
// an attacker reusing one key 100× gets amortised-cheaper attempts, NOT
55+
// free attempts (FINDING API-1, CEO Option C, 2026-05-29). The handler-
56+
// internal per-fingerprint provision-dedup cap (5/day, CLAUDE.md rule 6)
57+
// is NOT touched by the refund — that abuse signal lives in handler
58+
// code and is independent of the request-rate-limit counter.
59+
//
60+
// Quota-walls inside handlers (CheckAndIncrementToken) continue to fire
61+
// on replay paths, but the replay short-circuits BEFORE the handler so
62+
// the quota counter is unaffected — the cached response simply goes out
63+
// the wire. Net effect: rate-limit budget = refunded on replay (Stripe
64+
// contract); fingerprint provision-dedup = abuse-protected (unchanged);
5965
// quota budget = customer-friendly (no double-charge for retries).
6066
//
6167
// Cache key shape: idem:<scope>:<endpoint>:<sha256(key)> where <scope> is
@@ -287,12 +293,20 @@ func idempotencyExplicit(c *fiber.Ctx, rdb *redis.Client, endpoint, scope, rawKe
287293
"error", jerr, "endpoint", endpoint)
288294
} else {
289295
if entry.BodyHash != reqBodyHash {
296+
// 409 is a genuine error response, not a replay — DO NOT
297+
// refund the rate-limit counter here. The agent did the
298+
// wrong thing (reused a key for a different body) and
299+
// should still pay the cost of that mistake.
290300
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
291301
"ok": false,
292302
"error": "idempotency_key_conflict",
293303
"message": "Idempotency-Key already used with a different body",
294304
})
295305
}
306+
// Cache HIT — refund the rate-limit slot RateLimit burned on
307+
// the way in (FINDING API-1, Option C). Fail-open: a refund
308+
// error logs WARN but never blocks the cached response.
309+
RefundRateLimitCounter(c, rdb)
296310
c.Set(idempotencyReplayHeader, "true")
297311
if entry.ContentType != "" {
298312
c.Set(fiber.HeaderContentType, entry.ContentType)
@@ -397,6 +411,12 @@ func idempotencyFingerprint(c *fiber.Ctx, rdb *redis.Client, endpoint, scope str
397411
"error", jerr, "endpoint", endpoint)
398412
// Corrupt — fall through to handler and overwrite below.
399413
} else {
414+
// Cache HIT on the body-fingerprint fallback path. Same
415+
// refund semantics as the explicit-key branch: the
416+
// rate-limit slot RateLimit burned on the way in is
417+
// returned because we're serving a cached response, not
418+
// re-running the handler (FINDING API-1, Option C).
419+
RefundRateLimitCounter(c, rdb)
400420
c.Set(idempotencySourceHeader, idempotencySourceFingerprint)
401421
c.Set(idempotencyReplayHeader, "true")
402422
if entry.ContentType != "" {

0 commit comments

Comments
 (0)