Skip to content
v0.1.0latest release21 skills2 categories3 sub-categories22 tested scripts68 reference docsContact us

python-exception-handling-designer

Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks. Use when the user writes or reviews try/except code, designs an errors.py, asks how to retry or handle LLM/API errors, sees swallowed or lost tracebacks, maps exceptions to HTTP status codes, or tests exceptions in .py files.

Programming / Pythonv1.0.0Updated OCT 20 20266 references2 scripts

Design exception handling that makes failures loud, specific and recoverable: a small domain hierarchy with a retryable flag, narrow handlers placed only where something can actually be done, chained causes, one boundary that turns exceptions into logs and HTTP responses, and retry/fallback logic that distinguishes a transient 429 from a permanent 400. Every review produces concrete before/after code, and the audit script catches the mechanical mistakes (bare except, swallowed errors, lost causes, swallowed cancellation).

Trigger Conditions

Activate this skill when the user:

  • Writes, reviews or refactors try/except/finally code, or asks "is this error handling OK?"
  • Designs an errors.py / exception hierarchy for a library, SDK wrapper, RAG pipeline or FastAPI service
  • Asks how to retry LLM/embedding/vector-DB calls, handle 429/5xx/timeouts, or add fallbacks between models
  • Sees lost tracebacks, "During handling of the above exception, another exception occurred", silent failures, tasks that will not cancel, or Ctrl+C that does nothing
  • Uses asyncio.gather/TaskGroup and needs to handle several failures (ExceptionGroup, except*)
  • Parses model output (JSON, tool arguments) and needs a validate-and-repair loop
  • Needs to map exceptions to HTTP status codes or test that code raises the right error

Workflow

text
1. MAP        -> List failure sources: I/O, provider SDK, parsing, validation, cancellation, bugs
2. CLASSIFY   -> For each: expected or bug? transient (retry) or permanent? caller-fixable (4xx)?
3. HIERARCHY  -> One base error per package + subclasses by how callers react, retryable flag
                 (scaffold with scripts/scaffold_exceptions.py)
4. PLACE      -> Translate at the dependency edge (raise Domain(...) from err),
                 recover where you have a fallback, log+respond once at the boundary
5. HARDEN     -> Narrow handlers, else/finally, re-raise CancelledError, timeouts, bounded retries
6. AUDIT      -> python scripts/audit_exceptions.py <path> --strict
7. TEST       -> pytest.raises(match=...), ExceptionGroup checks, retry/fallback paths with fakes

Core Rules

  1. Catch what you can handle, where you can handle it. A handler must retry, fall back, translate, add context, or clean up. Anything else lets the exception propagate.
  2. Never swallow silently. except Exception: pass hides KeyError/TypeError bugs. If a fallback is intentional, log with logger.exception (or warning(..., exc_info=True)) and make the fallback explicit.
  3. Bare except: and except BaseException must re-raise. They catch KeyboardInterrupt, SystemExit and asyncio.CancelledError (a BaseException since 3.8).
  4. Chain on translation. raise ProviderError(...) from err keeps the cause; from None deliberately hides internals (e.g. KeyError from a lookup table). Never raise X(str(err)).
  5. Keep try bodies tiny. Only the call that can raise goes in try; success-path code goes in else:; cleanup goes in finally: or a context manager.
  6. Specific before general. Handlers run first-match, so except RateLimitError must precede except ProviderError.
  7. No return/break/continue in finally. It discards the in-flight exception (SyntaxWarning in 3.14, PEP 765).
  8. Log once, at the boundary. Inner layers add context (add_note, attributes, chaining) and re-raise; the request/job boundary logs with traceback and converts to a response/exit code. Log-and-raise at every layer produces duplicate tracebacks.
  9. assert is for invariants, not input. Asserts vanish under python -O; validate arguments with if + ValueError/TypeError or Pydantic.
  10. Retry only transient errors, with a budget. Exponential backoff + full jitter, honour Retry-After, cap attempts and total time, never retry 400/401/403/422 or context-length errors.
  11. Cancellation must propagate. Catch CancelledError only to clean up, then raise.
  12. Raise specific types. Never raise Exception("..."); use builtins (ValueError, TypeError, LookupError, TimeoutError) or your domain hierarchy.

Designing a Hierarchy

Group classes by how the caller reacts, not by where the error came from.

ClassCaller reactionretryableHTTP
RagkitError (base)catch-all at the boundaryno500
ConfigurationErrorfail fast at startupno500
InputValidationErrortell the user what to fixno422
ProviderErrortranslate SDK errors heredepends502
RateLimitError(ProviderError)wait retry_after, retryyes429
ProviderOverloadedError(ProviderError)backoff, then fallback modelyes503
ContextLengthError(ProviderError)truncate/summarise, larger modelno413
StructuredOutputErrorrepair loop with validation errorsyes502
ToolExecutionErrorfeed error back to the modelno500
VectorStoreErrorretry, then degrade to keyword searchyes503
python
class RagkitError(Exception):
    """Base for every error raised by ragkit."""
    retryable: ClassVar[bool] = False

    def __init__(self, message: str, **context: Any) -> None:
        super().__init__(message)
        self.message = message
        self.context = context


class ProviderError(RagkitError):
    def __init__(self, message: str, *, provider: str, model: str, **context: Any) -> None:
        super().__init__(message, provider=provider, model=model, **context)
        self.provider, self.model = provider, model


class RateLimitError(ProviderError):
    retryable = True

    def __init__(self, message: str, *, retry_after: Optional[float] = None, **kw: Any) -> None:
        super().__init__(message, retry_after=retry_after, **kw)
        self.retry_after = retry_after

Rules: one base per package; inherit from Exception (never BaseException); names end in Error; do not shadow builtins (TimeoutError, ConnectionError); you may multiply-inherit a builtin when it helps callers (class InputValidationError(RagkitError, ValueError)); keyword-only context attributes instead of parsing messages; implement __reduce__ when __init__ has keyword-only args so errors survive pickling across processes. scripts/scaffold_exceptions.py --preset llm-app --http generates all of this.

Translate at the Edge

Wrap third-party exceptions once, inside the client wrapper, so the rest of the codebase never imports openai/httpx exception types.

Before:

python
async def complete(prompt: str) -> str:
    try:
        resp = await client.chat.completions.create(model=MODEL, messages=[{"role": "user", "content": prompt}])
        text = resp.choices[0].message.content
        data = json.loads(text)
        return data["answer"]
    except Exception as e:
        logger.error(f"LLM failed: {e}")
        return ""

After:

python
async def complete(prompt: str) -> str:
    try:
        resp = await client.chat.completions.create(model=MODEL, messages=[{"role": "user", "content": prompt}])
    except openai.RateLimitError as err:
        raise RateLimitError("rate limited", provider="openai", model=MODEL,
                             retry_after=parse_retry_after(err.response.headers)) from err
    except openai.APITimeoutError as err:
        raise ProviderTimeoutError("timed out", provider="openai", model=MODEL) from err
    except openai.APIStatusError as err:
        if err.status_code >= 500:
            raise ProviderOverloadedError("upstream 5xx", provider="openai", model=MODEL,
                                          status_code=err.status_code) from err
        raise ProviderError(f"upstream {err.status_code}", provider="openai", model=MODEL) from err
    else:
        return parse_answer(resp.choices[0].message.content or "")  # raises StructuredOutputError

EAFP vs LBYL

PreferWhenExample
EAFP (try)race-prone checks, the common path succeedstry: cfg = cache[key] except KeyError: ...
EAFPfile/network existence checks (TOCTOU)open the file and catch FileNotFoundError
LBYL (if)validating caller input with a clear messageif not 0 < temperature <= 2: raise ValueError(...)
LBYLthe failure is common, or the try would be largeif chunk.strip(): before embedding
Neithera method exists that expresses intentd.get(k, default), next(iter(xs), None)

Context, Chaining and Notes

python
try:
    chunks = chunker.split(doc.text)
except ValueError as err:
    err.add_note(f"doc_id={doc.id} source={doc.source}")  # 3.11+: shown under the traceback
    raise
Form__cause____suppress_context__Traceback shows
raise New() inside exceptNoneFalse"During handling ... another exception occurred" (looks like a bug in the handler)
raise New() from errerrTrue"The above exception was the direct cause of ..."
raise New() from NoneNoneTrueonly the new exception
raiseunchangedunchangedoriginal traceback, continued

Use bare raise to re-raise (not raise err, which adds a frame and is easy to confuse with a new raise).

Logging

python
except VectorStoreError:
    logger.exception("retrieval failed", extra={"collection": coll, "query_id": qid})  # ERROR + traceback
    raise
  • logger.exception only inside an except block; elsewhere pass exc_info=err.
  • Never logger.error(e) / logger.error(f"{e}"): it drops type and traceback, and many SDK messages are empty.
  • Put identifiers in extra= (structured logging), never prompts with PII or API keys.

Async, Timeouts and Groups

python
async def stream_answer(prompt: str) -> AsyncIterator[str]:
    try:
        async with asyncio.timeout(60):              # 3.11+; use asyncio.wait_for on 3.9/3.10
            async for delta in provider.stream(prompt):
                yield delta
    except asyncio.CancelledError:
        await provider.aclose()                      # cleanup only
        raise                                        # never swallow cancellation
    except TimeoutError as err:                      # asyncio.TimeoutError is TimeoutError since 3.11
        raise ProviderTimeoutError("stream timed out", provider="x", model="y") from err
python
try:
    async with asyncio.TaskGroup() as tg:             # 3.11+
        tasks = [tg.create_task(embed(batch)) for batch in batches]
except* RateLimitError as eg:
    logger.warning("rate limited on %d batches", len(eg.exceptions))
    raise
except* VectorStoreError as eg:
    raise RetrievalError("index unavailable") from eg

except* handlers each receive a sub-group; unmatched exceptions re-raise automatically. Use asyncio.gather(..., return_exceptions=True) when partial success is acceptable. Details in async-and-groups.md.

Retries, Fallbacks and Circuit Breakers

python
async def with_retry(call, *, attempts=5, base=0.5, cap=30.0, deadline=120.0):
    start = time.monotonic()
    for attempt in range(attempts):
        try:
            return await call()
        except RagkitError as err:
            if not err.retryable or attempt == attempts - 1:
                raise
            delay = getattr(err, "retry_after", None) or random.uniform(0, min(cap, base * 2 ** attempt))
            if time.monotonic() - start + delay > deadline:
                raise
            err.add_note(f"retry attempt {attempt + 1}/{attempts}")  # 3.11+
            await asyncio.sleep(delay)
    raise AssertionError("unreachable")
ErrorRetry?Then
429 rate limityes, honour Retry-Afterfallback model / queue
500/502/503/529 overloadedyes, backoff + jitterfallback model, open circuit
connect/read timeoutyes (idempotent calls only)fallback
400 bad request, 422nofix the request
401/403 authnopage someone
context length exceededno (same input)truncate history / larger-context model
content filterednoreturn a safe refusal
malformed JSON outputyes, as a repair call with errorsafter N tries raise StructuredOutputError
CUDA OOMnot as-ishalve batch size, torch.cuda.empty_cache()

Full patterns (repair loops, tool errors, embedding partial failures, OOM halving, breakers) are in ai-error-handling.md.

Boundaries: FastAPI, CLIs, Workers

python
@app.exception_handler(RagkitError)
async def ragkit_error_handler(request: Request, exc: RagkitError) -> JSONResponse:
    status = http_status_for(exc)
    if status >= 500:
        logger.error("request failed", exc_info=exc, extra={"path": request.url.path})
    headers = {"Retry-After": str(math.ceil(exc.retry_after))} if getattr(exc, "retry_after", None) else {}
    return JSONResponse(status_code=status, content=exc.to_dict(), headers=headers)

CLI: main() catches the package base error, prints one line to stderr and returns exit code 1; lets KeyboardInterrupt become exit code 130. Workers (Celery, queue consumers): catch per message, log with traceback, ack/nack based on is_retryable(exc), never let one poisoned message kill the loop.

Warnings vs Exceptions

Use warnings.warn("top_k > 100 is slow", RuntimeWarning, stacklevel=2) for "works, but probably not what you want" and deprecations (DeprecationWarning, stacklevel=2 points at the caller). Escalate in tests with -W error or filterwarnings = ["error"] in pytest config.

Output Format

When reviewing or designing, answer with:

  1. Findings - numbered, each with location, rule id (EXC0xx when the script flagged it), why it is wrong (what failure becomes invisible or mis-handled), and severity.
  2. Fixed code - complete before/after for every changed handler; keep behaviour on the happy path.
  3. Hierarchy (when designing) - tree with retryable and HTTP status per class, plus where each is raised and where it is caught.
  4. Retry/fallback policy - table of error -> retry? -> backoff -> fallback, with budgets.
  5. Tests - pytest.raises(..., match=...) cases for every new raise path and for retry exhaustion.

Scripts

bash
# Audit files or whole directories (AST only; never imports or runs the code)
python scripts/audit_exceptions.py src/ragkit/
python scripts/audit_exceptions.py app/llm_client.py --json
cat handler.py | python scripts/audit_exceptions.py -
python scripts/audit_exceptions.py src/ --strict                       # exit 1 on any finding (CI gate)
python scripts/audit_exceptions.py src/ --select EXC001,EXC003,EXC008  # ids, id prefixes or names
python scripts/audit_exceptions.py src/ --ignore try-too-large --max-try-body 6

Rules: EXC001 bare-except, EXC002 base-exception-swallow, EXC003 broad-except-swallow (also suppress(Exception)), EXC004 raise-without-from, EXC005 generic-raise, EXC006 unreachable-handler, EXC007 finally-control-flow, EXC008 cancellation-swallowed, EXC009 assert-validation, EXC010 log-error-not-exception, EXC011 try-too-large, EXC012 useless-reraise, EXC013 stringified-exception, EXC014 redundant-except-type. Silence one line with # noqa: EXC003. Exit codes: 0 clean (or not strict), 1 findings with --strict, 2 usage/read/syntax error.

bash
# Generate errors.py: base class, retryable flags, attributes, __str__, to_dict, pickling, is_retryable()
python scripts/scaffold_exceptions.py --package ragkit --errors "ProviderError:retryable,provider=str" \
    "RateLimitError(ProviderError):retryable,retry_after=float" "ContextLengthError(ProviderError):tokens=int"
python scripts/scaffold_exceptions.py --package ragkit --preset llm-app --http -o src/ragkit/errors.py
python scripts/scaffold_exceptions.py --spec errors.json --json        # JSON spec file (or - for stdin)
python scripts/scaffold_exceptions.py --package ragkit --preset llm-app --http --check src/ragkit/errors.py

Spec grammar: Name[(Parent)][:retryable,http=429,code=slug,attr=type,...]. --http adds HTTP_STATUS, http_status_for(exc) and register_exception_handlers(app) for FastAPI. Output is verified with ast.parse/compile (exit 2 if invalid); --check exits 1 when the file has drifted.

Additional Resources

FileContent
exception-hierarchy.mdFull builtin tree, when each builtin is raised, which to raise yourself, version changes
patterns.mdHierarchy design, translation, chaining, notes, else/finally, cleanup, logging, boundaries, warnings, result types
anti-patterns.mdEvery EXC rule with before/after fixes, plus smells the script cannot see
async-and-groups.mdCancellation, timeouts, TaskGroup, ExceptionGroup and except*, gather, generators, threads
ai-error-handling.mdProvider errors, retries, streaming, JSON repair, tool errors, embeddings, CUDA OOM, vector DBs, breakers, fallbacks
testing-exceptions.mdpytest.raises, match, ExceptionGroup, warnings, logging assertions, fault injection for retries
More in Python

python-beginner-concept-tutor

Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.

python-comprehension-refactorer

Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.

python-context-manager-writer

Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.

python-dataclass-converter

Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.

python-idiomatic-code-reviewer

Review Python code for non-idiomatic, unpythonic or risky patterns and rewrite it into clean modern Python: PEP 8 / PEP 20, EAFP vs LBYL, iteration idioms (enumerate, zip, comprehensions, any/all), unpacking, f-strings, pathlib, truthiness, `is None`, mutable defaults, walrus, match statements, collections / itertools / functools, logging instead of print, with-blocks and resource handling. Includes AI-engineering checks: hardcoded API keys, time.sleep or requests inside async def, missing HTTP timeouts, unbounded retry loops, model clients built at import time, prompt string concatenation, sequential awaits, eval() on LLM output. Ships a 46-rule AST reviewer with severities and suggested rewrites.

python-type-hints-annotator

Add accurate, modern type hints to Python and fix mypy / pyright (pyrefly, ty) errors: builtin generics, X | None, TypeAlias and the 3.12 type statement, TypeVar and PEP 695 generics, Protocol, Callable / ParamSpec / Concatenate, TypedDict (Required, NotRequired, ReadOnly), Literal, Final, ClassVar, overload, Annotated, TypeGuard / TypeIs, Self, Never, TypeVarTuple, async types, Iterable vs Sequence vs Mapping, TYPE_CHECKING, stubs, py.typed, gradual strictness. AI-engineering focus: typed LLM message dicts and tool schemas, pydantic vs TypedDict, numpy NDArray, torch / jaxtyping shapes, streaming generators, async clients, callbacks, generic VectorStore protocols, typed config. Ships an AST audit: coverage gate, legacy-typing rewrites, inferred types.