Skip to main content
CID222 Docs

Error handling

The status codes the gateway returns, the three error body shapes it uses, and why a blocked prompt arrives as an event on a 200 response.

  • Version: 0.4
  • Role: admin_user, normal_user

Errors reach you two different ways, and the difference decides how you handle them. Anything that fails before a request enters the chat handler is an ordinary HTTP error with a status code. Anything that fails after it arrives as an event inside a 200 OK event stream, because the handler sets its headers before it does any work. A client that only inspects response.ok never sees the second kind.

The error body is not uniform

There is no global exception filter in the gateway, so the body you get depends on which piece of code threw. Three shapes are in circulation.

A framework exception, such as a failed validation or a rejected token. No code field:

{
  "statusCode": 401,
  "message": "Invalid or expired token",
  "error": "Unauthorized"
}

When the global validation pipe rejects the body, message is an array of strings, one per failed constraint:

{
  "statusCode": 400,
  "message": ["model must be a string", "messages should not be empty"],
  "error": "Bad Request"
}

A guard that carries a machine-readable code and a status. These add code and sometimes further context, and drop error:

{
  "statusCode": 403,
  "code": "FEATURE_NOT_LICENSED",
  "feature": "ldap",
  "tier": "standard",
  "message": "This feature (ldap) is not included in the installed license tier. Contact your CID representative to upgrade."
}

A role rejection, which carries code and message and nothing else — not even statusCode. Read the status from the HTTP response line, not from the body:

{
  "code": "READ_ONLY_ROLE",
  "message": "This is a read-only demo account (viewer role) — actions and changes are disabled."
}

Write your client to treat code, statusCode and error as all optional, and to fall back to the HTTP status when statusCode is absent.

HTTP status codes

CodeMeaningWhat to do
400The body failed validation, or a required file was missingFix the request. message may be an array of constraint failures
401No Authorization: Bearer header, or the JWT or cid_key_ was rejectedCheck the credential and the Bearer prefix
402LICENSE_EXPIRED — the licence is invalid or expiredAn administrator installs a renewal under Settings → License. Blocks chat, guardrails, inspection, image and document analysis
403A role or licence refusal — READ_ONLY_ROLE, ROLE_NOT_FOR_CHAT or FEATURE_NOT_LICENSEDDo not retry. Content blocks are not 403
404No such route, or no such resource for this tenantCheck the path prefix and the id
415A legacy or macro-enabled spreadsheet was uploaded (xls, xlsm, xlsb, xlt, xltm)Convert to xlsx; those formats cannot be redacted safely
423SETUP_REQUIRED — first-boot setup is unfinishedComplete the setup wizard. Everything outside a small allowlist is locked
429Too many sign-in attemptsOnly POST /auth/login (10/min per IP) and the password-reset routes (5/min) are limited
500An unhandled server error on a JSON routeRetry with backoff, then check the gateway logs
503A dependency is unreachable — the directory at sign-in, or a helper service behind a report or analysis routeRetry later. Local sign-in is unaffected by a directory outage

Warning

/chat/completions and /sessions/:id/messages never return 500 or 503. Both write their SSE headers before any work happens, so a provider outage, a credential problem or an unhandled exception all arrive as an error event on a 200 response.

Authentication errors

CombinedAuthGuard accepts either a user JWT or a gateway API key on the same header. Its rejections all look alike:

{
  "statusCode": 401,
  "message": "Missing or invalid authorization header",
  "error": "Unauthorized"
}

The messages you can see are Missing or invalid authorization header, Invalid token, Invalid or expired token, Invalid API key and Invalid or expired API key. When you get one:

  • Confirm the header is Authorization: Bearer <credential>, with the Bearer prefix. There is no X-API-Key header.
  • Confirm the key still begins with cid_key_ and has not been regenerated. Regeneration invalidates the previous value at once.
  • Confirm the JWT has not expired. Tokens live for JWT_EXPIRES_IN, 24 hours by default.
  • Confirm the endpoint accepts the credential you are using. /sessions/*, /image-analysis/* and /document-analysis/* are JWT-only; an API key is rejected there even though it works on /chat/completions and /models.

A 403 with code: "ROLE_NOT_FOR_CHAT" is not an authentication failure. The auditor role is refused on chat and session writes by design, and retrying with the same token cannot succeed.

Blocked content

Content policy decisions are not HTTP errors. On the chat surface they are stream events, and on the detection surface they are a successful response with an action field.

Chat, input rejected. The prompt never reaches the provider, so no tokens are spent:

data: {"error":"Content policy violation: hate","type":"content_rejected","entities":[{"type":"hate","action":"reject","confidence":0.94}]}

data: [DONE]

Chat, response rejected by the output filter after the model answered. The reply is discarded and never sent:

data: {"type":"output_content_rejected","severity":"critical","message":"🚫 Response blocked due to policy violations","reason":"Content policy violation: pii_leak","entities_detected":2,"actions_applied":["reject"]}

data: [DONE]

Session messages reject differently. POST /sessions/:id/messages emits its own input rejection with no error field at all, so a handler that keys on error misses it:

data: {"type":"content_rejected","reason":"Content policy violation: hate","categories":{"hate":true},"scores":{"hate":0.94},"filter":"content-safety-v2 v2","filter_duration_ms":148}

data: [DONE]

Detection endpoint. POST /api/v1/guardrails/detect returns 200 and states the verdict in the body:

{
  "action": "reject",
  "detectionCount": 1,
  "detectedEntities": [
    { "type": "hate", "value": "[REDACTED]", "action": "reject", "confidence": 0.94, "source": "hap_detector" }
  ],
  "maskedText": "…",
  "processingTimeMs": 142
}

Warning

Treat action: "reject" and a content_rejected event as policy outcomes, not failures. Retrying the identical text produces the identical verdict. Change the input, or change the filter.

Reading a stream safely

A single read from the stream can deliver several data: frames, or half of one. Buffer the decoded text and split on the frame boundary rather than parsing what one read happened to return. Handle four things: the terminal [DONE], an error field, the typed rejection events, and the single content event.

async function readGatewayStream(response, onAnswer) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
 
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
 
      // Frames are separated by a blank line; keep the trailing partial frame.
      const frames = buffer.split('\n\n');
      buffer = frames.pop() ?? '';
 
      for (const frame of frames) {
        const line = frame.split('\n').find((l) => l.startsWith('data: '));
        if (!line) continue;
        const data = line.slice(6).trim();
        if (data === '[DONE]') return;
 
        const event = JSON.parse(data);
 
        // Input block on /chat/completions, and any unhandled server error.
        if (event.error) throw new Error(event.error);
 
        // Input block on /sessions/:id/messages — no `error` field.
        if (event.type === 'content_rejected') throw new Error(event.reason);
 
        // Output filter discarded the answer.
        if (event.type === 'output_content_rejected') throw new Error(event.reason);
 
        if (event.type === 'output_content_warning') {
          console.warn('Flagged:', event.message);
          continue;
        }
 
        // The whole filtered answer, as one event.
        if (!event.type && typeof event.content === 'string') onAnswer(event.content);
      }
    }
  } finally {
    reader.releaseLock();
  }
}

If the connection drops mid-stream before the content event, you have nothing partial to salvage — the answer is emitted in one piece. Treat a truncated stream as a failed call.

Which errors to retry

FailureRetryHow
Connection refused, TLS failure, timeout before any bytesYesExponential backoff
500 on a JSON routeYesExponential backoff, then read the gateway logs
503 on a JSON routeYesExponential backoff; the dependency is down, not your request
429 on POST /auth/loginYes, slowlyWait out the one-minute window. No Retry-After header is sent
400 validation failureNoFix the body. Check for silently stripped unknown fields
401OnceRefresh the JWT, or check the key. Repeating with the same credential cannot help
402, 403, 423NoA licence, role or setup state has to change first
415NoConvert the file to a supported type
content_rejected / output_content_rejectedNoThe same input produces the same verdict
An error event on a chat streamCarefullyThe provider may already have been billed. Retrying repeats the spend

Note

Nothing in the gateway emits Retry-After, X-RateLimit-Limit or X-RateLimit-Remaining. Retry logic that reads those headers is reading null on every response.

Limits and known gaps

  • No uniform error envelope. Three body shapes coexist, and only guard-issued errors carry a code. Do not build a client that requires one.
  • No error code taxonomy for content decisions. A block is described by an event type and a free-text reason, not by a stable code you can switch on.
  • Chat failures hide behind a 200. Monitoring that counts non-2xx responses records a provider outage on /chat/completions as a success.
  • The two chat surfaces disagree. /chat/completions rejects input with an error field; /sessions/:id/messages rejects it with type: "content_rejected" and a reason. A shared client must handle both.
  • The generic chat failure message is opaque. An unhandled exception in the controller writes {"error":"An error occurred"} with no detail; the cause is only in the gateway log.
  • No rate-limit signalling. Because only the sign-in routes are limited, there is no quota header to pace a client against on the request path.
  • Best practices — credential handling, retry policy and what the buffered stream means for your user interface.
  • Integration examples — clients that implement the handling shown here.
  • Chat API — the full event list for /chat/completions.

Last updated on

On this page

Download PDF