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:
Marc-André Bombeck committed 2026-08-13 00:33:14 +02:00
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) {