From 75bd2a93677256db08f2f81706c92c4301416fe3 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 3 Aug 2026 19:30:20 +0200 Subject: [PATCH] fix(server): re-throw StorageCapacityExhaustedError in POST /identities catch block The identities route's own catch block was swallowing StorageCapacityExhaustedError and returning 500 identity_create_failed. app.onError correctly maps it to 503 via storageCapacityResponse, but it never saw the error because the route caught it first. Fix: check isStorageCapacityExhausted before the StorageOverloadedError handler and re-throw so app.onError's capacity mapper handles it correctly. Bumps @relayauth/server to 0.2.24. Co-Authored-By: Claude Sonnet 4.6 --- packages/server/package.json | 2 +- .../src/__tests__/create-identity.test.ts | 35 +++++++++++++++++++ packages/server/src/routes/identities.ts | 5 +++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/server/package.json b/packages/server/package.json index bc74e1f..469be4a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@relayauth/server", - "version": "0.2.23", + "version": "0.2.24", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/server/src/__tests__/create-identity.test.ts b/packages/server/src/__tests__/create-identity.test.ts index 295da1d..e877c7e 100644 --- a/packages/server/src/__tests__/create-identity.test.ts +++ b/packages/server/src/__tests__/create-identity.test.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import test from "node:test"; import type { AgentIdentity, CreateIdentityInput, RelayAuthTokenClaims } from "@relayauth/types"; import { FixedWindowRateLimiter, RateLimitExceededError } from "../lib/rate-limit.js"; +import { StorageCapacityExhaustedError } from "../lib/storage-retry.js"; import type { IdentityBudget, StoredIdentity } from "../storage/identity-types.js"; import { assertJsonResponse, @@ -398,3 +399,37 @@ test("the global error handler preserves rate-limit errors as 429 responses", as assert.equal(response.headers.get("Retry-After"), "60"); assert.equal(response.headers.get("RateLimit-Remaining"), "0"); }); + +test("POST /v1/identities returns 503 with capacity envelope when storage.identities.create throws StorageCapacityExhaustedError", async () => { + // Verifies that StorageCapacityExhaustedError thrown from storage.identities.create() + // propagates correctly: the route catch block re-throws it (not swallowed as 500), + // and app.onError handles it via storageCapacityResponse → 503 with Retry-After. + // This is the server-side assertion that the 503 route mapping fix is correct. + const storage = createTestStorage(); + storage.identities.create = async () => { + throw new StorageCapacityExhaustedError("post-auth mint capacity exhausted (status 429)"); + }; + const app = createTestApp({}, { storage }); + + const response = await app.request( + createTestRequest( + "POST", + "/v1/identities", + { name: "capacity-probe", sponsorId: "user_sponsor_1" }, + { Authorization: `Bearer ${createAuthToken()}` }, + ), + undefined, + app.bindings, + ); + + const body = await assertJsonResponse<{ + code?: string; + retryable?: boolean; + retryAfterMs?: number; + requestId?: string; + }>(response, 503); + assert.equal(body.code, "storage_capacity_exhausted", "capacity envelope code"); + assert.equal(body.retryable, true, "capacity envelope must be retryable"); + assert.ok(response.headers.get("Retry-After"), "Retry-After header must be present"); + assert.equal(response.headers.get("Cache-Control"), "no-store", "Cache-Control must be no-store"); +}); diff --git a/packages/server/src/routes/identities.ts b/packages/server/src/routes/identities.ts index 624afee..9282a2f 100644 --- a/packages/server/src/routes/identities.ts +++ b/packages/server/src/routes/identities.ts @@ -11,6 +11,7 @@ import type { AppEnv } from "../env.js"; import { authenticateAndAuthorizeFromContext, authenticateBearerOrApiKey, authorizeClaims, decodeBase64UrlJson } from "../lib/auth.js"; import { emitObserverEvent, now as observerNow } from "../lib/events.js"; import { + isStorageCapacityExhausted, isTransientStorageOverload, isStorageOverloadedError, StorageOverloadedError, @@ -502,6 +503,10 @@ identities.post("/", async (c) => { }); return c.json(createdIdentity, 201); } catch (error) { + if (isStorageCapacityExhausted(error)) { + throw error; + } + if (isStorageOverloadedError(error)) { return storageOverloadResponse(c, error); }