mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(labs): make the lab-scan limit operator-tunable
A self-hoster scanning a stack of lab reports ran into a fixed six scans an hour with no way to raise it. LABS_OCR_LIMIT_PER_HOUR now sets the per-user ceiling (default 6, clamped to 1-1000, falling back to the default when unset or not a number), the same shape as DOCUMENT_AI_LIMIT_PER_HOUR. It is on the compose whitelist, in the production example, the env manifest and the AI provider guide. The route also charged the slot before it had looked at the request, so a malformed body, an unsupported or oversized file, a PDF that could not be rendered or a scan the daily budget refused still used up the hour. Those failures hand the slot back now; a scan that reached the provider keeps it. The 429 carries meta.retryAt. Refs #985
This commit is contained in:
1 parent
8457e4ffcb
commit
ed8a93afc2
10 files changed
+304
-37
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
refundRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({ "Retry-After": "1800" })),
|
||||
}));
|
||||
|
||||
import {
|
||||
checkLabsOcrRateLimit,
|
||||
labsOcrRateLimited,
|
||||
refundLabsOcrSlot,
|
||||
resolveLabsOcrLimitPerHour,
|
||||
} from "../ocr-rate-limit";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("LABS_OCR_LIMIT_PER_HOUR", () => {
|
||||
it("defaults to 6 when unset or not a number", () => {
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "");
|
||||
expect(resolveLabsOcrLimitPerHour()).toBe(6);
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "lots");
|
||||
expect(resolveLabsOcrLimitPerHour()).toBe(6);
|
||||
});
|
||||
|
||||
it("uses an operator value inside the range", () => {
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "40");
|
||||
expect(resolveLabsOcrLimitPerHour()).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps to 1-1000 so a typo can neither switch scans off nor remove the cap", () => {
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "0");
|
||||
expect(resolveLabsOcrLimitPerHour()).toBe(1);
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "-5");
|
||||
expect(resolveLabsOcrLimitPerHour()).toBe(1);
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "50000");
|
||||
expect(resolveLabsOcrLimitPerHour()).toBe(1000);
|
||||
});
|
||||
|
||||
it("charges the existing bucket key with the resolved ceiling and an hour window", async () => {
|
||||
vi.stubEnv("LABS_OCR_LIMIT_PER_HOUR", "25");
|
||||
await checkLabsOcrRateLimit("user-1");
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"labs-ocr:user-1",
|
||||
25,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("refunds the same bucket and never throws when the refund fails", async () => {
|
||||
vi.mocked(refundRateLimit).mockRejectedValueOnce(new Error("db down"));
|
||||
await expect(refundLabsOcrSlot("user-1")).resolves.toBeUndefined();
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("labs-ocr:user-1");
|
||||
});
|
||||
|
||||
it("names the reset instant on the 429", async () => {
|
||||
const resetAt = Date.parse("2026-09-15T15:00:00Z");
|
||||
const res = labsOcrRateLimited({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt,
|
||||
} as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("Retry-After")).toBe("1800");
|
||||
const body = (await res.json()) as {
|
||||
meta: { errorCode: string; retryAt: string };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("labs.ocr.rateLimited");
|
||||
expect(body.meta.retryAt).toBe("2026-09-15T15:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import {
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
refundRateLimit,
|
||||
type RateLimitResult,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* The lab-report scan (`POST /api/labs/ocr/extract`, both the vision and the
|
||||
* browser-OCR text mode) charges one per-user hourly bucket. The ceiling is
|
||||
* operator-tunable via `LABS_OCR_LIMIT_PER_HOUR` (default 6, clamped to
|
||||
* 1-1000 so a typo can neither switch the scan off nor remove the cap), the
|
||||
* same posture as `DOCUMENT_AI_LIMIT_PER_HOUR` for the document vault. Unset or
|
||||
* non-numeric falls back to the default. Cost is bounded separately by the
|
||||
* daily AI budget; this bucket only bounds the request rate.
|
||||
*/
|
||||
const LABS_OCR_LIMIT_DEFAULT = 6;
|
||||
const LABS_OCR_LIMIT_MIN = 1;
|
||||
const LABS_OCR_LIMIT_MAX = 1000;
|
||||
|
||||
export function resolveLabsOcrLimitPerHour(): number {
|
||||
const raw = process.env.LABS_OCR_LIMIT_PER_HOUR;
|
||||
if (!raw) return LABS_OCR_LIMIT_DEFAULT;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) return LABS_OCR_LIMIT_DEFAULT;
|
||||
return Math.min(LABS_OCR_LIMIT_MAX, Math.max(LABS_OCR_LIMIT_MIN, parsed));
|
||||
}
|
||||
|
||||
export const LABS_OCR_WINDOW_MS = 60 * 60 * 1000;
|
||||
/** Bucket key prefix. Unchanged, so a running window survives the upgrade. */
|
||||
export const LABS_OCR_BUCKET = "labs-ocr";
|
||||
|
||||
/** Charge one slot of the per-user lab-scan bucket. */
|
||||
export async function checkLabsOcrRateLimit(
|
||||
userId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(
|
||||
`${LABS_OCR_BUCKET}:${userId}`,
|
||||
resolveLabsOcrLimitPerHour(),
|
||||
LABS_OCR_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a charged slot back when the scan failed BEFORE the provider was
|
||||
* called: a malformed body, a file that is too large or not an image or PDF,
|
||||
* a PDF that could not be rendered, or a daily budget that turned it away.
|
||||
* The bucket is charged early so a 429 stays cheap, but a slot must stand for
|
||||
* a scan that was actually sent. Best-effort: a failed refund never masks the
|
||||
* response the caller is about to return.
|
||||
*/
|
||||
export async function refundLabsOcrSlot(userId: string): Promise<void> {
|
||||
try {
|
||||
await refundRateLimit(`${LABS_OCR_BUCKET}:${userId}`);
|
||||
} catch {
|
||||
// The original response matters more than the bookkeeping.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 429 for the lab-scan bucket. Carries the standard `X-RateLimit-*`
|
||||
* headers and mirrors the reset instant into `meta.retryAt` (ISO 8601) so the
|
||||
* client can name the actual wait.
|
||||
*/
|
||||
export function labsOcrRateLimited(rl: RateLimitResult): Response {
|
||||
const response = apiError("Too many scans. Try again later.", 429, {
|
||||
errorCode: "labs.ocr.rateLimited",
|
||||
retryAt: new Date(rl.resetAt).toISOString(),
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Labs"],
|
||||
summary: "Extract lab readings from a photo, PDF, or OCR'd text",
|
||||
description:
|
||||
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a 6/hour rate bucket, and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
|
||||
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are gated by AI consent, a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`), and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
||||
Reference in new issue
Block a user