fix(environment): keep one account from draining the Open-Meteo budget

The backfill route shared the analytics-read bucket and sent every
explicit range without a key, so one account could queue two-year
backfills until the instance-wide daily budget was gone for everyone.

The route now allows three backfills a rolling hour per account (429
environment.backfill_rate_limited) and one queued backfill per
twenty-minute slot, keyed on the account (409
environment.backfill_pending). Every fetch made for an account is also
charged to that account's own share of the daily budget, one twentieth
of it, so a refused request ends the run as budget-blocked and the
nightly run continues.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:37:56 +02:00
1 parent 78cde56542
commit 509166afc4
12 files changed
+309 -59

No files matched your search

@@ -0,0 +1,117 @@
/**
* v1.42 — one account must not drain the instance-wide Open-Meteo budget.
*
* The backfill route shared the analytics-read bucket (120 a minute) and sent
* every explicit range without a key, so one account could queue a
* two-year backfill again and again. It is now held to three a rolling hour,
* and to one queued backfill per twenty-minute slot, keyed on the account.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { send, store } = vi.hoisted(() => ({
send: vi.fn(),
store: new Map<string, number>(),
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAuth: vi.fn(async () => ({ user: { id: "acct-1" } })),
};
});
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: () => ({ send }),
}));
vi.mock("@/lib/rate-limit", async () => {
const actual =
await vi.importActual<typeof import("@/lib/rate-limit")>(
"@/lib/rate-limit",
);
const counted = (limit: number) => async (key: string) => {
const count = (store.get(key) ?? 0) + 1;
store.set(key, count);
return {
allowed: count <= limit,
limit,
remaining: Math.max(0, limit - count),
resetAt: Date.now() + 1000,
};
};
return {
...actual,
checkAnalyticsReadRateLimit: (id: string) =>
counted(120)(`analytics-read:${id}`),
checkEnvironmentBackfillRateLimit: (id: string) =>
counted(actual.ENVIRONMENT_BACKFILL_LIMIT)(`environment-backfill:${id}`),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(async () => ({
homeLat: null,
homeLocationEncrypted: new Uint8Array([1]),
homeSince: new Date("2025-01-01T00:00:00Z"),
})),
},
},
}));
import { POST } from "../route";
function post() {
return new NextRequest("http://localhost/api/environment/backfill", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ startDate: "2025-01-01", endDate: "2026-10-01" }),
});
}
beforeEach(() => {
store.clear();
send.mockReset().mockResolvedValue("job-id");
});
describe("POST /api/environment/backfill budget protection", () => {
it("sends with a per-account singleton key and slot", async () => {
const res = await POST(post());
expect(res.status).toBe(202);
expect(send).toHaveBeenCalledWith(
"environment-fetch",
expect.objectContaining({ userId: "acct-1" }),
expect.objectContaining({
singletonKey: "environment-backfill:acct-1",
singletonSeconds: 20 * 60,
}),
);
});
it("answers 409 when the account's slot already holds a backfill", async () => {
send.mockResolvedValue(null);
const res = await POST(post());
expect(res.status).toBe(409);
const body = await res.json();
expect(body.meta.errorCode).toBe("environment.backfill_pending");
});
it("refuses the fourth backfill in an hour with 429", async () => {
for (let i = 0; i < 3; i++) {
expect((await POST(post())).status).toBe(202);
}
const res = await POST(post());
expect(res.status).toBe(429);
expect(send).toHaveBeenCalledTimes(3);
});
});
+24 -3
View File
@@ -6,6 +6,11 @@
* The span is capped (the worker re-checks the cap too). Requires a home
* location (otherwise there is nothing to resolve). Module-gated + rate-limited;
* `userId` is narrowed from auth.
*
* Every backfill draws on the instance-wide Open-Meteo budget, so one account
* is held to three a rolling hour (429), one queued per twenty minutes (409
* `environment.backfill_pending`), and its own share of the daily budget in
* the worker (`OPEN_METEO_ACCOUNT_DAY_SHARE`).
*/
import { NextRequest } from "next/server";
@@ -18,7 +23,10 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import {
checkAnalyticsReadRateLimit,
checkEnvironmentBackfillRateLimit,
} from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { environmentBackfillSchema } from "@/lib/validations/environment";
import {
@@ -26,7 +34,7 @@ import {
defaultBackfillRange,
utcDayKey,
} from "@/lib/environment/service";
import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch";
import { enqueueEnvironmentBackfill } from "@/lib/jobs/environment-fetch";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -96,11 +104,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const enqueued = await enqueueEnvironmentFetch({
const backfillLimit = await checkEnvironmentBackfillRateLimit(user.id);
if (!backfillLimit.allowed) {
return apiError("Too many backfills. Please retry later.", 429, {
errorCode: "environment.backfill_rate_limited",
});
}
const outcome = await enqueueEnvironmentBackfill({
userId: user.id,
startDate,
endDate,
});
if (outcome === "already_queued") {
return apiError("A backfill for this account is already queued.", 409, {
errorCode: "environment.backfill_pending",
});
}
const enqueued = outcome === "enqueued";
annotate({
action: { name: "environment.backfill.enqueue" },