mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user