defensive-error-handling

verified

3a74d39c-2a1d-4bf8-ba93-4b869bbbd826

Handle errors explicitly — typed/structured errors, no bare except, fail loud with context, and convert exceptions at boundaries. Use when writing or reviewing code that touches I/O, networks, or user input.

Metadata

Skill ID
3a74d39c-2a1d-4bf8-ba93-4b869bbbd826
Version
1
Owner
387274b7-2891-478b-81b8-e11d5adb9319
Tags
error-handlingexceptionsfail-fastrobustnesstry-except
Signature
verified
Integrity
OK
Content hash
3cb8a7d3c03fd75bbda59b34e57348f02980d07d36907926a4927f8c01712840
Created
2026-08-15T05:24:22Z

Skill file

Raw skill file (markdown source)
# Defensive Error Handling

Use when writing or reviewing code that touches I/O, networks, databases, or user
input — the places exceptions actually happen. The goal: handle errors explicitly
and loudly, with context, and convert exceptions at the boundary between layers.

## 1. The decision table: what to do with an exception

For every `except` clause, pick exactly one strategy. If you cannot name which one
you are using, the handler is wrong.

| Strategy | Use when | Example |
|---|---|---|
| **Retry** | Transient failure, idempotent, bounded | network timeout, rate-limit |
| **Propagate** | Caller can decide better, or it is a real bug | programming error, logic error |
| **Convert** | Crossing a layer boundary; hide library details | catch `requests.ConnectionError` → raise `PaymentGatewayError` |
| **Fail** | Unrecoverable, no safe fallback | config missing, DB down at startup |

```python
# RETRY — transient, idempotent, bounded
for attempt in range(3):
    try:
        return http_client.get(url, timeout=5)
    except requests.Timeout:
        if attempt == 2:
            raise
        time.sleep(2 ** attempt)

# CONVERT — at the boundary, keep domain language
def charge_card(amount: int) -> ChargeResult:
    try:
        return gateway.charge(amount)
    except (gateway.NetworkError, gateway.ProviderError) as exc:
        raise PaymentFailedError(amount=amount) from exc
```

## 2. Anti-patterns — never do these

| Anti-pattern | Why it is wrong | Correct version |
|---|---|---|
| `except:` (bare) | Catches `KeyboardInterrupt`/`SystemExit`, hides everything | `except ValueError:` (specific) |
| `except Exception: pass` | Swallows the error, code continues in a broken state | `except Exception as e: log + re-raise or handle` |
| Catch without re-raise or handling | Half a fix; the error vanishes | either handle or `raise` |
| Stringly errors `if err == "timeout"` | Fragile, no type safety | typed exception classes |
| `raise "some string"` | Invalid in Py3; loses stack/type | `raise ValueError("...")` |

## 3. The boundary pattern

Catch library/network exceptions at the **edge** of your code, convert them to
**domain errors**, and log the **context** — what was attempted, the input, and
the retry count.

```python
def fetch_invoice(invoice_id: str) -> Invoice:
    try:
        resp = http_client.get(f"/invoices/{invoice_id}", timeout=5)
        resp.raise_for_status()
        return Invoice.from_dict(resp.json())
    except requests.HTTPError as exc:
        if exc.response.status_code == 404:
            raise InvoiceNotFoundError(invoice_id) from exc
        logger.error("invoice fetch failed",
                     extra={"invoice_id": invoice_id,
                            "status": exc.response.status_code,
                            "retries": 0})
        raise InvoiceServiceError(invoice_id) from exc
```

Key rules:
- **Convert** at the boundary so callers never import `requests` just to catch an error.
- **Log context** (`extra={...}` with structured fields), not just "an error happened".
- **Always chain** with `from exc` to preserve the original traceback.

## 4. When to fail fast vs recover

| Situation | Action |
|---|---|
| Config missing at startup | **Fail** — crash with a clear message; do not limp |
| One of N requests fails | **Recover** — log and continue, or retry |
| Data corruption / invariant broken | **Fail** — this is a bug; surface it, don't mask it |
| User gave bad input | **Return a validation error**, do not raise |

Failing fast on unrecoverable states is a feature: a service that crashes with a
clear message is more debuggable than one that silently returns wrong answers.

## 5. Worked example: wrapping a flaky HTTP call

```python
import logging, time, requests

logger = logging.getLogger(__name__)

class InvoiceServiceError(Exception): ...
class InvoiceNotFoundError(InvoiceServiceError): ...

def fetch_invoice(invoice_id: str, *, retries: int = 3) -> dict:
    for attempt in range(retries):
        try:
            resp = requests.get(f"{BASE}/invoices/{invoice_id}", timeout=5)
            resp.raise_for_status()
            return resp.json()
        except requests.HTTPError as exc:
            if exc.response.status_code == 404:
                raise InvoiceNotFoundError(invoice_id) from exc   # CONVERT (terminal)
            logger.error("invoice fetch HTTP error",
                         extra={"invoice_id": invoice_id,
                                "status": exc.response.status_code,
                                "attempt": attempt})
            raise InvoiceServiceError(invoice_id) from exc        # CONVERT + propagate
        except requests.Timeout as exc:
            if attempt == retries - 1:
                raise InvoiceServiceError(invoice_id) from exc    # CONVERT (exhausted)
            logger.warning("invoice fetch timeout, retrying",
                           extra={"invoice_id": invoice_id, "attempt": attempt})
            time.sleep(2 ** attempt)                              # RETRY (bounded backoff)
    raise InvoiceServiceError(invoice_id)   # unreachable, but explicit
```

Notice: specific exception types, `from exc` chaining, structured `extra=` context,
and a bounded retry only on the *transient* failure (timeout), never the terminal
one (404).

## 6. Custom exception hierarchy

For anything beyond a script, define a small hierarchy so callers can catch at the
right level:

```python
class AppError(Exception):
    """Base for all domain errors."""

class NotFoundError(AppError): ...
class InvalidInputError(AppError): ...
class ExternalServiceError(AppError): ...

class InvoiceNotFoundError(NotFoundError): ...
class PaymentFailedError(ExternalServiceError): ...
```

Callers catch `NotFoundError` to handle any missing resource, or the specific
subclass when they care. This beats both `except Exception` (too broad) and a
sprawl of unrelated exception types.

## Guardrails

- Do **not** use bare `except:` or `except Exception: pass`. Ever.
- Do **not** catch `KeyboardInterrupt` or `SystemExit` — these are control flow,
  not errors.
- Do **not** lose the original traceback — chain with `from exc`.
- Do **not** catch broadly and "handle" by logging alone; either recover or re-raise.
- Do **not** swallow errors at a boundary — convert them to domain errors.
- Prefer typed, specific exception classes over strings and over a single generic
  `Exception`.

## Pitfalls

- **Over-catching** — wrapping a whole function in `try/except Exception` hides real
  bugs behind a friendly log line.
- **Catching `KeyboardInterrupt`/`SystemExit`** — breaks Ctrl-C and `sys.exit()`,
  making the program hard to stop.
- **Losing the traceback** — `raise NewError("...")` without `from exc` discards the
  original cause, making the log useless.
- **Logging without context** — "error: failed" tells you nothing; include input,
  id, status, retry count.
- **Stringly errors** — comparing error strings is fragile across versions and
  libraries; use exception types.

## Verify / Checklist

- [ ] Every `except` clause uses a specific exception type (no bare `except:`).
- [ ] No `except Exception: pass` or silent swallowing anywhere.
- [ ] Boundary conversions use `raise DomainError(...) from exc` to preserve cause.
- [ ] Error logs include structured context (id, input, status, retries), not just a message.
- [ ] Retry logic is bounded (max attempts + backoff) and only for idempotent/transient ops.
- [ ] `KeyboardInterrupt`/`SystemExit` are not caught.
- [ ] Each `except` handler maps to one strategy from the decision table.

Attached files

No attached files.