Error handling¶
This guide explains the typed exception hierarchy kneo-client raises, what each exception carries, and how to write robust catch blocks for the common operational shapes.
What "errors" mean in kneo-client¶
Every failure of a request — transport-level or server-side, at any layer — surfaces as a typed exception derived from KneoError. There is no (ok, err) tuple return, no Response[T] wrapper, no errno field. The standard Python try / except flow is the only error-handling shape, and except KneoError catches every request failure (including a malformed server response — see KneoProtocolError).
The one deliberate exception is argument validation that fails before a request is sent — e.g. passing an empty / whitespace-only idempotency_key, which raises a plain ValueError. That is a programming error in the caller's input (like a TypeError for the wrong argument type), not a request failure, so it is intentionally not a KneoError. Validate or trust your inputs; reserve except KneoError for the request itself.
Each exception carries enough context to:
- Log the failure with traceability — every exception has
.request_id(the server-assigned correlation ID) and, forPOSTfailures,.idempotency_key. - Branch on the operational meaning — the exception class encodes "what went wrong" (auth vs. permission vs. server outage vs. network).
- Read the server's reason —
.bodyis the parsed JSON the platform returned (or raw text when the response wasn't JSON). - Decide whether to retry, escalate, or surface — combined with
.statusand the exception type, you can route the failure programmatically.
Hierarchy¶
KneoError
├── KneoNetworkError # DNS / connect / TLS / read timeout — wrapped from httpx.HTTPError
├── KneoProtocolError # success status with a missing/non-JSON body (e.g. a bodyless 202)
├── KneoBadRequestError # HTTP 400 — semantically rejected (spec_invalid, token_budget_exceeded, …)
├── KneoAuthError # HTTP 401 — missing or invalid API key
├── KneoPermissionError # HTTP 403 — scope denied, or environment_policy_blocked
├── KneoNotFoundError # HTTP 404 — resource does not exist
├── KneoConflictError # HTTP 409 — state conflict, classified by .code (carries .retry_after)
│ ├── KneoIdempotencyMismatchError # HTTP 409 with code idempotency_key_conflict
│ └── KneoHumanTaskExpiredError # HTTP 409 with code human_task_expired (carries .continuation_id / .expires_at)
├── KneoPayloadTooLargeError # HTTP 413 — body over the server's cap (carries .max_body_bytes)
├── KneoValidationError # HTTP 422 — request validation failed
├── KneoRateLimitedError # HTTP 429 (carries .retry_after)
└── KneoServerError # HTTP 5xx — server-side failure
└── KneoServiceUnavailableError # HTTP 503 — backpressure / store down (carries .retry_after)
KneoIdempotencyMismatchError and KneoHumanTaskExpiredError are subclasses of KneoConflictError (catching KneoConflictError also catches both). Everything else is parallel.
Renamed in 0.10.0.
KneoRateLimitedError,KneoPayloadTooLargeError, andKneoServiceUnavailableErrorwere previouslyKneoRateLimited,KneoPayloadTooLarge, andKneoServiceUnavailable. The old names remain importable as deprecated aliases (they emit aDeprecationWarningand resolve to the same classes, so existingexceptclauses keep working) through the1.xline and are removed at2.0. Migrate to the*Errornames.
What every exception carries¶
class KneoError(Exception):
status: int | None # HTTP status, or None for transport-level failures
body: Any # Parsed JSON dict, raw text, or None
request_id: str | None # X-Request-ID echoed by the server
idempotency_key: str | None # Idempotency-Key sent on the failing request (POSTs)
code: str | None # Stable snake_case code from the error envelope (kneo_serv 0.6.0+)
KneoConflictError (409), KneoRateLimitedError (429), and KneoServiceUnavailableError (503) each add a retry_after field; KneoPayloadTooLargeError (413) adds max_body_bytes; KneoHumanTaskExpiredError (409 human_task_expired) additionally adds continuation_id and expires_at (float epoch seconds):
class KneoConflictError(KneoError):
retry_after: float | None # Seconds parsed from the Retry-After header
class KneoRateLimitedError(KneoError):
retry_after: float | None # Seconds parsed from the Retry-After header
class KneoServiceUnavailableError(KneoServerError):
retry_after: float | None # Seconds parsed from the Retry-After header
class KneoPayloadTooLargeError(KneoError):
max_body_bytes: int | None # The server's configured body-size cap, when disclosed
KneoServiceUnavailableError is a subclass of KneoServerError (catching KneoServerError also catches the 503 case). All other subclasses inherit from KneoError without adding fields.
Status code → exception mapping¶
| HTTP status | Exception | When |
|---|---|---|
| 400 | KneoBadRequestError |
Structurally valid but semantically rejected — .code is e.g. invalid_request, spec_invalid (diagnostics list in .body), or token_budget_exceeded. |
| 401 | KneoAuthError |
Missing or invalid API key. Re-check the profile resolution chain. |
| 403 | KneoPermissionError |
API key is valid but the platform won't authorize this operation. .code distinguishes a scope/role denial from environment_policy_blocked — the target environment's policy rejected the operation; fixing the key's scopes won't help there. |
| 404 | KneoNotFoundError |
The resource (run, spec, environment, etc.) doesn't exist. Often a stale ID. |
| 409 | KneoConflictError |
A state conflict, classified by .code: run_state_conflict (lifecycle fence — e.g. continuing a run that isn't paused for human review; note cancelling an already-terminal run is instead a 200 no-op) or resource_locked (held by another operation). Carries .retry_after. idempotency_key_in_progress (same key's original attempt still executing) is auto-retried by the transport per its Retry-After and surfaces here only once retries exhaust. |
409 with code idempotency_key_conflict |
KneoIdempotencyMismatchError |
Same key reused with a different payload — see idempotency. Also raised for code-less legacy 409 bodies (pre-0.6.0 servers) on keyed requests. |
409 with code human_task_expired |
KneoHumanTaskExpiredError |
Resuming a human task past its deadline (kneo_serv >= 1.1.0). Carries .continuation_id / .expires_at (float epoch); never auto-retried. Tasks with on_timeout: escalate stay resumable late and don't raise this. |
| 413 | KneoPayloadTooLargeError |
Request body exceeds the server's KNEO_SERV_MAX_BODY_BYTES cap; .max_body_bytes carries the cap when disclosed. Not retryable — shrink the payload. |
| 422 | KneoValidationError |
Request validation failed. Either the platform's envelope (e.g. .code == "guardrail_violation") or FastAPI's list-shaped detail, whose first entry is summarized into the message as loc.path: msg (+N more) — the full diagnostics stay on .body. |
| 429 | KneoRateLimitedError |
Rate limit hit. .retry_after carries the server's hint. |
| 503 | KneoServiceUnavailableError |
Platform temporarily unavailable, classified by .code: queue_full (kneo_serv's run-queue backpressure on POST /v1/runs) vs store_unavailable (the persistence store is down) — both carry Retry-After. A KneoServerError subclass that also carries .retry_after. The transport already retried within its policy honoring Retry-After; one reaching your code means retries exhausted — use .retry_after for longer caller-side back-off. |
| 5xx (other) | KneoServerError |
Server-side failure. The transport already retried within its policy (for 502/503/504); a KneoServerError reaching your code means retries exhausted. |
| Other (1xx / 3xx / 4xx that aren't above) | KneoError (base) |
Catch-all for unmodeled statuses. |
| Connection / DNS / TLS / read timeout | KneoNetworkError |
Transport-level failure. The transport already retried for transient errors; a KneoNetworkError reaching your code means retries exhausted. |
Catching patterns¶
Catch broadly, log richly¶
The most common pattern — log the full context, then decide whether to re-raise:
from kneo_client.core import KneoError
try:
run = await client.platform.runs.create(payload)
except KneoError as exc:
log.error(
"create_run failed status=%s request_id=%s idempotency_key=%s body=%r",
exc.status,
exc.request_id,
exc.idempotency_key,
exc.body,
)
raise
The request_id is the link to the platform's audit events — pass it along when reporting a problem to the platform operators.
Branch on specific status¶
When the operational meaning matters (auth flow, retry decision, user-visible error message):
from kneo_client.core import (
KneoAuthError,
KneoNotFoundError,
KneoRateLimitedError,
KneoServerError,
KneoServiceUnavailableError,
)
try:
run = await client.platform.runs.get(run_id)
except KneoAuthError:
print("API key is missing, invalid, or revoked.")
raise
except KneoNotFoundError:
print(f"run {run_id!r} does not exist.")
return None
except KneoRateLimitedError as exc:
print(f"rate-limited; server suggests waiting {exc.retry_after}s")
await asyncio.sleep(exc.retry_after or 10)
raise
except KneoServiceUnavailableError as exc:
# Backpressure / overload — back off for the server's suggested window.
# Must precede `except KneoServerError`, since it's a subclass.
print(f"platform overloaded; backing off {exc.retry_after or 5}s")
await asyncio.sleep(exc.retry_after or 5)
raise
except KneoServerError as exc:
log.error("platform 5xx (after retries): %s", exc.body)
raise
Order matters: catch more specific exceptions first (KneoNotFoundError before KneoError).
Branching on KneoError.code¶
When one status covers several operational meanings, branch on .code — the stable snake_case code from the platform's error envelope (kneo_serv 0.6.0+; None when the body carries no code). The code is the contract; the human-readable message is not:
from kneo_client.core import KneoConflictError
try:
# Continuing a run that isn't paused for human review is a
# lifecycle-fence conflict. (Cancelling an already-terminal run, by
# contrast, is a 200 no-op — it returns the unchanged terminal state
# and does not raise.)
await client.platform.runs.continue_(run_id)
except KneoConflictError as exc:
if exc.code == "run_state_conflict":
pass # run isn't awaiting input — nothing to continue
else:
raise
Codes worth knowing (illustrative, not exhaustive — kneo_serv may add new snake_case codes in a minor):
| Code | Status | Meaning |
|---|---|---|
invalid_request, spec_invalid, token_budget_exceeded |
400 | Semantically rejected request. |
environment_policy_blocked |
403 | Environment policy rejected the operation — distinct from a scope denial. |
run_state_conflict, resource_locked |
409 | Lifecycle fence / resource held by another operation. |
idempotency_key_in_progress |
409 | Same key's original attempt still executing; auto-retried by the transport. |
idempotency_key_conflict |
409 | Key replayed with a different payload → KneoIdempotencyMismatchError. |
human_task_expired |
409 | Human-task resume past the deadline → KneoHumanTaskExpiredError (kneo_serv >= 1.1.0). |
invalid_timestamp |
422 | Malformed created_after/created_before run filter (kneo_serv >= 1.2.0; older servers compare lexically without validating) → KneoValidationError. |
payload_too_large |
413 | Body over the server's cap. |
invalid_idempotency_key |
400 | An Idempotency-Key over the server's 256-char cap (measured on the stripped value). The client validates length locally first, so this is reached only via a hand-built raw-transport request. |
unknown_query_parameters |
422 | Request sent a query param the endpoint doesn't declare (kneo_serv 0.11.0+). The generated wrappers only send declared params, so this usually means a hand-built raw-transport call. |
guardrail_violation |
422 | A guardrail blocked the run. On a synchronous run this is the 422; since kneo_serv 0.11.0 a guardrail block also terminalizes the run — an async_mode=true run instead ends in the failed terminal status (no exception; check the status). |
spec_path_confined |
422 | A caller-supplied spec_path, overlays entry, or skills[].source resolved outside the server's allow-listed spec root (KNEO_SERV_SPEC_ROOT). Default-on from kneo_serv 1.0.0 (opt-in through 0.12.x). The client sends these path fields unchanged — keep specs under an allow-listed root, or have the operator set KNEO_SERV_SPEC_ROOT. See the compatibility floor note. |
not_ready |
503 | The platform isn't ready to serve yet. |
queue_full, store_unavailable |
503 | Run-queue backpressure vs persistence-store outage; both carry Retry-After. |
Handle idempotency-key mismatches loudly¶
A 409 with code idempotency_key_conflict means the same key was reused with a different payload. This is almost always a caller bug — surface it explicitly:
from kneo_client.core import KneoIdempotencyMismatchError
try:
await client.platform.runs.create(payload, idempotency_key=key)
except KneoIdempotencyMismatchError as exc:
raise RuntimeError(
f"Idempotency-Key {exc.idempotency_key!r} was reused with a different payload. "
f"Either generate a new key for the new request or fix the payload drift."
) from exc
See Idempotency and retries for the full story on how / when this happens.
Don't catch successful-status branches¶
Methods on the platform / agent clients return parsed response models on success and raise on failure. There is no "ok / err" branching at the call site. Wrap the call in try / except, not the return value:
# Right
try:
run = await client.platform.runs.create(payload)
process(run)
except KneoError:
...
# Wrong — runs.create never returns None / False on failure; it raises
result = await client.platform.runs.create(payload)
if result is None: # never happens
...
Transport errors and network troubleshooting¶
KneoNetworkError covers everything below the HTTP layer: DNS resolution, TCP connect failures, TLS handshakes, read timeouts, connection resets. It wraps the underlying httpx.HTTPError as the cause — exc.__cause__ is the original httpx exception if you need to inspect it.
The transport retries these automatically within RetryPolicy.max_attempts for transport errors and for HTTP 429 / 502 / 503 / 504. So a KneoNetworkError reaching your code means all retries exhausted:
from kneo_client.core import KneoNetworkError
try:
health = await client.platform.health.readyz()
except KneoNetworkError as exc:
print(f"could not reach the platform: {exc}")
# Treat as a hard dependency outage; don't pretend the call succeeded.
raise
If you want to see the retry behavior in your logs, set the kneo_client.transport logger to INFO:
import logging
logging.getLogger("kneo_client.transport").setLevel(logging.INFO)
# → INFO kneo_client.transport: transport error on attempt 1; sleeping 0.20s: ...
Timeouts¶
Profile.timeout (default 30s, settable per-profile in ~/.config/kneo/client.toml or via the KNEO_TIMEOUT env var) becomes httpx's single-number timeout, which is shorthand for all four of httpx's timeout dimensions: connect, read, write, and pool. So a timeout=30.0 means each of those phases independently must complete within 30s, not that the whole request must finish within 30s.
A read timeout while waiting for a slow runs.create or agent.specs.compile response surfaces as KneoNetworkError(__cause__=httpx.ReadTimeout(...)). The transport's retry policy applies — if you're seeing these reach your code, increase timeout (e.g. KNEO_TIMEOUT=120) rather than raising RetryPolicy.max_attempts, since retrying a slow request just rebuilds the same wait.
For finer control (e.g. a long read budget but a short connect budget), inject a custom httpx client with a httpx.Timeout instance:
import httpx
from kneo_client import KneoClient
custom = httpx.AsyncClient(
base_url=profile.url,
timeout=httpx.Timeout(connect=5.0, read=120.0, write=30.0, pool=5.0),
)
client = KneoClient(profile=profile, http_client=custom)
# Caller owns `custom`'s lifecycle when passing http_client.
TLS verification¶
httpx verifies server certificates by default against the certifi CA bundle, and that's what kneo-client uses with no extra configuration. Three common operational variations:
- Custom CA bundle (corporate proxy with an internal CA): pass
verify="/path/to/ca.pem"to a customhttpx.AsyncClient. - Disable verification (development against a self-signed staging instance, not for prod):
verify=False. Don't do this against an internet-reachable platform. - Client certificates (mTLS-protected staging):
cert=("/path/cert.pem", "/path/key.pem").
All three flow through the http_client= injection point shown in the Timeouts example. There is no kneo-client-level wrapper for these — the configuration surface is httpx's.
A failing TLS handshake surfaces as KneoNetworkError(__cause__=httpx.ConnectError(...)) with a message like [SSL: CERTIFICATE_VERIFY_FAILED]. Check the __cause__ for the precise OpenSSL error code.
HTTP proxies¶
httpx automatically picks up HTTPS_PROXY, HTTP_PROXY, and NO_PROXY from the environment — kneo-client inherits that behavior with no extra wiring. So:
works out of the box. For an explicit proxy URL set in code (without touching env vars), inject a custom httpx client:
A proxy that returns 502 or refuses the upstream connection surfaces as KneoServerError (if the proxy returns a real HTTP error) or KneoNetworkError (if the connection itself fails). Check __cause__ to distinguish.
Inspecting the underlying httpx exception¶
For diagnosing intermittent network issues, the original httpx exception carries more context than KneoNetworkError's string. Walk the cause chain:
try:
await client.platform.runs.list()
except KneoNetworkError as exc:
cause = exc.__cause__
print(f"kneo wrapper: {exc}")
print(f"httpx cause: {type(cause).__name__}: {cause}")
# Common httpx exception types you'll see here:
# httpx.ConnectTimeout — couldn't connect within the connect budget
# httpx.ReadTimeout — server stopped responding mid-read
# httpx.ConnectError — DNS / TCP / TLS handshake failed
# httpx.RemoteProtocolError — connection reset, partial response, etc.
# httpx.ProxyError — proxy-specific failure
When opening a support ticket for an intermittent failure, including the __cause__'s type and message lets the operator distinguish connection-pool exhaustion from a stuck upstream from a proxy misconfiguration.
Building error messages for users¶
The exceptions are designed for internal error handling, not for user-facing messages. If you're surfacing platform errors to end users (in a dashboard UI, a CLI prompt, etc.), build a friendly message from the exception's attributes rather than printing str(exc):
def user_message(exc: KneoError) -> str:
if isinstance(exc, KneoAuthError):
return "Your API key is invalid. Please check your credentials."
if isinstance(exc, KneoPermissionError):
return "You don't have permission to perform this action."
if isinstance(exc, KneoNotFoundError):
return "The requested item could not be found."
if isinstance(exc, KneoRateLimitedError):
wait = exc.retry_after or 60
return f"Too many requests. Please try again in {int(wait)}s."
if isinstance(exc, KneoServerError):
return f"Server error (request {exc.request_id}). Please report this."
if isinstance(exc, KneoNetworkError):
return "Could not reach the server. Check your network connection."
return f"Unexpected error: {exc}"
Why a typed hierarchy¶
A single KneoError would force callers to inspect .status everywhere they want to branch. A flat enum of error codes would lose the natural isinstance ergonomics. The hierarchy lets you catch broadly (except KneoError) for logging and narrowly (except KneoAuthError) for recovery — without losing the underlying response context, which stays attached to the exception instance.
Subclasses are added when the platform introduces a new HTTP status or a stable error code that warrants its own catch site, and not before. The set above is sufficient for /v1 as it stands at kneo_serv 1.2.0 (the current pin): 0.5.0 added run-queue backpressure (503 + Retry-After, surfaced as KneoServiceUnavailableError), 0.6.0 standardized the error envelope (surfaced as .code), and the 0.8.0 / 0.9.0 surfaces brought the 400 / 413 / 422 shapes now typed as KneoBadRequestError, KneoPayloadTooLargeError, and KneoValidationError. kneo_serv 0.11.0 added two new 422 codes — unknown_query_parameters and a terminalizing guardrail_violation — and 1.0.0 added a third, spec_path_confined (default-on spec-path confinement); all slot into the existing KneoValidationError via .code (no new subclass needed). kneo_serv 1.1.0 added the 409 human_task_expired code, typed as the KneoHumanTaskExpiredError subclass (a deadline miss is a distinct catch site: operators surface it rather than retry it), and 1.2.0 added the 422 invalid_timestamp code, which slots into KneoValidationError via .code.
Reference¶
| Helper | Where | What it does |
|---|---|---|
from_response(response, *, idempotency_key=None) |
kneo_client.core.errors |
Maps an httpx.Response to the appropriate KneoError subclass. Used internally by Transport; rarely called directly. |
KneoError(message, *, status, body, request_id, idempotency_key, code) |
kneo_client.core.errors |
The base. All subclasses share this constructor signature (except KneoConflictError, KneoRateLimitedError, and KneoServiceUnavailableError, which add retry_after; KneoPayloadTooLargeError, which adds max_body_bytes; and KneoHumanTaskExpiredError, which adds retry_after plus continuation_id / expires_at). |