mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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" },
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/db", () => {
|
||||
});
|
||||
|
||||
import {
|
||||
OPEN_METEO_ACCOUNT_DAY_WINDOW,
|
||||
OPEN_METEO_BUDGET_WINDOWS,
|
||||
admitWeight,
|
||||
openMeteoCallWeight,
|
||||
@@ -113,3 +114,23 @@ describe("reserveOpenMeteoCalls", () => {
|
||||
expect(store.get("open-meteo-budget:day")!.count).toBe(dayBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the per-account share", () => {
|
||||
it("refuses one account past its share while another account still gets through", async () => {
|
||||
store.clear();
|
||||
expect(OPEN_METEO_ACCOUNT_DAY_WINDOW.limit).toBe(400);
|
||||
// A two-year backfill weighs about 63 calls; six of them fit in the
|
||||
// share, the seventh does not.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
expect(await reserveOpenMeteoCalls(62.57, "acct-a")).toBe(true);
|
||||
}
|
||||
expect(await reserveOpenMeteoCalls(62.57, "acct-a")).toBe(false);
|
||||
expect(store.get("open-meteo-budget:account:acct-a:day")!.count).toBe(
|
||||
6 * 6257,
|
||||
);
|
||||
// The instance day is far from full, so another account is admitted.
|
||||
expect(await reserveOpenMeteoCalls(62.57, "acct-b")).toBe(true);
|
||||
// A request made for no account is bound by the instance windows only.
|
||||
expect(await reserveOpenMeteoCalls(1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -350,13 +350,16 @@ export interface AirQualityFetchResult {
|
||||
* problem: the caller gets what was fetched and why it stopped, and the days
|
||||
* not returned stay unfetched for the next run.
|
||||
*/
|
||||
export async function fetchDailyAirQuality(args: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
timezone: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<AirQualityFetchResult> {
|
||||
export async function fetchDailyAirQuality(
|
||||
args: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
timezone: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
},
|
||||
budget: { accountId?: string } = {},
|
||||
): Promise<AirQualityFetchResult> {
|
||||
const days: DailyAirQualityObservation[] = [];
|
||||
// Days before the feed's first day are answered here, without a request.
|
||||
for (
|
||||
@@ -379,7 +382,7 @@ export async function fetchDailyAirQuality(args: {
|
||||
variables.length,
|
||||
enumerateDayCount(chunk.startDate, chunk.endDate),
|
||||
);
|
||||
if (!(await reserveOpenMeteoCalls(weight))) {
|
||||
if (!(await reserveOpenMeteoCalls(weight, budget.accountId))) {
|
||||
return { days, stopped: "budget" };
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -256,20 +256,24 @@ export async function geocodeLocation(
|
||||
* cloud) are requested alongside and aggregated to per-day values here. Throws
|
||||
* a {@link SafeFetchError} on egress failure so the caller can classify it,
|
||||
* and an {@link OpenMeteoBudgetExhaustedError} when the instance-wide request
|
||||
* budget refuses the call (nothing is sent then).
|
||||
* budget, or the account's share of it (`budget.accountId`), refuses the call
|
||||
* (nothing is sent then). The account never reaches the request.
|
||||
*/
|
||||
export async function fetchDailyEnvironment(args: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
timezone: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<DailyEnvironmentObservation[]> {
|
||||
export async function fetchDailyEnvironment(
|
||||
args: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
timezone: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
},
|
||||
budget: { accountId?: string } = {},
|
||||
): Promise<DailyEnvironmentObservation[]> {
|
||||
const weight = openMeteoCallWeight(
|
||||
ARCHIVE_VARIABLE_COUNT,
|
||||
enumerateDayCount(args.startDate, args.endDate),
|
||||
);
|
||||
if (!(await reserveOpenMeteoCalls(weight))) {
|
||||
if (!(await reserveOpenMeteoCalls(weight, budget.accountId))) {
|
||||
throw new OpenMeteoBudgetExhaustedError();
|
||||
}
|
||||
const url = new URL(`${ARCHIVE_BASE_URL}/v1/archive`);
|
||||
|
||||
@@ -34,7 +34,7 @@ import { prisma } from "@/lib/db";
|
||||
/** One budget window. */
|
||||
export interface BudgetWindow {
|
||||
/** Bucket key suffix, also the window's name. */
|
||||
name: "minute" | "hour" | "day";
|
||||
name: "minute" | "hour" | "day" | "account-day";
|
||||
windowMs: number;
|
||||
/** Calls admitted per window (whole calls). */
|
||||
limit: number;
|
||||
@@ -46,6 +46,26 @@ export const OPEN_METEO_BUDGET_WINDOWS: readonly BudgetWindow[] = [
|
||||
{ name: "day", windowMs: 86_400_000, limit: 8_000 },
|
||||
];
|
||||
|
||||
/**
|
||||
* The share of the daily budget one account may use. The instance-wide
|
||||
* windows alone let a single account drain the day for everyone (repeated
|
||||
* backfills of a two-year range), so a request made for an account is also
|
||||
* charged to that account's own daily bucket. One twentieth of the day is
|
||||
* 400 calls: more than two full backfills of weather and air quality, and
|
||||
* still leaves the rest of the day for nineteen other accounts.
|
||||
*/
|
||||
export const OPEN_METEO_ACCOUNT_DAY_SHARE = 0.05;
|
||||
|
||||
const DAY_LIMIT =
|
||||
OPEN_METEO_BUDGET_WINDOWS.find((w) => w.name === "day")?.limit ?? 0;
|
||||
|
||||
/** The per-account daily window, charged only when a request names an account. */
|
||||
export const OPEN_METEO_ACCOUNT_DAY_WINDOW: BudgetWindow = {
|
||||
name: "account-day",
|
||||
windowMs: 86_400_000,
|
||||
limit: Math.floor(DAY_LIMIT * OPEN_METEO_ACCOUNT_DAY_SHARE),
|
||||
};
|
||||
|
||||
const BUCKET_PREFIX = "open-meteo-budget";
|
||||
const LOCK_KEY = "open-meteo-budget";
|
||||
|
||||
@@ -97,13 +117,23 @@ export function admitWeight(
|
||||
/**
|
||||
* Ask the budget for one request of `weight` calls. Returns true and charges
|
||||
* every window when it fits, false (charging nothing) when any window is
|
||||
* full.
|
||||
* full. With `accountId`, the account's own daily share is checked and
|
||||
* charged as well (`OPEN_METEO_ACCOUNT_DAY_SHARE`); requests made for no
|
||||
* particular account (the geocoder) are bound by the instance windows only.
|
||||
*/
|
||||
export async function reserveOpenMeteoCalls(weight: number): Promise<boolean> {
|
||||
export async function reserveOpenMeteoCalls(
|
||||
weight: number,
|
||||
accountId?: string,
|
||||
): Promise<boolean> {
|
||||
const weightCenti = Math.max(1, Math.ceil(weight * CENTI));
|
||||
const keys = OPEN_METEO_BUDGET_WINDOWS.map(
|
||||
(w) => `${BUCKET_PREFIX}:${w.name}`,
|
||||
);
|
||||
const windows: readonly BudgetWindow[] = accountId
|
||||
? [...OPEN_METEO_BUDGET_WINDOWS, OPEN_METEO_ACCOUNT_DAY_WINDOW]
|
||||
: OPEN_METEO_BUDGET_WINDOWS;
|
||||
const keyOf = (name: BudgetWindow["name"]) =>
|
||||
name === "account-day"
|
||||
? `${BUCKET_PREFIX}:account:${accountId}:day`
|
||||
: `${BUCKET_PREFIX}:${name}`;
|
||||
const nameOfKey = new Map(windows.map((w) => [keyOf(w.name), w.name]));
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// `pg_advisory_xact_lock` returns void, which the client cannot
|
||||
// deserialize as a column; selecting FROM it yields a plain row.
|
||||
@@ -112,21 +142,18 @@ export async function reserveOpenMeteoCalls(weight: number): Promise<boolean> {
|
||||
FROM pg_advisory_xact_lock(hashtextextended(${LOCK_KEY}, 0))
|
||||
`;
|
||||
const rows = await tx.rateLimit.findMany({
|
||||
where: { key: { in: keys } },
|
||||
where: { key: { in: [...nameOfKey.keys()] } },
|
||||
select: { key: true, count: true, resetAt: true },
|
||||
});
|
||||
const buckets = new Map<BudgetWindow["name"], BudgetBucket>();
|
||||
for (const row of rows) {
|
||||
const name = row.key.slice(BUCKET_PREFIX.length + 1) as
|
||||
BudgetWindow["name"] | string;
|
||||
if (name === "minute" || name === "hour" || name === "day") {
|
||||
buckets.set(name, { count: row.count, resetAt: row.resetAt });
|
||||
}
|
||||
const name = nameOfKey.get(row.key);
|
||||
if (name) buckets.set(name, { count: row.count, resetAt: row.resetAt });
|
||||
}
|
||||
const decision = admitWeight(buckets, weightCenti, new Date());
|
||||
const decision = admitWeight(buckets, weightCenti, new Date(), windows);
|
||||
if (!decision.admitted) return false;
|
||||
for (const [name, bucket] of decision.next) {
|
||||
const key = `${BUCKET_PREFIX}:${name}`;
|
||||
const key = keyOf(name);
|
||||
await tx.rateLimit.upsert({
|
||||
where: { key },
|
||||
create: { key, count: bucket.count, resetAt: bucket.resetAt },
|
||||
|
||||
@@ -330,13 +330,16 @@ export async function fetchAndStoreEnvironment(args: {
|
||||
const max = groupDays[groupDays.length - 1];
|
||||
let observations: DailyEnvironmentObservation[];
|
||||
try {
|
||||
observations = await fetchDailyEnvironment({
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
timezone,
|
||||
startDate: min,
|
||||
endDate: max,
|
||||
});
|
||||
observations = await fetchDailyEnvironment(
|
||||
{
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
timezone,
|
||||
startDate: min,
|
||||
endDate: max,
|
||||
},
|
||||
{ accountId: userId },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof OpenMeteoBudgetExhaustedError) {
|
||||
// Nothing was sent. The remaining groups wait for the next run.
|
||||
@@ -354,13 +357,16 @@ export async function fetchAndStoreEnvironment(args: {
|
||||
// air quality; the weather is stored regardless.
|
||||
let airByDay: Map<string, DailyAirQualityObservation> | null = null;
|
||||
if (airQuality) {
|
||||
const result = await fetchDailyAirQuality({
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
timezone,
|
||||
startDate: min,
|
||||
endDate: max,
|
||||
});
|
||||
const result = await fetchDailyAirQuality(
|
||||
{
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
timezone,
|
||||
startDate: min,
|
||||
endDate: max,
|
||||
},
|
||||
{ accountId: userId },
|
||||
);
|
||||
if (result.stopped === "budget") budgetBlocked = true;
|
||||
airByDay = new Map(result.days.map((d) => [d.date, d]));
|
||||
fetches += result.days.length > 0 ? 1 : 0;
|
||||
@@ -574,13 +580,16 @@ export async function fillAirQualityGaps(
|
||||
let filled = 0;
|
||||
for (const group of byLocation.values()) {
|
||||
for (const range of nearbyRanges(group.days)) {
|
||||
const result = await fetchDailyAirQuality({
|
||||
lat: group.lat,
|
||||
lon: group.lon,
|
||||
timezone,
|
||||
startDate: range[0],
|
||||
endDate: range[range.length - 1],
|
||||
});
|
||||
const result = await fetchDailyAirQuality(
|
||||
{
|
||||
lat: group.lat,
|
||||
lon: group.lon,
|
||||
timezone,
|
||||
startDate: range[0],
|
||||
endDate: range[range.length - 1],
|
||||
},
|
||||
{ accountId: userId },
|
||||
);
|
||||
const wanted = new Set(range);
|
||||
const now = new Date();
|
||||
const ops = result.days
|
||||
|
||||
@@ -103,6 +103,32 @@ export async function enqueueEnvironmentFetch(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One account's backfill slot: at most one backfill job per slot. */
|
||||
export const ENVIRONMENT_BACKFILL_SINGLETON_SECONDS = 20 * 60;
|
||||
|
||||
/**
|
||||
* Enqueue the on-demand backfill a person asked for from the settings
|
||||
* surface. Unlike the travel-period refresh, it carries a per-account
|
||||
* `singletonKey` with a time slot, so one account has at most one backfill
|
||||
* per twenty minutes no matter how often the button is pressed; pg-boss
|
||||
* enforces the slot (`singleton_on`) under every queue policy. A send the
|
||||
* slot refuses comes back as `already_queued`, and the route says so instead
|
||||
* of reporting a job that will not run.
|
||||
*/
|
||||
export async function enqueueEnvironmentBackfill(payload: {
|
||||
userId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<"enqueued" | "no_worker" | "already_queued"> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return "no_worker";
|
||||
const id = await boss.send(ENVIRONMENT_FETCH_QUEUE, payload, {
|
||||
singletonKey: `environment-backfill:${payload.userId}`,
|
||||
singletonSeconds: ENVIRONMENT_BACKFILL_SINGLETON_SECONDS,
|
||||
});
|
||||
return id ? "enqueued" : "already_queued";
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery fan-out: enqueue one per-user fetch job for every account with the
|
||||
* module on and a home location set. Idempotent (singletonKey-coalesced per
|
||||
|
||||
@@ -754,8 +754,10 @@ const schedules: ScheduleEntry[] = [
|
||||
* Any policy would collapse every keyless explicit-range backfill onto the
|
||||
* shared empty key, so two different requested date ranges would silently
|
||||
* become one. That is exactly the class of silent work-dropping a policy is
|
||||
* supposed to prevent, so the queue keeps `standard` until the enqueue side
|
||||
* gives the explicit-range variant a key of its own.
|
||||
* supposed to prevent, so the queue keeps `standard`. The settings
|
||||
* backfill (v1.42) is held per account by a keyed time slot
|
||||
* (`singletonSeconds`), which pg-boss enforces under any policy; the
|
||||
* travel-period refresh stays keyless so two periods never merge.
|
||||
* - APPLE_HEALTH_IMPORT_V2_QUEUE, APPLE_HEALTH_IMPORT_LEGACY_QUEUE,
|
||||
* DATA_BACKUP_QUEUE and PR_DETECTION_QUEUE send keylessly by design (each
|
||||
* import / backup / detection run is a distinct unit of work). A policy
|
||||
|
||||
@@ -177,6 +177,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
],
|
||||
encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"],
|
||||
environment: [
|
||||
"environment.backfill_pending",
|
||||
"environment.backfill_rate_limited",
|
||||
"environment.invalid",
|
||||
"environment.no_home",
|
||||
"environment.range_too_large",
|
||||
|
||||
@@ -361,7 +361,7 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
post: {
|
||||
tags: ["Environment"],
|
||||
summary: "Enqueue a historical environment fetch",
|
||||
description: `Asks the worker to fetch and store past days rather than waiting for the rolling nightly lookback. Returns 202 — the work has been QUEUED, not done; poll \`GET /api/environment\` and watch \`context.days\`.\n\nAn omitted bound defaults to the conservative \`[homeSince .. today]\` range. An explicit start earlier than \`homeSince\` is accepted and then resolves to a skip inside the worker: the span cannot fabricate weather for the pre-home past, so a wide range can legitimately return \`enqueued: true\` and add no days at all.\n\nThe span is capped at ${ENVIRONMENT_MAX_BACKFILL_DAYS} days, and the worker re-checks the cap independently. Body cap 4 KiB. Module-gated, and it draws on the shared analytics-read rate bucket.`,
|
||||
description: `Asks the worker to fetch and store past days rather than waiting for the rolling nightly lookback. Returns 202 — the work has been QUEUED, not done; poll \`GET /api/environment\` and watch \`context.days\`.\n\nAn omitted bound defaults to the conservative \`[homeSince .. today]\` range. An explicit start earlier than \`homeSince\` is accepted and then resolves to a skip inside the worker: the span cannot fabricate weather for the pre-home past, so a wide range can legitimately return \`enqueued: true\` and add no days at all.\n\nThe span is capped at ${ENVIRONMENT_MAX_BACKFILL_DAYS} days, and the worker re-checks the cap independently. Body cap 4 KiB. Module-gated, and it draws on the shared analytics-read rate bucket.\n\nEvery backfill draws on the instance-wide Open-Meteo budget, so one account is held to three a rolling hour (429 \`environment.backfill_rate_limited\`), at most one queued backfill per twenty-minute slot (409 \`environment.backfill_pending\`), and, inside the worker, its own share of the daily request budget; days the share does not reach are filled by the nightly run.`,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: backfillRequest } },
|
||||
@@ -391,7 +391,7 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...moduleDisabledResponse,
|
||||
"409": {
|
||||
description:
|
||||
"No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`.",
|
||||
"No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot (`environment.backfill_pending`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
|
||||
@@ -117,6 +117,24 @@ export async function checkAnalyticsReadRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment backfill: three a rolling hour per account. Each one can ask
|
||||
* the shared Open-Meteo budget for a two-year range, so the general
|
||||
* analytics ceiling (120 a minute) is far too loose for it.
|
||||
*/
|
||||
export const ENVIRONMENT_BACKFILL_LIMIT = 3;
|
||||
const ENVIRONMENT_BACKFILL_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export async function checkEnvironmentBackfillRateLimit(
|
||||
userId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(
|
||||
`environment-backfill:${userId}`,
|
||||
ENVIRONMENT_BACKFILL_LIMIT,
|
||||
ENVIRONMENT_BACKFILL_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared per-account ceiling for the single-record writes.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user