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.
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 | Two current uses: URL ingestion disabled by feature flag, and DELETE /documents/{document_id} refused because the verified principal is not an admin. 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. |
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 | No | Only DELETE /documents/{document_id} returns it: the document is already out of the index, so retrying returns 404. Escalate the orphaned raw object to an operator. |
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, remember the server performs a pre-stream LLM pipeline before the first SSE byte. |
| 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 /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 can also narrow results to an empty set. Unknown, forbidden, or inaccessible collections produce no rows rather than 403, so the client cannot use empty results to distinguish those cases.
POST /query is different: if the answer pipeline has no indexed content to answer from, it returns 404 with a plain detail string.
Pre-stream vs in-stream failures
POST /query/stream is buffer-then-reveal. The full query, retrieval, model, faithfulness, policy-guard, and persistence pipeline runs before the first SSE frame.
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 | 404 |
| Context window overflow | 413 |
| Model or orchestration failure | 502 |
| Request-schema validation | 422 |
Successful stream sequence
event: message.start
event: citation # zero or more
event: answer.delta # zero or more, when guard passes
# OR
event: guard.blocked # when policy guard withholds answer
event: done
guard.blocked is not an error. It is a completed policy outcome and is followed by done with finish_reason: "guard_blocked".
In-stream framing fault
After streaming has started, an unexpected framing fault sends a terminal error event:
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 /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. |
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. 500 means the index rows were removed but the raw object could not be deleted. See Ingestion and documents. |
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 | 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). |
404 | Missing resource, inaccessible owned resource, or no indexed content for query. |
413 | Query context exceeded the model/synthesis budget. |
422 | FastAPI/Pydantic request validation error. |
500 | Partial document deletion: index rows removed, raw object orphaned. Only DELETE /documents/{document_id} returns it. |
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.