Skip to content

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, for POST failures, .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.body is the parsed JSON the platform returned (or raw text when the response wasn't JSON).
  • Decide whether to retry, escalate, or surface — combined with .status and 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, and KneoServiceUnavailableError were previously KneoRateLimited, KneoPayloadTooLarge, and KneoServiceUnavailable. The old names remain importable as deprecated aliases (they emit a DeprecationWarning and resolve to the same classes, so existing except clauses keep working) through the 1.x line and are removed at 2.0. Migrate to the *Error names.

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 custom httpx.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:

HTTPS_PROXY=http://proxy.corp.example.com:8080 python my_script.py

works out of the box. For an explicit proxy URL set in code (without touching env vars), inject a custom httpx client:

custom = httpx.AsyncClient(
    base_url=profile.url,
    proxy="http://proxy.corp.example.com:8080",
)

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).