Skip to content

Commit 2cba96e

Browse files
authored
Merge pull request #11 from benavlabs/dispatch-hooks
Dispatch hooks
2 parents a90f349 + 03a479d commit 2cba96e

14 files changed

Lines changed: 568 additions & 16 deletions

File tree

docs/api/index.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ from fastroai import (
9494
FastroAIError,
9595
PipelineValidationError,
9696
CostBudgetExceededError,
97+
DispatchSkippedError,
98+
ErrorCategory,
9799
)
98100
```
99101

@@ -105,9 +107,12 @@ All FastroAI exceptions inherit from `FastroAIError`, so you can catch all libra
105107
FastroAIError # Base for all FastroAI errors
106108
├── PipelineValidationError # Invalid pipeline configuration
107109
├── StepExecutionError # Step failed during execution
108-
└── CostBudgetExceededError # Cost budget exceeded
110+
├── CostBudgetExceededError # Cost budget exceeded
111+
└── DispatchSkippedError # Raised from on_before_dispatch to short-circuit (no retry)
109112
```
110113

114+
`ErrorCategory` (StrEnum: `TRANSIENT`, `PERMANENT`, `RESOURCE_EXHAUSTION`, `UNKNOWN`) is provided for callers that want to categorize exceptions inside `on_after_dispatch` (e.g., a circuit breaker that only counts transient failures toward its open threshold). The library doesn't auto-categorize.
115+
111116
```python
112117
try:
113118
result = await pipeline.execute(inputs, deps)

docs/changelog.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ The Changelog documents all notable changes made to FastroAI. This includes new
66

77
---
88

9+
## [0.6.0] - May 7, 2026
10+
11+
#### Added
12+
- **Dispatch hooks on `FastroAgent`** by [@igorbenav](https://github.com/igorbenav)
13+
- `on_before_dispatch` — async callback fired before each `agent.run()` attempt inside the retry loop. Raising `DispatchSkippedError` (or a subclass) short-circuits the dispatch — the retry loop propagates the error without retrying, and `on_after_dispatch` is not called.
14+
- `on_after_dispatch` — async callback fired after each attempt with the exception that occurred, or `None` on success. Use for circuit-breaker recording, retry-budget tracking, alerting.
15+
- Hooks fire **per attempt** (every retry triggers them again), letting downstream guards see each individual try rather than only the final outcome.
16+
- Pre-flight rejections like `CostBudgetExceededError` skip both hooks — they raise before the dispatch loop is entered.
17+
18+
- **`DispatchSkippedError`** exception — subclass of `FastroAIError`. Raise from `on_before_dispatch` to fail fast on circuit-breaker-open / kill-switch / rate-limit signals without burning a retry slot.
19+
20+
- **`ErrorCategory`** StrEnum (`TRANSIENT`, `PERMANENT`, `RESOURCE_EXHAUSTION`, `UNKNOWN`) — provided for callers that want to categorize exceptions inside `on_after_dispatch` (e.g., a circuit breaker that only counts transient failures toward its open threshold). The library doesn't auto-categorize; callers map exceptions themselves.
21+
22+
- **`AgentConfig.timeout_name`** — optional human-readable label for the configured timeout, surfaced in span attributes and `TimeoutError` messages emitted by the retry loop. Has no effect on enforcement; observability tooling (e.g. Logfire) uses it to distinguish which configured timeout fired without parsing call-site context.
23+
24+
#### Documentation
25+
- Updated `docs/learn/6-when-things-go-wrong.md` with a Dispatch Hooks section showing the breaker pattern.
26+
- Updated error-hierarchy diagrams in `docs/api/index.md`, `docs/learn/6-when-things-go-wrong.md`, and `CLAUDE.md` to include `DispatchSkippedError`.
27+
- Updated `docs/guides/fastro-agent.md` configuration reference table with `timeout_name`, `on_before_dispatch`, `on_after_dispatch`.
28+
29+
**Full Changelog**: https://github.com/benavlabs/fastroai/compare/v0.5.0...v0.6.0
30+
31+
---
32+
933
## [0.5.0] - May 6, 2026
1034

1135
#### Fixed

docs/guides/fastro-agent.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ agent = FastroAgent(config=config)
6868
| `temperature` | `0.7` | Sampling temperature (0.0-2.0) |
6969
| `max_tokens` | `4096` | Maximum response tokens |
7070
| `timeout` | `None` | Per-request timeout in seconds. When set, forwarded to `ModelSettings.timeout` and enforced by the underlying model client. `None` means use the client's own default (typically 600s read on OpenAI). |
71+
| `timeout_name` | `None` | Human-readable label for this timeout, surfaced in span attributes and `TimeoutError` messages emitted by the retry loop. Has no effect on enforcement — observability only. |
7172
| `max_retries` | `3` | Retry attempts on failure |
73+
| `on_before_dispatch` | `None` | Async callback fired before each `agent.run()` attempt inside the retry loop. Raise `DispatchSkippedError` to short-circuit (no retry, no `on_after_dispatch`). Use for circuit breakers, kill switches, rate limiters. Lives on `FastroAgent`, not `AgentConfig`. |
74+
| `on_after_dispatch` | `None` | Async callback fired after each attempt with the exception (or `None` on success). Use for breaker recording, retry-budget tracking. Does not fire on pre-flight rejections like `CostBudgetExceededError`. Lives on `FastroAgent`, not `AgentConfig`. |
7275

7376
Model names use PydanticAI's provider prefix format: `openai:gpt-4o`, `anthropic:claude-3-5-sonnet`, `google:gemini-1.5-pro`. The prefix tells PydanticAI which API client to use.
7477

docs/learn/6-when-things-go-wrong.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ FastroAI uses a structured exception hierarchy:
8787
FastroAIError # Base for all FastroAI errors
8888
├── PipelineValidationError # Invalid pipeline config
8989
├── StepExecutionError # Step failed during execution
90-
└── CostBudgetExceededError # Budget limit hit
90+
├── CostBudgetExceededError # Budget limit hit
91+
└── DispatchSkippedError # Short-circuit a dispatch (breaker open, kill switch)
9192
```
9293

9394
Catch `FastroAIError` to handle all library-specific errors:
@@ -125,6 +126,32 @@ except CostBudgetExceededError as e:
125126
print(f"Step: {e.step_id}")
126127
```
127128

129+
## Dispatch Hooks
130+
131+
Some failures are best handled before they happen. If your downstream provider is rate-limiting you, retrying ten times in a row just makes it worse. If a circuit breaker has tripped, the next request shouldn't even be attempted. `FastroAgent` accepts two async callbacks that fire **per attempt** inside the retry loop:
132+
133+
```python
134+
async def before():
135+
if breaker.is_open():
136+
raise DispatchSkippedError("breaker open")
137+
138+
async def after(exc: Exception | None):
139+
if exc is None:
140+
breaker.record_success()
141+
else:
142+
breaker.record_failure(categorize(exc))
143+
144+
agent = FastroAgent(
145+
model="openai:gpt-4o",
146+
on_before_dispatch=before,
147+
on_after_dispatch=after,
148+
)
149+
```
150+
151+
Raising `DispatchSkippedError` from `on_before_dispatch` short-circuits the dispatch entirely — the retry loop propagates the error without retrying, and `on_after_dispatch` is not called. Use it for guards that should fail fast: circuit breakers, kill switches, rate limiters that have already exhausted budget. Pre-flight rejections like `CostBudgetExceededError` skip both hooks (they raise before the dispatch loop is entered).
152+
153+
For categorizing exceptions inside `on_after_dispatch`, the library exports `ErrorCategory` — a StrEnum with `TRANSIENT`, `PERMANENT`, `RESOURCE_EXHAUSTION`, and `UNKNOWN`. Callers map exceptions themselves; the library doesn't auto-categorize.
154+
128155
## Designing for Failure
129156

130157
The goal isn't preventing all failures - it's ensuring failures don't cascade.

fastroai/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ async def classify(ctx: StepContext[None]) -> str:
3434
print(result.output)
3535
"""
3636

37-
__version__ = "0.1.0"
37+
from importlib.metadata import PackageNotFoundError
38+
from importlib.metadata import version as _version
3839

3940
from .agent import AgentConfig, AgentStepWrapper, ChatResponse, FastroAgent, StreamChunk
40-
from .errors import CostBudgetExceededError, FastroAIError, PipelineValidationError
41+
from .errors import CostBudgetExceededError, DispatchSkippedError, ErrorCategory, FastroAIError, PipelineValidationError
4142
from .pipelines import (
4243
BasePipeline,
4344
BaseStep,
@@ -57,6 +58,11 @@ async def classify(ctx: StepContext[None]) -> str:
5758
from .tracing import LogfireTracer, NoOpTracer, SimpleTracer, Tracer
5859
from .usage import CostCalculator
5960

61+
try:
62+
__version__ = _version("fastroai")
63+
except PackageNotFoundError:
64+
__version__ = "0.0.0+unknown"
65+
6066
__all__ = [
6167
"__version__",
6268
# Agent
@@ -69,6 +75,8 @@ async def classify(ctx: StepContext[None]) -> str:
6975
"FastroAIError",
7076
"PipelineValidationError",
7177
"CostBudgetExceededError",
78+
"DispatchSkippedError",
79+
"ErrorCategory",
7280
# Pipelines
7381
"Pipeline",
7482
"PipelineResult",

fastroai/agent/agent.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import inspect
1111
import logging
1212
import time
13-
from collections.abc import AsyncGenerator, Callable
13+
from collections.abc import AsyncGenerator, Awaitable, Callable
1414
from typing import Any, Generic, TypeVar, cast
1515

1616
from pydantic_ai import Agent
@@ -96,6 +96,8 @@ def __init__(
9696
output_type: type[OutputT] | None = None,
9797
toolsets: list[AbstractToolset] | None = None,
9898
cost_calculator: CostCalculator | None = None,
99+
on_before_dispatch: Callable[[], Awaitable[None]] | None = None,
100+
on_after_dispatch: Callable[[Exception | None], Awaitable[None]] | None = None,
99101
**kwargs: Any,
100102
) -> None:
101103
"""Initialize FastroAgent.
@@ -107,8 +109,23 @@ def __init__(
107109
output_type: Pydantic model for structured output. Defaults to str.
108110
toolsets: Tool sets available to the agent.
109111
cost_calculator: Cost calculator. Default uses standard pricing.
112+
on_before_dispatch: Optional async callback fired immediately before
113+
each `agent.run()` attempt inside the StepContext retry loop.
114+
Raise `DispatchSkippedError` (or a subclass) to short-circuit
115+
the dispatch — the retry loop propagates that exception without
116+
retrying. Use this for guards that should fail fast: circuit
117+
breakers, rate limiters, kill switches. Fires per attempt
118+
(every retry triggers the hook again).
119+
on_after_dispatch: Optional async callback fired immediately after
120+
each `agent.run()` attempt completes. Receives the exception
121+
that was raised, or `None` on success. Use this for outcome
122+
signaling: breaker recording, retry-budget tracking, alerting.
123+
Fires per attempt (every retry triggers the hook again).
124+
Does NOT fire when `on_before_dispatch` raises (that
125+
short-circuits dispatch entirely) or when a pre-flight guard
126+
like `CostBudgetExceededError` rejects before dispatch.
110127
**kwargs: Passed to AgentConfig if config is None.
111-
Common: model, system_prompt, temperature, max_tokens.
128+
Common: model, system_prompt, temperature, max_tokens, timeout, timeout_name.
112129
113130
Examples:
114131
```python
@@ -132,12 +149,32 @@ def __init__(
132149
from pydantic_ai import Agent
133150
pydantic_agent = Agent(model="gpt-4o", output_type=MyType)
134151
agent = FastroAgent(agent=pydantic_agent)
152+
153+
# With dispatch hooks for circuit-breaker integration
154+
async def before():
155+
await breaker.check() # may raise DispatchSkippedError subclass
156+
157+
async def after(exc):
158+
if exc is None:
159+
await breaker.record_success()
160+
else:
161+
await breaker.record_failure(categorize(exc))
162+
163+
agent = FastroAgent(
164+
model="gpt-4o",
165+
timeout=300,
166+
timeout_name="my_step.dispatch",
167+
on_before_dispatch=before,
168+
on_after_dispatch=after,
169+
)
135170
```
136171
"""
137172
self.config = config or AgentConfig(**kwargs)
138173
self.toolsets = toolsets or []
139174
self.cost_calculator = cost_calculator or CostCalculator()
140175
self._output_type = output_type
176+
self._on_before_dispatch = on_before_dispatch
177+
self._on_after_dispatch = on_after_dispatch
141178
model_explicitly_set = (config is not None and config.model != DEFAULT_MODEL) or "model" in kwargs
142179
self._fallback_model: str | None = self.config.model if model_explicitly_set else None
143180

fastroai/agent/schemas.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ class AgentConfig(BaseModel):
6767
"by this value — total wall time is therefore (max_retries + 1) × timeout."
6868
),
6969
)
70+
timeout_name: str | None = Field(
71+
default=None,
72+
description=(
73+
"Human-readable label for this timeout, surfaced in span attributes and "
74+
"TimeoutError messages emitted by the retry loop. Used by observability "
75+
"tooling (e.g. Logfire) to distinguish which configured timeout fired "
76+
"without parsing call-site context. Has no effect on enforcement — the "
77+
"actual deadline is still controlled by `timeout`."
78+
),
79+
)
7080
max_retries: int = Field(default=DEFAULT_MAX_RETRIES, ge=0, description="Maximum retry attempts on failure.")
7181

7282
def get_effective_system_prompt(self) -> str:

fastroai/errors.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
"""FastroAI exception hierarchy.
1+
"""FastroAI exception hierarchy and error classification.
22
33
Provides structured exceptions for clear error handling:
44
- FastroAIError: Base exception for all FastroAI errors
55
- PipelineValidationError: Invalid pipeline configuration
66
- CostBudgetExceeded: Cost budget was exceeded
7+
- DispatchSkippedError: Short-circuit signal raised from on_before_dispatch hooks
8+
9+
Plus the ErrorCategory enum, used by on_after_dispatch callbacks to classify
10+
exceptions for downstream consumers (e.g. circuit breakers).
711
"""
812

913
from __future__ import annotations
1014

15+
from enum import StrEnum
16+
1117

1218
class FastroAIError(Exception):
1319
"""Base exception for all FastroAI errors.
@@ -90,3 +96,65 @@ def __init__(
9096
self.step_id = step_id
9197
location = f" in step '{step_id}'" if step_id else ""
9298
super().__init__(f"Cost budget exceeded{location}: {actual} microcents > {budget} microcents budget")
99+
100+
101+
class DispatchSkippedError(FastroAIError):
102+
"""Raise from `on_before_dispatch` to short-circuit the dispatch.
103+
104+
When raised, `agent.run()` is not called and the retry loop in
105+
`StepContext._execute_with_config` does not retry — the exception
106+
propagates immediately to the caller of `ctx.run()`. Use this for
107+
guards that should fail fast: circuit breakers being the canonical
108+
example. Subclass this for application-specific skip reasons.
109+
110+
The retry loop distinguishes `DispatchSkippedError` from other exceptions:
111+
the former propagates without retry; the latter triggers exponential
112+
backoff. This avoids burning ``retries × retry_delay`` seconds on a
113+
sick provider that the guard already classified as "don't even try."
114+
115+
Examples:
116+
```python
117+
class BreakerOpen(DispatchSkippedError):
118+
def __init__(self, provider: str):
119+
super().__init__(f"{provider} circuit breaker open")
120+
self.provider = provider
121+
122+
async def before_dispatch() -> None:
123+
if breaker.state == "open":
124+
raise BreakerOpen("deepseek")
125+
126+
agent = FastroAgent(
127+
...,
128+
on_before_dispatch=before_dispatch,
129+
)
130+
```
131+
"""
132+
133+
pass
134+
135+
136+
class ErrorCategory(StrEnum):
137+
"""Bare-minimum vocabulary for error classification at the dispatch hooks layer.
138+
139+
Concrete categorization (which exceptions are TRANSIENT for which
140+
provider) is the application's responsibility — fastroai stays
141+
provider-agnostic. Used by `on_after_dispatch` callbacks to feed
142+
downstream consumers (circuit breakers, retry budgets, alerting).
143+
144+
Categories:
145+
TRANSIENT: Should retry / let breaker count toward opening.
146+
Network errors, rate limits, 5xx responses, request timeouts.
147+
PERMANENT: Should not retry / no breaker signal.
148+
4xx responses (except 408/429), schema validation errors,
149+
malformed input.
150+
RESOURCE_EXHAUSTION: Out of memory / disk / quota.
151+
Distinct from TRANSIENT because recovery typically needs
152+
operational action (scale up, free disk) rather than a
153+
retry-after delay.
154+
UNKNOWN: Unclassified — caller decides how to treat.
155+
"""
156+
157+
TRANSIENT = "transient"
158+
PERMANENT = "permanent"
159+
RESOURCE_EXHAUSTION = "resource_exhaustion"
160+
UNKNOWN = "unknown"

fastroai/pipelines/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ async def execute(self, context: StepContext[MyDeps]) -> str:
2626
print(result.output) # "POSITIVE"
2727
"""
2828

29-
from ..errors import CostBudgetExceededError, FastroAIError, PipelineValidationError
29+
from ..errors import (
30+
CostBudgetExceededError,
31+
DispatchSkippedError,
32+
ErrorCategory,
33+
FastroAIError,
34+
PipelineValidationError,
35+
)
3036
from .base import BaseStep, ConversationState, ConversationStatus, StepContext
3137
from .config import PipelineConfig, StepConfig
3238
from .decorators import step
@@ -51,6 +57,8 @@ async def execute(self, context: StepContext[MyDeps]) -> str:
5157
"PipelineValidationError",
5258
"StepExecutionError",
5359
"CostBudgetExceededError",
60+
"DispatchSkippedError",
61+
"ErrorCategory",
5462
# Pipeline
5563
"Pipeline",
5664
"PipelineResult",

0 commit comments

Comments
 (0)