API errors
This page defines the cross-cutting error contract for every current HTTP and SSE route. Route-specific request and response schemas live in query and search, conversations and memory, and ingestion and documents.
Error schemas
Plain error response
Most non-validation errors return JSON with a single detail string.
{
"detail": "Invalid or missing API key"
}
| Field | Type | Meaning |
|---|---|---|
detail | string | Human-readable reason. It is safe to display after product copy review, but do not branch on exact prose unless the route page documents that prose as stable. |
There is no standard code, request_id, or errors field in plain HTTP errors.
Validation error response (422)
Request-schema errors use FastAPI's default validation shape: detail is an array.
{
"detail": [
{
"loc": ["body", "query"],
"msg": "Field required",
"type": "missing"
}
]
}
| Field | Type | Meaning |
|---|---|---|
detail | array | One or more validation issues. |
detail[].loc | array | Location of the invalid field, usually beginning with body, query, or path. |
detail[].msg | string | Human-readable validation message. |
detail[].type | string | FastAPI/Pydantic validation category. |
detail[].input | any | May be present when the validator includes the rejected input. |
Clients should handle both shapes because detail can be either a string or an array.
Model-timeout and budget refusals on /query and /query/stream follow ADR-0041; this exception applies to the 502 and SSE error guidance below.
Retry guidance
| Status or failure | Retry? | Client behavior |
|---|---|---|
400 | No, until the request changes | Fix the request content, such as whitespace-only query text, empty document text, invalid batch manifest, or rejected URL. |
401 | No, until credentials or identity injection changes | Refresh the server-side API key configuration or identity headers. Do not retry from browser code with an exposed shared key. |
403 | No, until platform configuration or the caller's role changes | Three current uses: URL ingestion disabled by feature flag; DELETE /documents/{document_id} refused because the verified principal is not an admin; DELETE /workspaces/{workspace_id}/documents/{document_id} refused because the caller is a viewer. Hide or disable the feature for that environment or that role. |
404 | Usually no | For ownership-scoped resources, unknown and not-owned both return 404. Ask the user to choose a resource they own or start a new conversation. |
409 | No, until the upload settles | Workspace delete while the target upload is still processing or any active workspace upload lacks a stored document id. Nothing changed. Retry after it settles. |
413 | No, until query/context changes | Reduce conversation context or ask a narrower question. |
422 | No, until the request schema changes | Fix missing fields, invalid enum values, bad path parameter types, extra forbidden fields, or list-size violations. |
500 | Depends on detail | Deletion may be partial or uncertain. Follow the recovery guidance for global admin deletion or workspace deletion. |
502 | Yes, with bounded backoff | Model, embedding, reranker, or orchestration dependency failed. Retry a small number of times, then show a temporary-service message. |
503 | Yes, with bounded backoff | Object storage is unavailable for upload/batch routes. Retry after a short delay or let the user try again later. |
| Network timeout before any response | Maybe | Use your gateway timeout policy. For /query/stream, allow for long gaps before and between progress frames; see the stream contract. |
| Client disconnect/cancel | No automatic retry | Treat as user cancellation unless your UI explicitly offers resume or retry. |
Recommended retry pattern for retryable 5xx responses:
- retry at most 2-3 times;
- use exponential backoff with jitter;
- do not duplicate visible user actions such as file uploads without clear UI state;
- for
/query, keep the sameconversation_idonly when the user intentionally retries the same turn.
Ownership 404 behavior
The API avoids existence leaks for user-owned resources.
| Resource | Behavior |
|---|---|
| Conversations | GET or DELETE /conversations/{conversation_id} and POST /query with conversation_id return 404 for both unknown IDs and conversations owned by another subject. |
| Memories | DELETE /memories/{memory_id} returns 404 for both unknown IDs and memories owned by another subject. |
| Conversation and memory lists | GET /conversations and GET /memories return only resources owned by the resolved principal; other subjects' resources are omitted. |
Do not show users copy such as "you do not have permission to this existing resource" for these 404s. Use neutral copy such as "That item was not found or is no longer available."
Empty search behavior
POST /search returns 200 even when no passages match.
{
"mode": "hybrid",
"results": []
}
This is not an error. Show an empty state and let the user refine the query.
collection_ids and well-formed document_ids can also narrow results to an empty set. Unknown, forbidden, or inaccessible collections/documents produce no rows rather than 403, so the client cannot use empty results to distinguish those cases. A malformed document_ids entry is different: it is request validation and returns 422.
POST /query returns 404 with a plain detail string when no visible indexed content is available to answer, including when a well-formed document focus yields no matching chunks. For /query/v2 exceptions, see Errors in the v2 contract. For /query/stream, the same failure is an HTTP error before streaming or a terminal SSE error after progress starts; see the stream contract.
Pre-stream vs in-stream failures
The Query stream contract defines the boundary between HTTP setup errors and terminal SSE errors after live progress starts.
Pre-stream failures
Failures before the stream starts return normal HTTP statuses and JSON errors. The client should check response.ok before reading the stream.
Examples:
| Failure | Status |
|---|---|
| Missing or invalid API key | 401 |
| Empty query | 400 |
Unknown or cross-user conversation_id | 404 |
No indexed content matched (/query, or /query/v2 when a selection that holds documents finds nothing) | 404 |
Empty named selection (/query/v2) | See v2 Errors |
| Context window overflow | 413 |
| Model or orchestration failure | 502 |
| Request-schema validation | 422 |
guard.blocked is not an error. It is a completed policy outcome and is followed by done with finish_reason: "guard_blocked".
In-stream failures
After streaming starts, pipeline failures and unexpected framing faults send terminal error events. A framing fault uses:
event: error
data: {"code":"stream_failed","message":"..."}
When error appears, done does not follow. Treat it as terminal and offer retry. Client disconnects are cancellations, not server error frames.
See the full stream event contract in Query stream.
Route and status matrix
401 on data routes means either the API-key header is missing/wrong or, on principal-bearing routes when strict identity is enabled, trusted identity is missing/malformed.
| Route | Success | Client errors | Server/dependency errors | Notes |
|---|---|---|---|---|
GET /healthz | 200 | - | - | No API key required. |
GET /readyz | 200 | - | 503 | No API key required. 503 lists every failed dependency. |
GET /openapi.json | 200 | - | - | No API key required. Available in every environment. |
GET /docs | 200 outside production | 404 in production | - | Swagger UI only; not an application API. |
POST /query | 200 | 400, 401, 404, 413, 422 | 502 | 404 covers unknown/cross-user conversation and no indexed content. |
POST /query/stream | 200 SSE | 400, 401, 404, 413, 422 | 502 before stream; SSE error after stream starts | Same request body and setup failures as /query. |
POST /search | 200 | 400, 401, 422 | 502 | Empty results are 200 with results: []. |
POST /conversations | 201 | 401, 422 | - | Creates a conversation owned by the resolved principal. |
GET /conversations | 200 | 401 | - | Returns only the resolved principal's conversations. |
GET /conversations/{conversation_id} | 200 | 401, 404, 422 | - | Unknown and cross-user IDs both return 404. |
DELETE /conversations/{conversation_id} | 204 | 401, 404, 422 | - | Deletes the owned conversation and all messages; unknown and cross-user IDs both return 404. |
GET /memories | 200 | 401 | - | Returns only the resolved principal's non-deleted memories. |
DELETE /memories/{memory_id} | 204 | 401, 404, 422 | - | Unknown and cross-user IDs both return 404. |
DELETE /memories | 200 | 401 | - | Forget-me deletion for all memories owned by the resolved principal. |
POST /documents/txt | 200 | 400, 401, 422 | 502 | Inline text ingestion; empty parsed content is 400, model dependency failure is 502. |
POST /documents | 200 | 400, 401, 422 | 503 | Multipart upload; storage failure is 503. |
GET /documents | 200 | 401, 422 | - | Optional status filter; invalid status values validate as 422. |
GET /documents/{document_id} | 200 | 401, 404 | - | Unknown document ID returns 404. |
DELETE /documents/{document_id} | 204 | 401, 403, 404 | 500 | Admin-only when verified authentication is on; operator/user get 403. Unknown, malformed, and already-deleted IDs all return 404. See Ingestion and documents. |
DELETE /workspaces/{workspace_id}/documents/{document_id} | 204 | 401, 403, 404, 409 | 500 | See Workspace sources for authorization, active-upload guards, and recovery. |
POST /ingestion/batch | 200 | 400, 401, 422 | 503 | Exactly one of prefix or keys is required. Storage list/enqueue failures are 503. |
POST /ingestion/url | 200 when enabled | 400, 401, 403, 422 | - | 403 means URL ingestion is disabled. Bad scheme, host, extension, or allow-list rejection is 400. |
GET /ingestion/jobs/{job_id} | 200 | 401, 404 | - | Unknown job ID returns 404. |
Common status meanings
| Status | Meaning |
|---|---|
200 | Request completed. For /search, this may include an empty results array. For /query/stream, this starts an SSE stream. |
201 | Conversation created. |
204 | Conversation, memory, or document deletion succeeded and returns no body. |
400 | Syntactically valid request shape, but invalid business input. |
401 | Missing/wrong API key, or strict trusted identity failure on principal-bearing routes. |
403 | Feature disabled for the environment (URL ingestion), or the verified principal lacks the required role (DELETE /documents/{document_id} is admin-only; DELETE /workspaces/{workspace_id}/documents/{document_id} is owner and partner only). |
404 | Missing resource, inaccessible owned resource, or no indexed content for query. |
409 | The target upload is still processing or an active workspace upload lacks a stored document id (workspace delete). Nothing changed. |
413 | Query context exceeded the model/synthesis budget. |
422 | FastAPI/Pydantic request validation error. |
500 | Deletion failure; see the route-specific recovery guidance above. |
502 | Upstream model/orchestrator dependency failure. |
503 | Object storage dependency unavailable for ingestion. |
TypeScript error parsing
Use a parser that handles both plain errors and validation errors, and that still gives useful output if an intermediary returns non-JSON.
type PlainApiError = { detail: string };
type ValidationIssue = {
loc?: Array<string | number>;
msg?: string;
type?: string;
input?: unknown;
};
type ValidationApiError = { detail: ValidationIssue[] };
type ApiErrorBody = PlainApiError | ValidationApiError | Record<string, unknown>;
export class ApiHttpError extends Error {
constructor(
message: string,
readonly status: number,
readonly body: ApiErrorBody | string | null,
) {
super(message);
this.name = "ApiHttpError";
}
}
function isPlainApiError(value: unknown): value is PlainApiError {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { detail?: unknown }).detail === "string"
);
}
function isValidationApiError(value: unknown): value is ValidationApiError {
return (
typeof value === "object" &&
value !== null &&
Array.isArray((value as { detail?: unknown }).detail)
);
}
function summarizeValidation(error: ValidationApiError): string {
return error.detail
.map((issue) => {
const path = issue.loc?.join(".") ?? "request";
return `${path}: ${issue.msg ?? issue.type ?? "invalid value"}`;
})
.join("; ");
}
export async function readApiError(response: Response): Promise<string> {
const contentType = response.headers.get("content-type") ?? "";
let body: ApiErrorBody | string | null = null;
if (contentType.includes("application/json")) {
body = (await response.json().catch(() => null)) as ApiErrorBody | null;
} else {
body = await response.text().catch(() => null);
}
let message: string;
if (isPlainApiError(body)) {
message = body.detail;
} else if (isValidationApiError(body)) {
message = summarizeValidation(body);
} else if (typeof body === "string" && body.trim()) {
message = body.trim();
} else {
message = response.statusText || "Request failed";
}
throw new ApiHttpError(`HTTP ${response.status}: ${message}`, response.status, body);
}
Use this before reading /query/stream bodies:
const response = await fetch("/query/stream", requestInit);
if (!response.ok) {
await readApiError(response);
}
// response.body now contains SSE frames.