fix: harden 402 guard recursion depth and make SDK API drift noisy

- Bound findStringField recursion to maxDepth=10 so adversarially deep
  402 bodies cannot cause runaway recursion. Real 402 bodies nest 3-4
  levels; 10 is generous while bounding the worst case.
- Add runtime shape checks with logger.warn() to all four monkey-patches
  (_handleErrorResponse, createProviderToken, topUp) so SDK API drift is
  noisy instead of silent. The topUp patch previously called .bind()
  without verifying the method exists — a real TypeError on SDK upgrade.
- Thread logger into installProvider402BodyCapture for the new warning.
- Tests: depth-limit edge cases (4-level match + 50-level no-crash) and
  three API-drift detection tests verifying warnings fire when SDK
  methods are missing.
This commit is contained in:
Paperclip Deployment Engineer
2026-08-07 22:06:33 +00:00
parent 09b8a4fcc3
commit d5bf37d8ed
4 changed files with 121 additions and 8 deletions
+15 -4
View File
@@ -86,9 +86,20 @@ function safeStringify(value: unknown): string {
}
}
/** Recursively find the first string-valued occurrence of `field`. */
function findStringField(value: unknown, field: string): string | undefined {
if (!value || typeof value !== "object") return undefined;
/**
* Recursively find the first string-valued occurrence of `field`.
*
* Bounded to `maxDepth` levels so an adversarially deep or cyclic-ish 402
* body cannot cause runaway recursion. Real 402 bodies nest 3-4 levels deep
* at most (`{"detail":{"error":{"message":...,"code":...}}}`), so 10 is
* generous while still bounding the worst case.
*/
function findStringField(
value: unknown,
field: string,
maxDepth = 10,
): string | undefined {
if (!value || typeof value !== "object" || maxDepth <= 0) return undefined;
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (key === field && typeof entry === "string" && entry.trim()) {
@@ -96,7 +107,7 @@ function findStringField(value: unknown, field: string): string | undefined {
}
}
for (const entry of Object.values(value as Record<string, unknown>)) {
const nested = findStringField(entry, field);
const nested = findStringField(entry, field, maxDepth - 1);
if (nested) return nested;
}
return undefined;
+34 -4
View File
@@ -72,12 +72,19 @@ const ERROR_BODY_PATCH_MARKER = Symbol.for("routstrd.provider402BodyPatched");
export function installProvider402BodyCapture(
client: RoutstrClientLike,
guard: Provider402Guard,
logger?: LoggerLike,
): void {
const target = client as RoutstrClientLike & {
[ERROR_BODY_PATCH_MARKER]?: boolean;
_handleErrorResponse?: (...args: unknown[]) => Promise<unknown>;
};
if (target[ERROR_BODY_PATCH_MARKER] || typeof target._handleErrorResponse !== "function") {
if (target[ERROR_BODY_PATCH_MARKER]) return;
if (typeof target._handleErrorResponse !== "function") {
logger?.warn(
"[wallet] _handleErrorResponse not found on SDK client — " +
"402 body capture disabled. If the SDK has changed its API, " +
"this patch and the 402 guard need updating.",
);
return;
}
@@ -294,7 +301,15 @@ export function installCreateProviderTokenFallback(
createProviderToken?: (options: TopUpOptions) => Promise<TopUpResult>;
[CREATE_TOKEN_PATCH_MARKER]?: boolean;
};
if (balanceManager[CREATE_TOKEN_PATCH_MARKER] || typeof balanceManager.createProviderToken !== "function") return;
if (balanceManager[CREATE_TOKEN_PATCH_MARKER]) return;
if (typeof balanceManager.createProviderToken !== "function") {
logger.warn(
"[wallet] createProviderToken not found on balance manager — " +
"createProviderToken fallback disabled. If the SDK has changed " +
"its API, this patch needs updating.",
);
return;
}
const originalCreateProviderToken = balanceManager.createProviderToken.bind(balanceManager);
@@ -477,12 +492,20 @@ export function installMintFallbackTopUp(
installCreateProviderTokenFallback(client, walletClient, walletAdapter, logger, upstreamProviderUrl);
const guard = provider402Guard ?? createProvider402Guard();
installProvider402BodyCapture(client, guard);
installProvider402BodyCapture(client, guard, logger);
const balanceManager = client.getBalanceManager() as BalanceManagerLike & {
[PATCH_MARKER]?: boolean;
};
if (balanceManager[PATCH_MARKER]) return;
if (typeof balanceManager.topUp !== "function") {
logger.warn(
"[wallet] topUp not found on balance manager — " +
"mint fallback top-up disabled. If the SDK has changed " +
"its API, this patch needs updating.",
);
return;
}
const originalTopUp = balanceManager.topUp.bind(balanceManager);
balanceManager.topUp = async (options: TopUpOptions): Promise<TopUpResult> => {
@@ -664,7 +687,14 @@ export function installMintUnreachableErrorRetry(
if (patchedClient[ERROR_RETRY_PATCH_MARKER]) return;
const originalHandleErrorResponse = patchedClient._handleErrorResponse;
if (typeof originalHandleErrorResponse !== "function") return;
if (typeof originalHandleErrorResponse !== "function") {
logger.warn(
"[wallet] _handleErrorResponse not found on SDK client — " +
"mint-unreachable retry disabled. If the SDK has changed " +
"its API, this patch needs updating.",
);
return;
}
patchedClient._handleErrorResponse = async function patchedHandleErrorResponse(
this: Record<string, any>,
+16
View File
@@ -56,6 +56,22 @@ describe("classifyProvider402", () => {
expect(classifyProvider402("gateway timeout")).toBe("unknown");
expect(classifyProvider402('{"detail":"something else"}')).toBe("unknown");
});
test("finds limit_source at realistic nesting depth (4 levels)", () => {
// Real 402 bodies nest 3-4 levels deep.
const body = JSON.stringify({
detail: { error: { metadata: { limit_source: "openrouter_credits" } } },
});
expect(classifyProvider402(body)).toBe("provider_side");
});
test("does not crash on adversarially deep nesting", () => {
// 50 levels deep — well beyond the depth limit. The field is never found,
// so it falls through to "unknown" instead of stack-overflowing.
let nested: Record<string, unknown> = { limit_source: "openrouter_credits" };
for (let i = 0; i < 50; i++) nested = { nested };
expect(classifyProvider402(JSON.stringify(nested))).toBe("unknown");
});
});
describe("provider 402 guard", () => {
+56
View File
@@ -481,6 +481,62 @@ describe("Hardened fallback — concurrency & idempotency", () => {
});
});
describe("Hardened fallback — SDK API drift detection", () => {
test("warns when topUp is missing from balance manager", () => {
// Simulate an SDK upgrade that renames or removes topUp.
const balanceManager = createBalanceManager();
delete (balanceManager as { topUp?: unknown }).topUp;
const client = { getBalanceManager: () => balanceManager };
const warnings: string[] = [];
installMintFallbackTopUp(
client as never,
createCocodClient(["https://mint-a.example"]),
createWalletAdapter(),
{ log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined },
);
// Should have warned, not silently swallowed.
expect(warnings.some((w) => w.includes("topUp not found"))).toBe(true);
});
test("warns when createProviderToken is missing from balance manager", () => {
const balanceManager = createBalanceManager();
delete (balanceManager as { createProviderToken?: unknown }).createProviderToken;
const client = { getBalanceManager: () => balanceManager };
const warnings: string[] = [];
installMintFallbackTopUp(
client as never,
createCocodClient(["https://mint-a.example"]),
createWalletAdapter(),
{ log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined },
);
expect(warnings.some((w) => w.includes("createProviderToken not found"))).toBe(true);
});
test("warns when _handleErrorResponse is missing from SDK client", () => {
// Simulate an SDK upgrade that renames _handleErrorResponse.
// createBalanceManager provides topUp + createProviderToken so those patches
// install cleanly, but the error-retry and 402-body-capture patches should warn.
const balanceManager = createBalanceManager();
const client = { getBalanceManager: () => balanceManager };
const warnings: string[] = [];
installMintFallbackTopUp(
client as never,
createCocodClient(["https://mint-a.example"]),
createWalletAdapter(),
{ log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined },
);
expect(
warnings.some((w) => w.includes("_handleErrorResponse not found")),
).toBe(true);
});
});
describe("Hardened fallback — API key safety", () => {
test("API key is not logged in error messages", async () => {
const balanceManager = createBalanceManager();