Cookbook: Robust Error Handling & Retries

On this page

Patterns for making routed handlers resilient, using the retry, sequential-fallback, and validation mechanisms router provides.

Recipe 1 — Retry only on transient errors

ExecutionConfig.retry_on_exceptions matches substrings against the handler's error message, so structure your HandlerResponse.fail(...) messages to include the exception class name (this happens automatically when a handler raises — Route.execute wraps it as f"Handler raised {type(exc).__name__}: {exc}"):

Python · 17 lines
from zerda.router import BaseHandler, HandlerRequest, HandlerResponse, ExecutionConfig, RouterConfig

class FlakyAPIHandler(BaseHandler):
    def handle(self, request: HandlerRequest) -> HandlerResponse:
        try:
            data = call_flaky_upstream_api(request.query)
        except TimeoutError as e:
            raise  # let Route.execute wrap it as "Handler raised TimeoutError: ..."
        return HandlerResponse.ok(data)

config = RouterConfig(
    execution=ExecutionConfig(
        max_retries=3,
        retry_delay=1.0,
        retry_on_exceptions=["TimeoutError", "ConnectionError"],
    ),
)

With this configuration, a TimeoutError triggers up to 3 attempts (1s apart); a different failure type (not matching either substring) fails immediately without wasting retries.

Recipe 2 — Validate before executing to avoid wasted attempts

Python · 6 lines
class OrderLookupHandler(BaseHandler):
    def validate(self, request: HandlerRequest) -> bool:
        return "order_id" in request.context

    def handle(self, request: HandlerRequest) -> HandlerResponse:
        return HandlerResponse.ok(lookup_order(request.context["order_id"]))

A missing order_id fails fast via validate() (no retries wasted, since _with_retry only retries on response.error matches, and a validation failure produces a fixed, unretriable message: f"Handler validation failed for route '{self.name}'.").

Recipe 3 — Sequential fallback across route variants

Python · 11 lines
from zerda.router import ExecutionConfig, ExecutionMode, RouterConfig

config = RouterConfig(execution=ExecutionConfig(mode=ExecutionMode.SEQUENTIAL))
router = HierarchicalRouter(config=config)

# Register a primary and a lower-confidence backup route with overlapping examples
router.register(Route(name="primary_docs_qa", description="...", handler=PrimaryHandler(), examples=[...], priority=10))
router.register(Route(name="backup_docs_qa", description="...", handler=BackupHandler(), examples=[...], priority=0))

# If primary_docs_qa's handler fails, ExecutionEngine automatically tries backup_docs_qa next
result = router.route_query("some tricky query")

Recipe 4 — Distinguishing "no match" from "handler failed" for alerting

Python · 12 lines
result = router.route_query(query)

if not result.matched:
    # No route cleared the confidence threshold — a routing/coverage gap,
    # possibly worth adding more examples for.
    metrics.increment("router.no_match")
elif not result.success:
    # A route was selected but its handler failed — an operational issue
    # (upstream API down, bad input, etc).
    metrics.increment("router.handler_failure", tags={"route": result.route_name})
else:
    metrics.increment("router.success", tags={"route": result.route_name})

Recipe 5 — Safe tool chaining with per-step error context

Python · 9 lines
response = router.chain(
    query="Process this customer's refund request",
    route_names=["classify_request", "verify_eligibility", "issue_refund"],
)

if not response.success:
    # response.error will read like:
    # "Chain broken at 'verify_eligibility': Order not eligible for refund."
    logger.error("Refund chain failed", extra={"error": response.error})

Related pages: _with_retry, BaseHandler.validate, ExecutionEngine.chain

Related pages