Errors
Exception types raised by the Python SDK and how to handle them.
Every SDK failure raises a subclass of KeelwaveError. Catch the base class to
handle all of them, or a specific subclass when the response differs.
from keelwave import Keelwave, KeelwaveError
client = Keelwave(api_key=os.environ["KEELWAVE_API_KEY"])
try:
with client.run("research-agent", input=task) as run:
run.step("think", content="deciding which tool to call")
except KeelwaveError as exc:
logger.warning("keelwave tracing failed: %s", exc)All seven exception types are exported from the package root:
from keelwave import (
KeelwaveError,
KeelwaveAuthError,
KeelwaveValidationError,
KeelwaveRateLimited,
KeelwaveBufferFull,
KeelwaveServerError,
KeelwaveTransportError,
)Hierarchy
Exception
└── KeelwaveError
├── KeelwaveAuthError 401
├── KeelwaveValidationError 400
├── KeelwaveRateLimited 429 (.retry_after)
├── KeelwaveBufferFull 503 (.retry_after)
├── KeelwaveServerError 5xx
└── KeelwaveTransportError network failureReference
KeelwaveError
Base class for every SDK error. Catch this to handle all keelwave failures in
one place. It is also raised directly for any non-2xx status the SDK does not
map to a more specific type, with the message formatted as "{status}: {body}".
KeelwaveAuthError
Raised on HTTP 401. The API key is missing, malformed, or revoked.
Not retryable. Check that api_key holds a real kw_… key for the project you
are pointing at, and that endpoint is the right server.
from keelwave import KeelwaveAuthError
try:
client.health()
except KeelwaveAuthError:
raise SystemExit("KEELWAVE_API_KEY is invalid or revoked")KeelwaveValidationError
Raised on HTTP 400. The server's validator rejected the payload.
Not retryable — the same payload will fail again. In practice this means a field
outside its allowed set, such as a status the server does not recognise, or a
required field left empty. Fix the call site.
from keelwave import KeelwaveValidationError
try:
client.ingest_ai(model="gpt-4o", status="not-a-valid-status")
except KeelwaveValidationError as exc:
logger.error("rejected by server validator: %s", exc)KeelwaveRateLimited
Raised on HTTP 429. The per-IP or per-key bucket is exhausted.
Retryable after a wait. The exception carries retry_after, parsed from the
Retry-After response header; it is None when the header is absent or not an
integer.
import time
from keelwave import KeelwaveRateLimited
try:
run.step("think", content="deciding which tool to call")
except KeelwaveRateLimited as exc:
time.sleep(exc.retry_after or 5)KeelwaveBufferFull
Raised on HTTP 503. The server's ingest buffer is full — writes are arriving faster than they can be flushed.
Retryable after a wait, and it also carries retry_after from the Retry-After
header. If you see this steadily rather than in bursts, the server is
undersized for your write volume rather than your client misbehaving.
import time
from keelwave import KeelwaveBufferFull
try:
run.tool_call("web_search", input={"q": query}, output=results)
except KeelwaveBufferFull as exc:
time.sleep(exc.retry_after or 1)KeelwaveServerError
Raised on any 5xx other than 503. A server bug or a database outage. The
message is formatted as "{status}: {body}".
Retryable with backoff, but if it persists the problem is server-side — check the keelwave server's own logs.
KeelwaveTransportError
Raised on a network failure before any HTTP response. DNS failure, connection
refused, TLS error, or a timeout against the client's fixed timeout profile
(5s connect, 30s read, 30s write, 5s pool). The message is the string form of
the underlying httpx.RequestError, which is preserved as the __cause__.
Most often it means the endpoint is wrong or the server is not running.
from keelwave import KeelwaveTransportError
try:
client.health()
except KeelwaveTransportError as exc:
raise SystemExit(f"cannot reach keelwave at {client.endpoint}: {exc}")Where errors surface
| Call | Can raise |
|---|---|
client.health() | all types |
client.ingest_*(...) | all types |
Entering with client.run(...) | all types (the start call) |
run.step() / run.tool_call() | all types (the step call) |
Exiting the with block | all types (the finish call) |
run.set_output() / run.mark_loop() / run.check_fingerprint() | none — local state only |
@client.observe / client.span() | none — emit failures become warnings |
@client.agent is the exception worth calling out: it wraps a real run, so a
keelwave failure raised while opening or closing that run propagates out of your
decorated function.
Keeping tracing non-fatal
Observability should not take down the thing it observes. @client.observe and
client.span() already swallow emit failures into warnings.warn. For manual
runs, wrap the block yourself:
from contextlib import contextmanager
from keelwave import KeelwaveError
@contextmanager
def traced(client, name, **kwargs):
"""Yield a Run, or None if keelwave is unavailable. Never raises
KeelwaveError out of the with-block."""
try:
run = client.run(name, **kwargs)
run.__enter__()
except KeelwaveError as exc:
logger.warning("keelwave run failed to open: %s", exc)
yield None
return
try:
yield run
finally:
try:
run.__exit__(None, None, None)
except KeelwaveError as exc:
logger.warning("keelwave run failed to close: %s", exc)This swallows keelwave failures but also loses the failed-run status, because
it always closes the run as completed. Use it when tracing must never break
the caller; use a plain with client.run(...) when accurate run status matters
more.
Retry only KeelwaveRateLimited, KeelwaveBufferFull, KeelwaveServerError,
and KeelwaveTransportError. Retrying KeelwaveAuthError or
KeelwaveValidationError just repeats the same failure.
import time
from keelwave import (
KeelwaveBufferFull,
KeelwaveRateLimited,
KeelwaveServerError,
KeelwaveTransportError,
)
RETRYABLE = (
KeelwaveRateLimited,
KeelwaveBufferFull,
KeelwaveServerError,
KeelwaveTransportError,
)
def with_retry(fn, attempts=3):
for attempt in range(attempts):
try:
return fn()
except RETRYABLE as exc:
if attempt == attempts - 1:
raise
time.sleep(getattr(exc, "retry_after", None) or 2**attempt)