From 7f476af319c971ec125d122cdf333f4139db11e0 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sat, 29 Aug 2026 19:59:22 +0100 Subject: [PATCH 1/2] fix(api): guard Upstash rate-limit failures on non-keeper routes --- api/__tests__/handlers.test.ts | 37 ++++++++++++++++++++++++++++++++- api/v1/positions/[publicKey].ts | 9 +++++++- api/v1/tx/add-trustline.ts | 9 +++++++- api/v1/tx/deposit.ts | 9 +++++++- api/v1/tx/submit.ts | 9 +++++++- api/v1/tx/withdraw.ts | 9 +++++++- api/v1/vaults/[vaultId].ts | 9 +++++++- api/v1/vaults/index.ts | 9 +++++++- 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/api/__tests__/handlers.test.ts b/api/__tests__/handlers.test.ts index bc4763b9..f041c214 100644 --- a/api/__tests__/handlers.test.ts +++ b/api/__tests__/handlers.test.ts @@ -1,6 +1,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { VercelRequest, VercelResponse } from "@vercel/node"; +vi.mock("../_lib/middleware.js", async () => { + const actual = await vi.importActual( + "../_lib/middleware.js" + ); + return { + ...actual, + checkRateLimit: vi.fn(async (...args: Parameters) => + actual.checkRateLimit(...args) + ), + }; +}); + // Stub the workspace builders/readers — these tests exercise the HTTP handler // contract (method guards, field validation, status codes, payload shape), not // the Soroban transaction building, which is unit-tested in the helpers package. @@ -96,7 +108,10 @@ import vaultsHandler from "../v1/vaults/index"; import positionsHandler from "../v1/positions/[publicKey]"; import keeperHandler from "../v1/keepers/accrue"; import rebalanceHandler from "../v1/keepers/rebalance"; -import { resetRateLimitForTesting } from "../_lib/middleware.js"; +import { + checkRateLimit, + resetRateLimitForTesting, +} from "../_lib/middleware.js"; import { buildDepositTx, runBlendAccrualKeeper, @@ -146,6 +161,26 @@ beforeEach(() => { }); describe("POST /api/v1/tx/deposit", () => { + it("returns 503 when the upstream rate limiter fails", async () => { + vi.mocked(checkRateLimit).mockRejectedValueOnce(new Error("Upstash timeout")); + + const res = makeRes(); + await depositHandler( + fakeReq({ + method: "POST", + body: { + walletAddress: PUBKEY, + vaultId: "blend-usdc-fixed", + amount: "10", + }, + }), + res + ); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ error: "Rate limiter unavailable; refusing to run" }); + }); + it("rejects non-POST methods with 405", async () => { const res = makeRes(); await depositHandler(fakeReq({ method: "GET", body: {} }), res); diff --git a/api/v1/positions/[publicKey].ts b/api/v1/positions/[publicKey].ts index c236b51a..b1161523 100644 --- a/api/v1/positions/[publicKey].ts +++ b/api/v1/positions/[publicKey].ts @@ -4,7 +4,14 @@ import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res))) return; + try { + if (!(await checkRateLimit(req, res))) return; + } catch (err) { + console.error("[positions] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } const { publicKey } = req.query as { publicKey: string }; const result = await handleGetPositions(publicKey); diff --git a/api/v1/tx/add-trustline.ts b/api/v1/tx/add-trustline.ts index 5050e9be..c1a96395 100644 --- a/api/v1/tx/add-trustline.ts +++ b/api/v1/tx/add-trustline.ts @@ -4,7 +4,14 @@ import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res, { strict: true }))) return; + try { + if (!(await checkRateLimit(req, res, { strict: true }))) return; + } catch (err) { + console.error("[tx/add-trustline] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" }); diff --git a/api/v1/tx/deposit.ts b/api/v1/tx/deposit.ts index 55ffa260..3e4e1e65 100644 --- a/api/v1/tx/deposit.ts +++ b/api/v1/tx/deposit.ts @@ -4,7 +4,14 @@ import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res, { strict: true }))) return; + try { + if (!(await checkRateLimit(req, res, { strict: true }))) return; + } catch (err) { + console.error("[tx/deposit] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" }); diff --git a/api/v1/tx/submit.ts b/api/v1/tx/submit.ts index 34fb1823..a4bd863a 100644 --- a/api/v1/tx/submit.ts +++ b/api/v1/tx/submit.ts @@ -4,7 +4,14 @@ import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res, { strict: true }))) return; + try { + if (!(await checkRateLimit(req, res, { strict: true }))) return; + } catch (err) { + console.error("[tx/submit] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" }); diff --git a/api/v1/tx/withdraw.ts b/api/v1/tx/withdraw.ts index 7b8711da..66db5379 100644 --- a/api/v1/tx/withdraw.ts +++ b/api/v1/tx/withdraw.ts @@ -4,7 +4,14 @@ import { applyCors, checkRateLimit } from "../../_lib/middleware.js"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res, { strict: true }))) return; + try { + if (!(await checkRateLimit(req, res, { strict: true }))) return; + } catch (err) { + console.error("[tx/withdraw] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" }); diff --git a/api/v1/vaults/[vaultId].ts b/api/v1/vaults/[vaultId].ts index 9b3ec055..95286858 100644 --- a/api/v1/vaults/[vaultId].ts +++ b/api/v1/vaults/[vaultId].ts @@ -13,7 +13,14 @@ const KNOWN_VAULT_IDS = new Set( export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res))) return; + try { + if (!(await checkRateLimit(req, res))) return; + } catch (err) { + console.error("[vaults] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } const raw = req.query["vaultId"]; const vaultId = typeof raw === "string" ? raw : undefined; if (!vaultId) return res.status(400).json({ error: "vaultId is required" }); diff --git a/api/v1/vaults/index.ts b/api/v1/vaults/index.ts index c464788f..4512b55d 100644 --- a/api/v1/vaults/index.ts +++ b/api/v1/vaults/index.ts @@ -16,7 +16,14 @@ const TESTNET_CACHE_CONTROL = "no-store"; export default async function handler(req: VercelRequest, res: VercelResponse) { if (applyCors(req, res)) return; - if (!(await checkRateLimit(req, res))) return; + try { + if (!(await checkRateLimit(req, res))) return; + } catch (err) { + console.error("[vaults] rate limit check failed:", err); + return res + .status(503) + .json({ error: "Rate limiter unavailable; refusing to run" }); + } const result = await handleGetVaults(); if (result.error) { From ebc08e223e978a2822462ec003f51e03d5f4d79f Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Sat, 29 Aug 2026 19:59:34 +0100 Subject: [PATCH 2/2] style: fix prettier formatting --- api/__tests__/handlers.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/api/__tests__/handlers.test.ts b/api/__tests__/handlers.test.ts index f041c214..d20c8ba1 100644 --- a/api/__tests__/handlers.test.ts +++ b/api/__tests__/handlers.test.ts @@ -7,8 +7,9 @@ vi.mock("../_lib/middleware.js", async () => { ); return { ...actual, - checkRateLimit: vi.fn(async (...args: Parameters) => - actual.checkRateLimit(...args) + checkRateLimit: vi.fn( + async (...args: Parameters) => + actual.checkRateLimit(...args) ), }; }); @@ -162,7 +163,9 @@ beforeEach(() => { describe("POST /api/v1/tx/deposit", () => { it("returns 503 when the upstream rate limiter fails", async () => { - vi.mocked(checkRateLimit).mockRejectedValueOnce(new Error("Upstash timeout")); + vi.mocked(checkRateLimit).mockRejectedValueOnce( + new Error("Upstash timeout") + ); const res = makeRes(); await depositHandler( @@ -178,7 +181,9 @@ describe("POST /api/v1/tx/deposit", () => { ); expect(res.statusCode).toBe(503); - expect(res.body).toEqual({ error: "Rate limiter unavailable; refusing to run" }); + expect(res.body).toEqual({ + error: "Rate limiter unavailable; refusing to run", + }); }); it("rejects non-POST methods with 405", async () => {