loadProducts()} requestId={getErrorRequestId(error)} />}
{isLoggedIn && !error && products.length > 0 && (
<>
diff --git a/frontend/src/app/dashboard/qa-health/page.tsx b/frontend/src/app/dashboard/qa-health/page.tsx
index f8c00dd0c..2e31560bc 100644
--- a/frontend/src/app/dashboard/qa-health/page.tsx
+++ b/frontend/src/app/dashboard/qa-health/page.tsx
@@ -24,6 +24,7 @@ import { StatusBadge, type ComplianceState } from "@/components/ui/status-badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useQAHealth } from "@/hooks/use-api";
import { useAuth } from "@/lib/auth-context";
+import { getErrorRequestId } from "@/lib/api-error";
import { isRequiresStepUpPayload, requestStepUp } from "@/lib/step-up-events";
import type {
QAHealthArtifactRef,
@@ -295,7 +296,7 @@ export default function QAHealthPage() {
return (
- refetch()} retrying={isRefetching} />
+ refetch()} retrying={isRefetching} requestId={getErrorRequestId(error)} />
);
}
diff --git a/frontend/src/app/dashboard/recall-report/page.tsx b/frontend/src/app/dashboard/recall-report/page.tsx
index e90f181be..e6d03e155 100644
--- a/frontend/src/app/dashboard/recall-report/page.tsx
+++ b/frontend/src/app/dashboard/recall-report/page.tsx
@@ -13,6 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/lib/auth-context';
import { useTenant } from '@/lib/tenant-context';
import { fetchRecallReport } from '@/lib/api-hooks';
+import { getErrorRequestId } from '@/lib/api-error';
import { ErrorState } from '@/components/ui/error-state';
import { fetchWithCsrf } from '@/lib/fetch-with-csrf';
import {
@@ -120,7 +121,7 @@ export default function RecallReportPage() {
-
refetch()} />
+ refetch()} requestId={getErrorRequestId(error)} />
);
diff --git a/frontend/src/app/dashboard/settings/page.tsx b/frontend/src/app/dashboard/settings/page.tsx
index 503ae28d6..da897ff58 100644
--- a/frontend/src/app/dashboard/settings/page.tsx
+++ b/frontend/src/app/dashboard/settings/page.tsx
@@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAuth } from '@/lib/auth-context';
import { useTenant } from '@/lib/tenant-context';
import { useCurrentSubscription } from '@/hooks/use-billing';
+import { getErrorRequestId } from '@/lib/api-error';
import {
Building2, Key, Database, Plug,
Save, CheckCircle2, CreditCard,
@@ -105,7 +106,7 @@ export default function SettingsPage() {
const { user, isAuthenticated } = useAuth();
const { tenantId, isSystemTenant } = useTenant();
const effectiveTenantId = isSystemTenant ? null : tenantId;
- const { data: subscriptionData, isLoading: subLoading, isError: subError, refetch: refetchSubscription, isFetching: subFetching } = useCurrentSubscription(effectiveTenantId ?? undefined);
+ const { data: subscriptionData, isLoading: subLoading, isError: subError, error: subQueryError, refetch: refetchSubscription, isFetching: subFetching } = useCurrentSubscription(effectiveTenantId ?? undefined);
const [saved, setSaved] = useState(false);
const [activeTab, setActiveTab] = useState<'profile' | 'api' | 'retention' | 'integrations'>('profile');
@@ -271,7 +272,7 @@ export default function SettingsPage() {
) : subError ? (
{planName}
- refetchSubscription()} retrying={subFetching} className="mt-2" />
+ refetchSubscription()} retrying={subFetching} requestId={getErrorRequestId(subQueryError)} className="mt-2" />
) : (
<>
diff --git a/frontend/src/app/records/page.tsx b/frontend/src/app/records/page.tsx
index 463509df2..34949ab20 100644
--- a/frontend/src/app/records/page.tsx
+++ b/frontend/src/app/records/page.tsx
@@ -7,15 +7,16 @@ import { PageHeader } from '@/components/layout/page-header';
import { Section } from '@/components/layout/section';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
-import { Callout } from '@/components/ui/callout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DataTable, type DataTableColumn } from '@/components/ui/data-table';
+import { ErrorState } from '@/components/ui/error-state';
import { fieldControlClasses } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { StatusBadge, type ComplianceState } from '@/components/ui/status-badge';
import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/auth-context';
+import { getErrorRequestId, getErrorRetryAfter, getErrorStatus } from '@/lib/api-error';
import {
useCanonicalEvents,
useCanonicalEvent,
@@ -50,6 +51,14 @@ const RESULT_STATE: Record = {
const FILTER_SELECT = 'h-8 w-auto max-w-[13rem] py-0 pr-8 text-small';
+/** Safe 429 copy; appends the Retry-After wait only when it is a plain second count. */
+function rateLimitMessage(retryAfterSeconds: number | null): string {
+ if (retryAfterSeconds === null) {
+ return 'Too many requests. Wait a moment before retrying.';
+ }
+ return `Too many requests. Wait a moment before retrying. Retry in ${retryAfterSeconds} seconds.`;
+}
+
/** Key/value row for the record detail — label above value, hairline separated. */
function DetailRow({ term, children }: { term: string; children: React.ReactNode }) {
return (
@@ -88,21 +97,18 @@ export default function CanonicalRecordsPage() {
if (events.error) {
+ // Fixed safe copy only — the raw backend detail is never rendered.
+ const loadErrorMessage = getErrorStatus(events.error) === 429
+ ? rateLimitMessage(getErrorRetryAfter(events.error))
+ : 'We could not load your records.';
return (
{pageHeader}
- events.refetch()}>
- Retry
-
- }
- >
- {(events.error as Error).message}
-
+ events.refetch()}
+ requestId={getErrorRequestId(events.error)}
+ />
);
}
diff --git a/frontend/src/components/dashboard/agent-review-dashboard.tsx b/frontend/src/components/dashboard/agent-review-dashboard.tsx
index ac44fd258..ae9548beb 100644
--- a/frontend/src/components/dashboard/agent-review-dashboard.tsx
+++ b/frontend/src/components/dashboard/agent-review-dashboard.tsx
@@ -15,6 +15,7 @@ import {
} from 'lucide-react';
import { useAgentReviewDecision, useAgentReviewItems } from '@/hooks/use-agent-reviews';
+import { getErrorRequestId } from '@/lib/api-error';
import { useAuth } from '@/lib/auth-context';
import { useTenant } from '@/lib/tenant-context';
import { cn } from '@/lib/utils';
@@ -636,7 +637,7 @@ export function AgentReviewDashboard() {
Loading agent reviews...
) : query.error ? (
- query.refetch()} retrying={query.isFetching} className="m-4" />
+ query.refetch()} retrying={query.isFetching} requestId={getErrorRequestId(query.error)} className="m-4" />
) : sortedItems.length === 0 ? (
refetch()} retrying={isFetching} />;
+ return refetch()} retrying={isFetching} requestId={getErrorRequestId(error)} />;
}
if (sortedItems.length === 0) {
diff --git a/frontend/src/hooks/use-agent-reviews.ts b/frontend/src/hooks/use-agent-reviews.ts
index 44d6d23fe..4fb0f078d 100644
--- a/frontend/src/hooks/use-agent-reviews.ts
+++ b/frontend/src/hooks/use-agent-reviews.ts
@@ -3,6 +3,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchWithCsrf } from '@/lib/fetch-with-csrf';
+import { withRequestId } from '@/lib/api-error';
import type {
AgentReviewDecisionInput,
AgentReviewDecisionResponse,
@@ -53,7 +54,7 @@ async function parseAgentReviewError(response: Response): Promise {
: typeof errorBody?.detail === 'string'
? errorBody.detail
: `Agent review request failed: ${response.status} ${response.statusText}`;
- return new Error(message);
+ return withRequestId(new Error(message), response);
}
async function readJsonResponse(response: Response): Promise {
diff --git a/frontend/src/hooks/use-api-query.ts b/frontend/src/hooks/use-api-query.ts
index ca5892cdf..e2e80e1be 100644
--- a/frontend/src/hooks/use-api-query.ts
+++ b/frontend/src/hooks/use-api-query.ts
@@ -20,7 +20,7 @@ import { useAuth } from '@/lib/auth-context';
import { useTenant } from '@/lib/tenant-context';
import { getServiceURL } from '@/lib/api-config';
import { fetchWithCsrf } from '@/lib/fetch-with-csrf';
-import { apiError } from '@/lib/api-error';
+import { apiError, requestIdFromResponse } from '@/lib/api-error';
type ServiceName = 'ingestion' | 'admin' | 'compliance' | 'graph';
@@ -46,7 +46,7 @@ async function authFetch(
if (!res.ok) {
// Keep the status machine-readable so callers can tell 401 (session
// expired, redirect to login) from 403 (forbidden) without parsing text.
- throw apiError(res.status, res.statusText);
+ throw apiError(res.status, res.statusText, requestIdFromResponse(res));
}
return res.json();
diff --git a/frontend/src/hooks/use-api.ts b/frontend/src/hooks/use-api.ts
index 73071e107..5de21680c 100644
--- a/frontend/src/hooks/use-api.ts
+++ b/frontend/src/hooks/use-api.ts
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
-import { apiError, isAuthError } from '@/lib/api-error';
+import { apiError, isAuthError, requestIdFromResponse } from '@/lib/api-error';
import type { IngestURLRequest } from '@/types/api';
import type { LabelBatchInitRequest } from '@/types/labels';
import type { SystemStatusResponse, SystemMetricsResponse } from '@/lib/api-client';
@@ -60,7 +60,7 @@ async function fetchAggregatedStatus(): Promise {
// down. Tag the status so the caller can surface it instead of
// reporting healthy-looking "unhealthy" demo services.
if (res.status === 401 || res.status === 403) {
- throw apiError(res.status, res.statusText);
+ throw apiError(res.status, res.statusText, requestIdFromResponse(res));
}
throw new Error(`HTTP ${res.status}`);
}
diff --git a/frontend/src/hooks/use-billing.ts b/frontend/src/hooks/use-billing.ts
index b9e9f21ff..1fb4737b7 100644
--- a/frontend/src/hooks/use-billing.ts
+++ b/frontend/src/hooks/use-billing.ts
@@ -7,6 +7,7 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { getServiceURL } from '@/lib/api-config';
+import { withRequestId } from '@/lib/api-error';
import { fetchWithCsrf } from '@/lib/fetch-with-csrf';
// Legacy billing helpers still route through the admin proxy when those
@@ -28,7 +29,7 @@ async function billingFetch(path: string, options?: RequestInit): Promise
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: 'Request failed' }));
- throw new Error(error.detail || `Billing API error: ${res.status}`);
+ throw withRequestId(new Error(error.detail || `Billing API error: ${res.status}`), res);
}
return res.json();
@@ -191,7 +192,7 @@ async function fetchCurrentSubscription(tenantId: string): Promise ({ detail: 'Request failed' }));
- throw new Error(error.detail || `Billing API error: ${res.status}`);
+ throw withRequestId(new Error(error.detail || `Billing API error: ${res.status}`), res);
}
return res.json();
diff --git a/frontend/src/hooks/use-control-plane.ts b/frontend/src/hooks/use-control-plane.ts
index 2146a423a..d36012cb4 100644
--- a/frontend/src/hooks/use-control-plane.ts
+++ b/frontend/src/hooks/use-control-plane.ts
@@ -3,6 +3,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/lib/auth-context';
import { fetchWithCsrf } from '@/lib/fetch-with-csrf';
+import { withRequestId, type StatusError } from '@/lib/api-error';
import { POLL_CONTROL_PLANE_MS, POLL_DATA_MS, POLL_METRICS_MS } from '@/lib/polling-config';
// ---------------------------------------------------------------------------
@@ -42,7 +43,21 @@ async function cpFetch(
// Backend errors arrive as {detail} (FastAPI HTTPException) or as the
// monolith envelope {error: {type, message}} — read both so callers'
// toasts show the domain message instead of a bare "HTTP 400".
- throw new Error(error.detail || error?.error?.message || `HTTP ${response.status}`);
+ const thrown = withRequestId(
+ new Error(error.detail || error?.error?.message || `HTTP ${response.status}`),
+ response,
+ ) as StatusError;
+ // Keep the status machine-readable so pages can branch on it (e.g. 429
+ // rate-limit messaging) via getErrorStatus.
+ thrown.status = response.status;
+ const retryAfterHeader = response.headers?.get?.('retry-after');
+ if (retryAfterHeader != null && retryAfterHeader.trim() !== '') {
+ const seconds = Number(retryAfterHeader.trim());
+ if (Number.isInteger(seconds) && seconds >= 0) {
+ thrown.retryAfter = seconds;
+ }
+ }
+ throw thrown;
}
const data = await response.json();
diff --git a/frontend/src/hooks/use-review.ts b/frontend/src/hooks/use-review.ts
index 7fa286a46..adcce2424 100644
--- a/frontend/src/hooks/use-review.ts
+++ b/frontend/src/hooks/use-review.ts
@@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchWithCsrf } from '@/lib/fetch-with-csrf';
-import { apiError } from '@/lib/api-error';
+import { apiError, requestIdFromResponse } from '@/lib/api-error';
import type { ReviewItem } from '@/types/review';
async function reviewFetch(path: string, options: RequestInit = {}): Promise {
@@ -23,7 +23,7 @@ async function reviewFetch(path: string, options: RequestInit = {}): Promise<
: null;
// Keep the status machine-readable alongside the server message so a
// 401 on the review queue can trigger re-login instead of a dead list.
- const error = apiError(response.status, response.statusText);
+ const error = apiError(response.status, response.statusText, requestIdFromResponse(response));
if (serverMessage) error.message = serverMessage;
throw error;
}
diff --git a/frontend/src/lib/api-error.ts b/frontend/src/lib/api-error.ts
index e1e0b6cad..24fc9f9c0 100644
--- a/frontend/src/lib/api-error.ts
+++ b/frontend/src/lib/api-error.ts
@@ -14,15 +14,52 @@
export type StatusError = Error & {
status?: number;
- response?: { status?: number; data?: unknown };
+ requestId?: string;
+ retryAfter?: number;
+ response?: { status?: number; data?: unknown; headers?: Record; requestId?: string };
};
/** Build an error that keeps the HTTP status machine-readable. */
-export function apiError(status: number, statusText: string): StatusError {
+export function apiError(status: number, statusText: string, requestId?: string | null): StatusError {
const suffix = statusText ? ` ${statusText}` : '';
const error = new Error(`API error: ${status}${suffix}`) as StatusError;
error.status = status;
error.response = { status };
+ if (requestId) {
+ error.requestId = requestId;
+ error.response.requestId = requestId;
+ }
+ return error;
+}
+
+/**
+ * Read the request ID the backend stamped on a failed response. The shared
+ * request-id middleware sets X-Request-ID on every response, so this is real
+ * correlation data for support, not a client invention. Returns undefined when
+ * the response carries none (network failure, opaque error).
+ */
+export function requestIdFromResponse(res: unknown): string | undefined {
+ if (!res || typeof res !== 'object') return undefined;
+ const headers = (res as { headers?: unknown }).headers;
+ if (!headers) return undefined;
+ if (typeof (headers as { get?: unknown }).get === 'function') {
+ const value = (headers as { get: (name: string) => string | null }).get('x-request-id');
+ return value || undefined;
+ }
+ if (typeof headers === 'object') {
+ const value = (headers as Record)['x-request-id']
+ ?? (headers as Record)['X-Request-ID'];
+ return typeof value === 'string' && value ? value : undefined;
+ }
+ return undefined;
+}
+
+/** Attach the failed response's request ID to any error, preserving its type. */
+export function withRequestId(error: T, res: unknown): T {
+ const requestId = requestIdFromResponse(res);
+ if (requestId) {
+ (error as StatusError).requestId = requestId;
+ }
return error;
}
@@ -43,6 +80,70 @@ export function getErrorStatus(error: unknown): number | null {
return null;
}
+/** Read the request ID off any error shape our fetch layers produce. */
+export function getErrorRequestId(error: unknown): string | undefined {
+ if (!error || typeof error !== 'object') return undefined;
+ const record = error as { requestId?: unknown; response?: unknown };
+ if (typeof record.requestId === 'string' && record.requestId) {
+ return record.requestId;
+ }
+ const response = record.response;
+ if (response && typeof response === 'object') {
+ const nested = response as { requestId?: unknown; headers?: unknown };
+ if (typeof nested.requestId === 'string' && nested.requestId) {
+ return nested.requestId;
+ }
+ // APIClient stores lowercased response headers on error.response.
+ const headers = nested.headers;
+ if (headers && typeof headers === 'object') {
+ const map = headers as Record;
+ const value = map['x-request-id'] ?? map['X-Request-ID'];
+ if (typeof value === 'string' && value) {
+ return value;
+ }
+ }
+ }
+ return undefined;
+}
+
+function toRetryAfterSeconds(value: unknown): number | null {
+ if (typeof value === 'number') {
+ return Number.isInteger(value) && value >= 0 ? value : null;
+ }
+ if (typeof value === 'string' && value.trim() !== '') {
+ const parsed = Number(value.trim());
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
+ }
+ return null;
+}
+
+/**
+ * Read the Retry-After delay (in seconds) off any error shape our fetch
+ * layers produce: a direct `retryAfter` field, or a `retry-after` entry in
+ * `response.headers` (APIClient stores lowercased headers there). Returns
+ * null when absent or not a plain second count (the HTTP-date form is
+ * ignored — callers only ever mention seconds).
+ */
+export function getErrorRetryAfter(error: unknown): number | null {
+ if (!error || typeof error !== 'object') return null;
+ const direct = toRetryAfterSeconds((error as { retryAfter?: unknown }).retryAfter);
+ if (direct !== null) return direct;
+ const response = (error as { response?: unknown }).response;
+ if (response && typeof response === 'object') {
+ const headers = (response as { headers?: unknown }).headers;
+ if (headers && typeof headers === 'object') {
+ if (typeof (headers as { get?: unknown }).get === 'function') {
+ return toRetryAfterSeconds(
+ (headers as { get: (name: string) => string | null }).get('retry-after'),
+ );
+ }
+ const map = headers as Record;
+ return toRetryAfterSeconds(map['retry-after'] ?? map['Retry-After']);
+ }
+ }
+ return null;
+}
+
/** True when the error is an authentication or authorization failure. */
export function isAuthError(error: unknown): boolean {
const status = getErrorStatus(error);
diff --git a/frontend/src/lib/api-hooks.ts b/frontend/src/lib/api-hooks.ts
index 9ed0afc29..185c1c406 100644
--- a/frontend/src/lib/api-hooks.ts
+++ b/frontend/src/lib/api-hooks.ts
@@ -10,7 +10,7 @@
import { getServiceURL } from './api-config';
import { CSRF_PROTECTED_METHODS } from './csrf';
import { fetchWithCsrf } from './fetch-with-csrf';
-import { apiError } from './api-error';
+import { apiError, requestIdFromResponse } from './api-error';
const BASE = () => getServiceURL('ingestion');
const API_FETCH_TIMEOUT_MS = 8000;
@@ -103,14 +103,14 @@ async function apiFetch(path: string, _apiKey?: string | null, options: Reque
// sit outside the try/catch above so they are never mistaken for a
// retryable transport failure.
if (res.status === 429 || (res.status >= 500 && res.status <= 599)) {
- lastError = apiError(res.status, res.statusText);
+ lastError = apiError(res.status, res.statusText, requestIdFromResponse(res));
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAYS[attempt]));
}
continue;
}
- throw apiError(res.status, res.statusText);
+ throw apiError(res.status, res.statusText, requestIdFromResponse(res));
}
throw lastError!;
diff --git a/frontend/src/lib/api-proxy.ts b/frontend/src/lib/api-proxy.ts
index 5ffd7befd..3a430262c 100644
--- a/frontend/src/lib/api-proxy.ts
+++ b/frontend/src/lib/api-proxy.ts
@@ -220,7 +220,7 @@ export function genericProxyErrorMessage(status: number): string {
export function proxyError(
message: string,
status: number,
- opts?: { code?: string; details?: string[] },
+ opts?: { code?: string; details?: string[]; requestId?: string },
): NextResponse {
const serverError = isServerErrorStatus(status);
const body: ProxyErrorBody = {
@@ -228,6 +228,9 @@ export function proxyError(
};
if (!serverError && opts?.code) body.code = opts.code;
if (!serverError && opts?.details) body.details = opts.details;
+ if (opts?.requestId) {
+ return NextResponse.json(body, { status, headers: { 'x-request-id': opts.requestId } });
+ }
return NextResponse.json(body, { status });
}
diff --git a/frontend/src/lib/proxy-factory.ts b/frontend/src/lib/proxy-factory.ts
index db73aacdb..2cbfa7973 100644
--- a/frontend/src/lib/proxy-factory.ts
+++ b/frontend/src/lib/proxy-factory.ts
@@ -183,11 +183,18 @@ export function createJsonProxy(config: JsonProxyConfig): MethodHandlers {
targetUrl,
detail: data,
});
- return proxyError(`${config.serviceName} request failed`, response.status);
+ return proxyError(`${config.serviceName} request failed`, response.status, {
+ requestId: response.headers.get('x-request-id') ?? undefined,
+ });
}
+ const clientHeaders = new Headers();
+ const requestId = response.headers.get('x-request-id');
+ if (requestId) clientHeaders.set('x-request-id', requestId);
+ const retryAfter = response.headers.get('retry-after');
+ if (retryAfter) clientHeaders.set('retry-after', retryAfter);
return NextResponse.json(
{ error: detail ?? `${config.serviceName} request failed` },
- { status: response.status },
+ { status: response.status, headers: clientHeaders },
);
}
@@ -247,7 +254,9 @@ const DEFAULT_STREAM_RESPONSE_HEADERS = [
'content-type',
'content-disposition',
'cache-control',
+ 'retry-after',
'x-fda-record-count',
+ 'x-request-id',
];
const AUTH_BROWSER_NONCE_COOKIE = 'regengine_auth_browser_nonce';
const OPAQUE_SESSION_COOKIE = '__Host-regengine_session';
@@ -417,7 +426,9 @@ export function createStreamProxy(config: StreamProxyConfig): MethodHandlers {
targetUrl,
upstreamError: await readUpstreamErrorForLog(response),
});
- return proxyError(`${config.serviceName} request failed`, response.status);
+ return proxyError(`${config.serviceName} request failed`, response.status, {
+ requestId: response.headers.get('x-request-id') ?? undefined,
+ });
}
const outgoing = new Headers();