From 08eb942f9971d7034fd1919cfe574f20a7d62c23 Mon Sep 17 00:00:00 2001 From: MAC Date: Fri, 26 Jun 2026 00:43:13 +0100 Subject: [PATCH] fix(api): cap /candles/:slab date-span to what MAX_BARS can hold [BUG-008] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route validated from < to but never bounded the SPAN. The existing row cap (MAX_BARS * 10 = 50,000 rows via .limit()) only bounds the RESULT size — Postgres still has to scan/sort the full set of matching rows for the requested date range (filtered by slab + network, ordered by created_at ascending) to find the first 50,000, which is unbounded work for a multi-year range on a high-volume market regardless of how few rows are eventually returned or how small the final candle count is. Cap the span to MAX_BARS * bucketSeconds — scaled per resolution, so the limit is proportional to what could actually produce MAX_BARS buckets at that resolution (~3.5 days at 1-minute resolution, ~13.7 years at daily), rather than a flat arbitrary cutoff that would be wrong at either end. Two existing tests used from=0&to=9999999999 as a "don't care about the range" placeholder; narrowed to realistic windows since their actual intent was exercising basic data flow, not wide-range behavior. Added a regression test (multi-year range at 1-minute resolution is rejected without ever calling the DB; exact-max-span and resolution-scaling are accepted). Verified it fails against the pre-fix code (200 instead of 400) and passes against the fix. Co-authored-by: Claude Sonnet 4.6 --- src/routes/candles.ts | 14 ++++++++++++++ tests/routes/candles.test.ts | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/routes/candles.ts b/src/routes/candles.ts index 4ca10c6..8dd727f 100644 --- a/src/routes/candles.ts +++ b/src/routes/candles.ts @@ -107,6 +107,20 @@ export function candleRoutes(): Hono { return c.json({ s: "error", errmsg: "Invalid from/to" }, 400); } + // Cap the requested span to what could actually produce MAX_BARS buckets + // at this resolution. Without this, the row cap below (MAX_BARS * 10) + // only bounds the RESULT size — the DB still has to scan/sort the full + // matching row set for the requested date range to find the first N in + // ascending order, which is unbounded for a multi-year range on a + // high-volume market regardless of how few rows are eventually returned. + const maxSpanSeconds = MAX_BARS * bucketSeconds; + if (toSec - fromSec > maxSpanSeconds) { + return c.json( + { s: "error", errmsg: `Requested range exceeds the maximum span for resolution '${resolution}' (${maxSpanSeconds}s)` }, + 400, + ); + } + try { const { data, error } = await getSupabase() .from("trades") diff --git a/tests/routes/candles.test.ts b/tests/routes/candles.test.ts index 8fbdce9..5096a1f 100644 --- a/tests/routes/candles.test.ts +++ b/tests/routes/candles.test.ts @@ -97,7 +97,7 @@ describe("GET /candles/:slab", () => { it("returns no_data when trades table is empty", async () => { const app = candleRoutes(); - const res = await app.request(`/candles/${SLAB}?resolution=1&from=0&to=9999999999`); + const res = await app.request(`/candles/${SLAB}?resolution=1&from=1745150400&to=1745150460`); expect(res.status).toBe(200); const body = await res.json() as any; expect(body.s).toBe("no_data"); @@ -112,7 +112,7 @@ describe("GET /candles/:slab", () => { error: null, }); const app = candleRoutes(); - const res = await app.request(`/candles/${SLAB}?resolution=1&from=0&to=9999999999`); + const res = await app.request(`/candles/${SLAB}?resolution=1&from=1745150400&to=1745150460`); const body = await res.json() as any; expect(body.s).toBe("ok"); expect(body.t).toHaveLength(1); @@ -131,4 +131,32 @@ describe("GET /candles/:slab", () => { const res = await app.request(`/candles/${SLAB}?resolution=1&from=1000&to=500`); expect(res.status).toBe(400); }); + + describe("max date-span cap (BUG-008)", () => { + it("rejects a range wider than MAX_BARS buckets at the requested resolution, without querying the DB", async () => { + const app = candleRoutes(); + // resolution=1 (60s buckets) — MAX_BARS(5000) * 60s = 300,000s max span. + // Request a multi-year range, far beyond that. + const res = await app.request(`/candles/${SLAB}?resolution=1&from=0&to=9999999999`); + expect(res.status).toBe(400); + const body = await res.json() as any; + expect(body.errmsg).toMatch(/exceeds the maximum span/i); + // The query must never have been issued for an out-of-bounds range. + expect(mockSupabase.from).not.toHaveBeenCalled(); + }); + + it("allows a range exactly at the max span for the requested resolution", async () => { + const app = candleRoutes(); + const maxSpan = 5000 * 60; // MAX_BARS * RES_TO_SECONDS["1"] + const res = await app.request(`/candles/${SLAB}?resolution=1&from=1000000&to=${1000000 + maxSpan}`); + expect(res.status).toBe(200); + }); + + it("scales the allowed span with resolution — a multi-year range is fine at daily resolution", async () => { + const app = candleRoutes(); + // resolution=1D (86400s buckets) — MAX_BARS(5000) * 1 day ≈ 13.7 years. + const res = await app.request(`/candles/${SLAB}?resolution=1D&from=0&to=400000000`); // ~12.7 years + expect(res.status).toBe(200); + }); + }); });