mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(documents): make the document-AI rate limit an operator setting with honest accounting
The document-AI actions (read/index, extract, suggest, summarise) shared a hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few minutes" while the window is an hour, and several failure paths burned a slot before any provider was dispatched. - DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling (default 6, clamped 1-1000), wired end to end: compose environment whitelist, .env.production.example, env manifest, operator docs. - INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest; it was documented but never listed, so an operator value could not reach the container. - The 429 mirrors the bucket's reset instant into meta.retryAt (plus the existing X-RateLimit-Reset header); the client derives "try again in about N minutes" from it in all six locales, with a plural-aware key. - A slot is consumed only when a request actually reaches the provider (or the local index/echo work): malformed bodies, unreadable originals, refused consent, and exhausted daily budgets refund the charged slot via a window-scoped refundRateLimit primitive. Consistent across all four routes sharing the bucket.
This commit is contained in:
1 parent
592d1e9aa8
commit
8e16df6894
26 files changed
+560
-160
No files matched your search
@@ -153,7 +153,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={null}
|
||||
isPending
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -167,7 +167,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={{ summary: "A lipid panel dated 1 March 2026." }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -180,7 +180,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="text"
|
||||
result={{ text: "LDL 3.1 mmol/L" }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -195,7 +195,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={null}
|
||||
isPending={false}
|
||||
errorKey="documents.assist.errorGeneric"
|
||||
errorText="Couldn't read the document. Try again."
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
@@ -209,7 +209,7 @@ describe("<DocumentSummaryPanel>", () => {
|
||||
output="summary"
|
||||
result={{ summary: "A lipid panel dated 1 March 2026." }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
errorText={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The honest-wait derivation for a document-AI 429. The routes mirror the
|
||||
* bucket's reset instant into `meta.retryAt`; the client turns it into "try
|
||||
* again in about N minutes". This pins the mapping: only a document-AI
|
||||
* rate-limit error yields minutes, the floor is one minute, and anything
|
||||
* malformed falls back to null (the generic copy).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
|
||||
import { documentAiRetryMinutes } from "../use-document-assist";
|
||||
|
||||
const NOW = new Date("2026-08-12T12:00:00.000Z").getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function rateLimited(retryAt?: unknown): ApiError {
|
||||
return new ApiError("Too many document AI requests this hour.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
...(retryAt !== undefined ? { retryAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("documentAiRetryMinutes", () => {
|
||||
it("derives whole minutes from the reset instant, rounding up", () => {
|
||||
const err = rateLimited(new Date(NOW + 42.4 * 60_000).toISOString());
|
||||
expect(documentAiRetryMinutes(err)).toBe(43);
|
||||
});
|
||||
|
||||
it("floors at one minute for a reset only seconds away", () => {
|
||||
const err = rateLimited(new Date(NOW + 20_000).toISOString());
|
||||
expect(documentAiRetryMinutes(err)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null when the error carries no usable instant", () => {
|
||||
expect(documentAiRetryMinutes(rateLimited())).toBeNull();
|
||||
expect(documentAiRetryMinutes(rateLimited("soon"))).toBeNull();
|
||||
expect(documentAiRetryMinutes(rateLimited(42))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for any other error class", () => {
|
||||
expect(
|
||||
documentAiRetryMinutes(
|
||||
new ApiError("Budget", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(documentAiRetryMinutes(new Error("network"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -202,13 +202,14 @@ export function DocumentSummaryPanel({
|
||||
output,
|
||||
result,
|
||||
isPending,
|
||||
errorKey,
|
||||
errorText,
|
||||
onClose,
|
||||
}: {
|
||||
output: DocumentSummaryMode;
|
||||
result: DocumentDescribeResult | null;
|
||||
isPending: boolean;
|
||||
errorKey: string | null;
|
||||
/** Resolved user-facing error sentence (already translated), or null. */
|
||||
errorText: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -247,9 +248,9 @@ export function DocumentSummaryPanel({
|
||||
<Skeleton className="h-3.5 w-11/12" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
) : errorKey ? (
|
||||
) : errorText ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(errorKey)}
|
||||
{errorText}
|
||||
</p>
|
||||
) : body !== null ? (
|
||||
<p
|
||||
|
||||
@@ -92,6 +92,7 @@ export function DocumentAiSection({
|
||||
actionsDisabled: boolean;
|
||||
onSuggest: () => void;
|
||||
suggestPending: boolean;
|
||||
/** Resolved user-facing error sentence (already translated), or null. */
|
||||
suggestErrorKey: string | null;
|
||||
onSummarise: (output: DocumentSummaryMode) => void;
|
||||
summaryPending: boolean;
|
||||
@@ -105,6 +106,7 @@ export function DocumentAiSection({
|
||||
onDismissSuggestion: () => void;
|
||||
summaryOutput: DocumentSummaryMode | null;
|
||||
summaryResult: DocumentDescribeResult | null;
|
||||
/** Resolved user-facing error sentence (already translated), or null. */
|
||||
summaryErrorKey: string | null;
|
||||
onCloseSummary: () => void;
|
||||
hasContentIndex: boolean;
|
||||
@@ -233,7 +235,7 @@ export function DocumentAiSection({
|
||||
|
||||
{suggestErrorKey ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(suggestErrorKey)}
|
||||
{suggestErrorKey}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -255,7 +257,7 @@ export function DocumentAiSection({
|
||||
output={summaryOutput}
|
||||
result={summaryResult}
|
||||
isPending={summaryPending}
|
||||
errorKey={summaryErrorKey}
|
||||
errorText={summaryErrorKey}
|
||||
onClose={onCloseSummary}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -87,8 +87,8 @@ import { DocumentShareSheet } from "./document-share-sheet";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { useIndexDocument } from "./use-content-index";
|
||||
import {
|
||||
documentAiErrorKey,
|
||||
useDocumentAiCapability,
|
||||
useDocumentAiErrorText,
|
||||
useDocumentsAutoAiRead,
|
||||
useDocumentSummary,
|
||||
useSuggestDetails,
|
||||
@@ -393,6 +393,9 @@ export function DocumentDetailSheet({
|
||||
);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
// Resolves an AI error to final copy — a 429 names the actual wait, derived
|
||||
// from the reset instant the route mirrors into the error meta.
|
||||
const aiErrorText = useDocumentAiErrorText();
|
||||
// Stored generation has an independent mutation state so transient
|
||||
// Summarise/Show text requests cannot change this block's spinner or result.
|
||||
const storedSummary = useDocumentSummary();
|
||||
@@ -593,7 +596,7 @@ export function DocumentDetailSheet({
|
||||
queryKey: queryKeys.inboundDocument(documentId),
|
||||
});
|
||||
},
|
||||
onError: (error) => toast.error(t(documentAiErrorKey(error))),
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -604,7 +607,7 @@ export function DocumentDetailSheet({
|
||||
{ mode: aiMode, target: aiTarget },
|
||||
{
|
||||
onSuccess: () => toast.success(t("documents.ai.readDone")),
|
||||
onError: (error) => toast.error(t(documentAiErrorKey(error))),
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -836,7 +839,7 @@ export function DocumentDetailSheet({
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
suggestErrorKey={
|
||||
suggest.isError ? documentAiErrorKey(suggest.error) : null
|
||||
suggest.isError ? aiErrorText(suggest.error) : null
|
||||
}
|
||||
onSummarise={runSummary}
|
||||
summaryPending={summary.isPending}
|
||||
@@ -851,7 +854,7 @@ export function DocumentDetailSheet({
|
||||
summaryOutput={summaryOutput}
|
||||
summaryResult={summary.data ?? null}
|
||||
summaryErrorKey={
|
||||
summary.isError ? documentAiErrorKey(summary.error) : null
|
||||
summary.isError ? aiErrorText(summary.error) : null
|
||||
}
|
||||
onCloseSummary={() => {
|
||||
setSummaryOutput(null);
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
* prefills; the summary is transient. The only mutation is the caller applying
|
||||
* a reviewed draft through the existing edit-on-commit machinery.
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
DocumentAiCapabilityDto,
|
||||
@@ -129,6 +132,45 @@ export function useDocumentSummary() {
|
||||
* `meta.errorCode`; client-side precondition failures carry a `reason`. The
|
||||
* default is the calm "try again" message — never a raw provider string.
|
||||
*/
|
||||
/**
|
||||
* The actual wait, in whole minutes, behind a document-AI 429. The routes
|
||||
* mirror the bucket's reset instant into the error meta (`retryAt`, ISO 8601);
|
||||
* the window is an hour, so a fixed "try again in a few minutes" understated
|
||||
* the wait for most of it. Null when the error is not a document-AI rate-limit
|
||||
* response or carries no usable instant — the caller falls back to the generic
|
||||
* copy. Floored at one minute: a reset seconds away still reads as "about
|
||||
* 1 minute", never "0 minutes".
|
||||
*/
|
||||
export function documentAiRetryMinutes(err: unknown): number | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
if (err.meta?.errorCode !== "documents.inbound.rateLimited") return null;
|
||||
const raw = err.meta?.retryAt;
|
||||
if (typeof raw !== "string") return null;
|
||||
const at = Date.parse(raw);
|
||||
if (!Number.isFinite(at)) return null;
|
||||
return Math.max(1, Math.ceil((at - Date.now()) / 60_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a document-AI error to the user-facing sentence. Rate-limited
|
||||
* responses derive their copy from the ACTUAL reset instant ("try again in
|
||||
* about N minutes"); everything else maps through `documentAiErrorKey`. The
|
||||
* result is final text — callers render or toast it as-is.
|
||||
*/
|
||||
export function useDocumentAiErrorText(): (err: unknown) => string {
|
||||
const { t, tCount } = useTranslations();
|
||||
return useCallback(
|
||||
(err: unknown) => {
|
||||
const minutes = documentAiRetryMinutes(err);
|
||||
if (minutes !== null) {
|
||||
return tCount("documents.assist.errorRateLimitedWait", minutes);
|
||||
}
|
||||
return t(documentAiErrorKey(err));
|
||||
},
|
||||
[t, tCount],
|
||||
);
|
||||
}
|
||||
|
||||
export function documentAiErrorKey(err: unknown): string {
|
||||
if (err instanceof DocumentAssistClientError) {
|
||||
switch (err.reason) {
|
||||
|
||||
Reference in new issue
Block a user