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}"):
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
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
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
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
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