diff --git a/api/__tests__/handlers.test.ts b/api/__tests__/handlers.test.ts index bc4763b9..d20c8ba1 100644 --- a/api/__tests__/handlers.test.ts +++ b/api/__tests__/handlers.test.ts @@ -1,6 +1,19 @@ 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 +109,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 +162,30 @@ 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) {