Skip to main content

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"
}
FieldTypeMeaning
detailstringHuman-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"
}
]
}
FieldTypeMeaning
detailarrayOne or more validation issues.
detail[].locarrayLocation of the invalid field, usually beginning with body, query, or path.
detail[].msgstringHuman-readable validation message.
detail[].typestringFastAPI/Pydantic validation category.
detail[].inputanyMay 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 failureRetry?Client behavior
400No, until the request changesFix the request content, such as whitespace-only query text, empty document text, invalid batch manifest, or rejected URL.
401No, until credentials or identity injection changesRefresh the server-side API key configuration or identity headers. Do not retry from browser code with an exposed shared key.
403No, until platform configuration or the caller's role changesTwo 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.
404Usually noFor ownership-scoped resources, unknown and not-owned both return 404. Ask the user to choose a resource they own or start a new conversation.
413No, until query/context changesReduce conversation context or ask a narrower question.
422No, until the request schema changesFix missing fields, invalid enum values, bad path parameter types, extra forbidden fields, or list-size violations.
500NoOnly 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.
502Yes, with bounded backoffModel, embedding, reranker, or orchestration dependency failed. Retry a small number of times, then show a temporary-service message.
503Yes, with bounded backoffObject storage is unavailable for upload/batch routes. Retry after a short delay or let the user try again later.
Network timeout before any responseMaybeUse your gateway timeout policy. For /query/stream, remember the server performs a pre-stream LLM pipeline before the first SSE byte.
Client disconnect/cancelNo automatic retryTreat as user cancellation unless your UI explicitly offers resume or retry.

Recommended retry pattern for retryable 5xx responses:

  1. retry at most 2-3 times;
  2. use exponential backoff with jitter;
  3. do not duplicate visible user actions such as file uploads without clear UI state;
  4. for /query, keep the same conversation_id only when the user intentionally retries the same turn.

Ownership 404 behavior

The API avoids existence leaks for user-owned resources.

ResourceBehavior
ConversationsGET /conversations/{conversation_id} and POST /query with conversation_id return 404 for both unknown IDs and conversations owned by another subject.
MemoriesDELETE /memories/{memory_id} returns 404 for both unknown IDs and memories owned by another subject.
Conversation and memory listsGET /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:

FailureStatus
Missing or invalid API key401
Empty query400
Unknown or cross-user conversation_id404
No indexed content404
Context window overflow413
Model or orchestration failure502
Request-schema validation422

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.

RouteSuccessClient errorsServer/dependency errorsNotes
GET /healthz200--No API key required.
GET /openapi.json200--No API key required. Available in every environment.
GET /docs200 outside production404 in production-Swagger UI only; not an application API.
POST /query200400, 401, 404, 413, 422502404 covers unknown/cross-user conversation and no indexed content.
POST /query/stream200 SSE400, 401, 404, 413, 422502 before stream; SSE error after stream startsSame request body and setup failures as /query.
POST /search200400, 401, 422502Empty results are 200 with results: [].
POST /conversations201401, 422-Creates a conversation owned by the resolved principal.
GET /conversations200401-Returns only the resolved principal's conversations.
GET /conversations/{conversation_id}200401, 404, 422-Unknown and cross-user IDs both return 404.
GET /memories200401-Returns only the resolved principal's non-deleted memories.
DELETE /memories/{memory_id}204401, 404, 422-Unknown and cross-user IDs both return 404.
DELETE /memories200401-Forget-me deletion for all memories owned by the resolved principal.
POST /documents/txt200400, 401, 422502Inline text ingestion; empty parsed content is 400, model dependency failure is 502.
POST /documents200400, 401, 422503Multipart upload; storage failure is 503.
GET /documents200401, 422-Optional status filter; invalid status values validate as 422.
GET /documents/{document_id}200401, 404-Unknown document ID returns 404.
DELETE /documents/{document_id}204401, 403, 404500Admin-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/batch200400, 401, 422503Exactly one of prefix or keys is required. Storage list/enqueue failures are 503.
POST /ingestion/url200 when enabled400, 401, 403, 422-403 means URL ingestion is disabled. Bad scheme, host, extension, or allow-list rejection is 400.
GET /ingestion/jobs/{job_id}200401, 404-Unknown job ID returns 404.

Common status meanings

StatusMeaning
200Request completed. For /search, this may include an empty results array. For /query/stream, this starts an SSE stream.
201Conversation created.
204Memory or document deletion succeeded and returns no body.
400Syntactically valid request shape, but invalid business input.
401Missing/wrong API key, or strict trusted identity failure on principal-bearing routes.
403Feature disabled for the environment (URL ingestion), or the verified principal lacks the required role (DELETE /documents/{document_id} is admin-only).
404Missing resource, inaccessible owned resource, or no indexed content for query.
413Query context exceeded the model/synthesis budget.
422FastAPI/Pydantic request validation error.
500Partial document deletion: index rows removed, raw object orphaned. Only DELETE /documents/{document_id} returns it.
502Upstream model/orchestrator dependency failure.
503Object 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.