Errors¶
Format¶
FastAPI's standard envelope:
Validation errors (422) carry the structured Pydantic form instead:
{
"detail": [
{
"type": "string_too_long",
"loc": ["body", "content"],
"msg": "String should have at most 8000 characters",
"input": "…"
}
]
}
So detail is either a string or a list. Handle both:
def message(resp):
d = resp.json().get("detail")
if isinstance(d, list):
return "; ".join(f"{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in d)
return str(d)
Status codes¶
| Code | Meaning | Typical cause |
|---|---|---|
200 |
OK | |
201 |
Created | POST /api/projects, /api/invitations, /api/handoffs |
401 |
Unauthenticated | No key, bad key, revoked key, API key on a session-only endpoint |
404 |
Not found | Unknown project key or context id — or one owned by another organization |
409 |
Conflict | Email already registered |
422 |
Validation failed | Bad type, oversized content, out-of-range floats |
429 |
Rate limited | Over 300/min authenticated or 60/min anonymous |
500 |
Server error | A bug — report it |
503 |
Not ready | /api/health could not reach the database |
The ones that surprise people¶
404 on a project you just wrote to¶
Project keys are created on write only. GET /api/context/billing-service
returns 404 until something has been POSTed to that exact key. A one-character
difference is a different project.
GET /api/projects lists what actually exists — start there.
404 instead of 403¶
A context belonging to another organization returns 404, not 403. This is
deliberate: 403 would confirm the id exists. Do not read 404 as "deleted".
401 where you expected an empty list¶
An unauthenticated retrieval is 401, never {"memories": []}. If you receive
an empty list you are authenticated and the project genuinely has no matches.
422 on a type you thought was valid¶
The six types are exact, lowercase, singular: fact decision preference
event constraint relationship. "Decision" and "decisions" are both
422.
401 with a valid-looking key on /api/auth/*¶
Key management and team endpoints need a session cookie. A key cannot mint keys. The message is explicit rather than a generic "invalid token", which would send you looking for the wrong problem.
Handling 429¶
Retry-After carries seconds. Honour it — a retry loop that ignores it will
stay rate-limited.
import time, requests
def with_retry(fn, attempts=3):
"""Retry only on 429, honouring Retry-After. Other errors surface immediately —
retrying a 422 will never work."""
for i in range(attempts):
r = fn()
if r.status_code != 429:
r.raise_for_status()
return r
time.sleep(int(r.headers.get("Retry-After", 2 ** i)))
r.raise_for_status()
Rate limits apply only when the server runs with VENKAI_ENV=production. A
self-hosted development instance will never return 429, so test this path
against a production-mode instance or you will not exercise it.
Client-side failure modes¶
Not HTTP errors, but the ones that actually cost time:
| Symptom | Cause |
|---|---|
| Connection refused against the hosted API | Python SDK base_url defaults to http://127.0.0.1:8000. Pass it explicitly. |
| Writes succeed, reads return nothing | Wrote and read different project keys, or MCP defaulted to forge-default. |
VenkaiAuthError: No project specified |
SDK call with no project= and no client-level project. |
| Retrieval returns irrelevant memories | No relevance floor exists. Filter on score yourself. |
| Timeouts on a large project | Scoring is O(candidates). Check VENKAI_RETRIEVAL_MAX_CANDIDATES and project size. |