SDKs · 4. Errors and retries
No Crux SDK has retry logic, backoff, jitter or an idempotency key. Every non-2xx becomes a thrown exception and the call ends there. That is a defensible design, retries belong in the caller, who knows whether the operation is safe to repeat, but it means the retry policy is your code, and getting it wrong on storeFact silently writes a second version of your fact.
This chapter is reference: the error classes, the status-by-status recoverability table, the two lists (safe, never), and a worked loop for each language.
4.1 The error class each client throws
| Client | Class | Carries | Thrown when |
|---|---|---|---|
@cuecrux/client | CoreCruxError extends Error | status: number, problem: ProblemDetails or null | Any non-2xx, except 404 on getFact, getSession, deleteFact (index.ts:36) |
corecrux-client | CoreCruxError(Exception) | status_code: int, detail: str, type: str | Any status ≥ 400, except 404 on get_fact, get_session, delete_fact (errors.py:8) |
@cuecrux/memory | CruxDaemonError extends Error | status: number, path: string, body: string or undefined | Any non-2xx, with no 404 convenience anywhere (errors.ts:2) |
@cuecrux/engine-client | bare Error | Only a message, "<op> failed: <status>" | Any non-OK response (index.ts:127) |
FactoryClient | FactoryClientError extends Error | statusCode, requestId, responseBody? | Non-2xx after retries are exhausted (client.ts:48) |
@cuecrux/engine-client is the outlier and the one to be careful with: its errors carry no status property. Recovering the status means parsing the message string, which is a contract nobody guaranteed. If you need to branch on status, wrap it in your own fetch and inspect the response there.
4.2 Not every failure is one of these classes
Three failure paths bypass the SDK error type entirely. They are the ones that break naive try / except CoreCruxError handling.
| Failure | What you actually catch | Where |
|---|---|---|
| Daemon unreachable, DNS failure, TLS failure, socket reset | TypeError in TypeScript (the fetch rejection); httpx.ConnectError or httpx.ReadTimeout in Python | Below the SDK, no CoreCruxError is constructed |
| A 200 with an unexpected body shape | KeyError in Python, from direct indexing of the response dict (client.py:59). TypeScript casts and does not notice | _to_fact and its siblings |
| A 204 No Content | TypeScript returns undefined cast to the declared type (index.ts:265). Python returns {} (client.py:54) | The request helper |
In Python, catch httpx.HTTPError alongside CoreCruxError or transport failures escape your handler. In TypeScript there is no exported base class to catch, so guard with err instanceof CoreCruxError and re-throw everything else.
4.3 Status by status
R means the same request may succeed later without changing anything. N means it will not.
| Status | Meaning | Recoverable | What to do |
|---|---|---|---|
| 400 | Malformed for this route. Includes private: true on a fact write (facts.rs:229) | N | Fix the payload |
| 401 | No usable credential; "code": "UNAUTHENTICATED" (auth.rs:857) | N | Supply a token. See 3.3 |
| 403 | Identified and refused; "code": "MISSING_SCOPE" with missingScopes (auth.rs:1331) | N | Get a token carrying the named scopes |
| 404 | No such fact, session or route | N | Both Crux SDKs already convert this to null or false on the three read-ish methods |
| 409 | Conflict | N | Re-read, decide, write again. No SDK models this specially |
| 422 | Semantic validation failure | N | Fix the payload |
| 429 | Quota exhausted. Carries Retry-After plus X-Crux-Quota-Limit and X-Crux-Quota-Remaining (quota.rs:23) | R | Sleep for Retry-After, then retry once. FLAG: the middleware is inert unless CORECRUXD_QUOTA is on, default off (mod.rs:296) |
| 500 | Unhandled daemon error | R, cautiously | Retry a read. Do not retry a write without reading 4.5 |
| 502, 504 | A proxy in front of the daemon failed | R | Retry with backoff |
| 503 | Daemon running, not ready. Body is {"ok": false, "checks": [...]} (health.rs:74) | R | Back off and retry. This is the normal startup and recovery signal |
The 429 headers are the only backoff hint the daemon gives you, and they only exist when the operator enabled quota. Everywhere else you choose your own delay. Neither Crux SDK exposes response headers, so reading Retry-After requires your own fetch or httpx call, a real gap, and the reason the loops in 4.6 use a fixed schedule rather than the served value.
4.4 What is safe to retry
Everything in this list is a read. None of it mutates state, so repeating it costs latency and nothing else.
| Call | TypeScript | Python |
|---|---|---|
| Health and readiness | healthz, readyz, version | healthz, readyz, version |
| Fact reads | getFact, getFactsByEntity, queryFacts, exportFacts | get_fact, get_facts_by_entity, query_facts, export_facts |
| Session read | getSession | get_session |
| Queries | textSearch, textSearchExpand, graphExpand, timeRange | text_search, text_search_expand, graph_expand, time_range |
Retry these on 429, 500, 502, 503, 504 and on transport failures. Cap the attempts, three is usually right, and back off exponentially with jitter so a fleet of agents does not synchronise against a recovering daemon.
exportFacts deserves a note: retrying a page is safe because the cursor is server-supplied and stable, but do not advance the cursor on a failed page. Retry the same cursor.
4.5 What must never be blindly retried
storeFact and storeFacts are not idempotent. This is the one that bites.
Writing the same (entity, key) a second time does not overwrite the first. The store assigns version = previous + 1, sets supersedes to the previous fact_id, and returns a new fact_id (fact_store.rs:882, fact_store.rs:1137). There is no idempotency key, no If-None-Match, and no client-supplied request id the daemon deduplicates on.
The failure mode is specific and quiet. Your write succeeds; the response is lost to a timeout or a reset connection; your retry lands; you now hold version 2 of a fact whose version 1 you never saw, and your recorded fact_id points at the wrong one. Nothing errors. Nothing warns you.
The safe sequence after an ambiguous write failure is read, then decide:
async function storeOnce(client: CoreCruxClient, fact: StoreFact): Promise<Fact> {
try {
return await client.storeFact(fact);
} catch (err) {
// The write may or may not have landed. Look before you leap.
const { facts } = await client.getFactsByEntity(fact.entity);
const existing = facts.find((f) => f.key === fact.key && f.value === fact.value && !f.deleted);
if (existing) return existing;
throw err;
}
}
The rest of the never-retry-blindly list:
| Call | Why | Safe alternative |
|---|---|---|
putSession / put_session | Full-document overwrite with no If-Match. A retry that races a concurrent writer silently discards their state | Read the current state, merge, write once. Accept that this is last-writer-wins |
deleteFact / delete_fact | Repeating it is harmless, the tombstone is already written, but the return value flips: the second call 404s and yields false | Treat false as "not present", not as "failed" |
| Anything that failed with 400, 401, 403, 404, 409 or 422 | The response will be byte-identical next time | Fix the request |
FactoryClient.createJob | Job creation. The SDK marks it retryUnsafe: false deliberately (client.ts:82), a retry would enqueue a duplicate job | Poll listJobs for your x-request-id before re-creating |
@cuecrux/engine-client.answers | A metered call against a hosted plane. A retry loop on a 5xx spends real budget | Retry at most once, and log it |
The general rule: retry reads freely, retry writes only when you have re-read and know the write did not land.
4.6 Writing the loop yourself
TypeScript, wrapping any read method:
import { CoreCruxError } from "@cuecrux/client";
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
export async function withRetry<T>(
fn: () => Promise<T>,
{ attempts = 3, baseMs = 200, maxMs = 5_000 } = {},
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const status = err instanceof CoreCruxError ? err.status : undefined;
const transport = err instanceof TypeError; // fetch rejection
if (!transport && (status === undefined || !RETRYABLE.has(status))) throw err;
if (attempt === attempts) break;
const backoff = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
const jitter = Math.floor(Math.random() * (backoff / 4 + 1));
await new Promise((r) => setTimeout(r, backoff + jitter));
}
}
throw lastError;
}
// Reads only. Do not wrap storeFact in this.
const result = await withRetry(() => client.queryFacts({ query: "deploy", token_budget: 500 }));
Python, the same policy:
import random
import time
import httpx
from corecrux_client import CoreCruxError
RETRYABLE = {429, 500, 502, 503, 504}
def with_retry(fn, attempts: int = 3, base_ms: int = 200, max_ms: int = 5_000):
last = None
for attempt in range(1, attempts + 1):
try:
return fn()
except CoreCruxError as exc:
last = exc
if exc.status_code not in RETRYABLE:
raise
except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as exc:
last = exc
if attempt == attempts:
break
backoff = min(max_ms, base_ms * 2 ** (attempt - 1))
time.sleep((backoff + random.randint(0, backoff // 4)) / 1000)
raise last
result = with_retry(lambda: client.query_facts("deploy", token_budget=500))
Both loops deliberately omit Retry-After. Neither SDK surfaces response headers, so the value the daemon served on a 429 is unreachable from inside the SDK. If quota enforcement matters to you, drop to a raw HTTP call for that path.
4.7 Timeouts
| Client | Timeout | Configurable |
|---|---|---|
@cuecrux/client | None. Global fetch with no signal (index.ts:242) | No |
corecrux-client | Flat 30 s, connect and read alike (client.py:154) | Yes, at construction: timeout= |
@cuecrux/memory | None on requests; a 750 ms budget on the discovery probe only (discovery.ts:71) | Probe only |
FactoryClient | 10 s default, floor 1 s (client.ts:72) | Yes, timeoutMs |
The TypeScript client's lack of a timeout is the most likely cause of a hung agent. There is no AbortSignal parameter, so the only remedy is to race the promise yourself:
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return Promise.race([
p,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms),
),
]);
}
This rejects your promise but does not cancel the underlying request, the socket stays open until the platform closes it, and if the call was a write, it may still land. That is precisely the ambiguous-write case from 4.5.
4.8 The one SDK that does retry, and what it does
FactoryClient in SDKCrux is the only client in either estate with a retry policy, and it is worth copying rather than reinventing (client.ts:62):
| Element | Behaviour |
|---|---|
| Retryable statuses | Exactly {500, 502, 503, 504} (client.ts:60). 429 is not in the set |
| Retryable transport errors | AbortError, FetchError, TypeError (client.ts:273) |
| Attempts | maxRetries, default 2, floor 0 |
| Backoff | min(max, base × 2^(n−1)) plus jitter of up to a quarter of that (client.ts:289) |
| Defaults | retryBaseDelayMs 150 (floor 50), retryMaxDelayMs 2000 |
| Per-method opt-in | GETs retry; the one POST that creates a job does not (client.ts:82) |
| Correlation | Every request carries x-request-id, caller-supplied or a fresh randomUUID() (client.ts:243) |
| Response validation | Every body is schema.safeParsed before it is returned |
The per-method opt-in is the design decision worth stealing: retryability is a property of the operation, not of the status code alone.
SDKCrux also ships a status-to-taxonomy mapper in its shared runtime, mapHttpError, with an explicit isRecoverable predicate (errors.ts:11):
type SDKError =
| { type: 'RateLimited'; message?: string; retryAfterSec?: number }
| { type: 'UpstreamDown'; message?: string; upstream?: string }
| { type: 'ValidationError'; message?: string; issues?: ZodIssue[] }
| { type: 'Contradiction'; message?: string; details?: string }
| { type: 'StaleSnapshot'; message?: string; asOf?: string; observedAt?: string }
| { type: 'AuthMissing'; message?: string };
// isRecoverable → true for RateLimited, UpstreamDown, StaleSnapshot
// false for ValidationError, Contradiction, AuthMissing
// mapHttpError: 429→RateLimited (+retryAfterSec), 401/403→AuthMissing, 409→Contradiction,
// 428→StaleSnapshot, 422→ValidationError, ≥500→UpstreamDown
That taxonomy lives in @cuecrux-internal/core, which is a restricted package on GitHub Packages - you cannot install it from public npm. It is reproduced here because the classification is sound and you may want it in your own code, not because you can import it.
4.9 The 3am checklist
| Symptom | First check | Likely cause |
|---|---|---|
| Every call hangs, no error | Is this TypeScript? | No timeout exists. Add one (4.7) |
CoreCruxError: 401 on every call | CORECRUXD_AUTH_MODE on the daemon | Under dev_scopes the token must be a scope list, not a JWT |
CoreCruxError: 403 with no obvious cause | The missingScopes array in the problem body | Token is missing facts:write or query:read. Python discards this field, use curl |
| Everything 503s after a restart | curl /readyz and read checks | A readiness gate. Often disk capacity on the data partition |
| Duplicate facts appearing under one key | Your retry policy | storeFact is not idempotent (4.5) |
KeyError from a Python call that "worked" | The daemon version | _to_fact indexes required keys directly; a shape change surfaces as KeyError, not CoreCruxError |
| SSE connects then immediately errors, no status | Whether the daemon requires auth | EventSource cannot send the token (3.9) |
TypeError: fetch failed | The base URL and the daemon process | Transport failure, not an API error. Not a CoreCruxError |
Sources
- sdks/typescript/src/index.ts:230, error construction, problem parsing, the 204 cast
- sdks/python/src/corecrux_client/client.py:37,
_raise_for_statusand content-type gating - sdks/python/src/corecrux_client/errors.py:8,
CoreCruxError - crates/corecrux-memory/src/fact_store.rs:882, version and
supersedesassignment on repeat writes - crates/corecruxd/src/http/quota.rs:23, 429 with
Retry-Afterand quota headers - crates/corecruxd/src/http/mod.rs:296, quota middleware default off
- crates/corecruxd/src/http/health.rs:74, 503 readiness body
- packages/internal/factory/src/client.ts:60, retryable status set
- packages/internal/factory/src/client.ts:289, exponential backoff with jitter
- packages/core/src/errors.ts:3, the
SDKErrortaxonomy

