Stateful Companion¶
In this guide you'll build a circuit breaker — an immutable companion object that an actor uses to protect against cascading failures. Along the way you'll learn how to extract business rules into plain frozen dataclasses whose methods work as a constant fold: every call returns the result plus the updated state.
The Problem¶
Sometimes an actor's message handler grows complex — rate limiting, failure detection, metrics collection. You can keep everything inline, but if you'd rather extract that logic into a reusable, testable object, that object needs state. In a mutable world you'd reach for a class with internal dicts. In Casty, mutation is the enemy.
The stateful companion is one way to handle this: a frozen dataclass whose every method returns tuple[result, new_self]. The actor carries it as a behavior parameter and does behavior recursion as usual — the companion is just another piece of immutable state.
The Companion¶
A circuit breaker has three states: closed (normal operation), open (all calls rejected), and half-open (one trial call allowed). The CircuitBreaker companion tracks failure counts and timestamps, all as immutable fields:
class CircuitState(Enum):
closed = "closed"
open = "open"
half_open = "half_open"
@dataclass(frozen=True)
class CircuitBreaker:
failure_threshold: int
reset_timeout: float
state: CircuitState = CircuitState.closed
failure_count: int = 0
last_failure_time: float = 0.0
The allow method decides whether a call can proceed. When the circuit is open but the reset timeout has elapsed, it transitions to half-open:
def allow(self, now: float) -> tuple[bool, CircuitBreaker]:
match self.state:
case CircuitState.closed:
return True, self
case CircuitState.open if now - self.last_failure_time >= self.reset_timeout:
new = CircuitBreaker(
failure_threshold=self.failure_threshold,
reset_timeout=self.reset_timeout,
state=CircuitState.half_open,
failure_count=self.failure_count,
last_failure_time=self.last_failure_time,
)
return True, new
case CircuitState.open:
return False, self
case CircuitState.half_open:
return True, self
After the call completes, the actor reports the outcome. record_success resets the breaker to closed. record_failure increments the counter and trips the circuit when it hits the threshold:
def record_success(self) -> tuple[None, CircuitBreaker]:
return None, CircuitBreaker(
failure_threshold=self.failure_threshold,
reset_timeout=self.reset_timeout,
state=CircuitState.closed,
failure_count=0,
last_failure_time=0.0,
)
def record_failure(self, now: float) -> tuple[None, CircuitBreaker]:
new_count = self.failure_count + 1
new_state = (
CircuitState.open if new_count >= self.failure_threshold else self.state
)
return None, CircuitBreaker(
failure_threshold=self.failure_threshold,
reset_timeout=self.reset_timeout,
state=new_state,
failure_count=new_count,
last_failure_time=now,
)
Notice: every method returns tuple[result, CircuitBreaker]. Even status, which doesn't change state, follows the same signature — it returns self unchanged:
This uniformity means the actor never has to wonder whether a method changed the companion. It always unpacks result, new_companion and moves on.
Messages¶
The caller handles two messages: service calls and status queries:
@dataclass(frozen=True)
class CallResult:
success: bool
message: str
@dataclass(frozen=True)
class Call:
should_fail: bool
reply_to: ActorRef[CallResult]
@dataclass(frozen=True)
class GetStatus:
reply_to: ActorRef[CircuitState]
type CallerMsg = Call | GetStatus
Using It in an Actor¶
The service_caller actor is thin — it checks the breaker, makes the call, records the outcome, and transitions with the updated breaker:
def service_caller(breaker: CircuitBreaker) -> Behavior[CallerMsg]:
async def receive(
_ctx: ActorContext[CallerMsg], msg: CallerMsg
) -> Behavior[CallerMsg]:
match msg:
case Call(should_fail=should_fail, reply_to=reply_to):
now = time.monotonic()
allowed, new_breaker = breaker.allow(now)
if not allowed:
reply_to.tell(CallResult(success=False, message="circuit open"))
return service_caller(new_breaker)
if should_fail:
_, new_breaker = new_breaker.record_failure(now)
reply_to.tell(CallResult(success=False, message="service error"))
else:
_, new_breaker = new_breaker.record_success()
reply_to.tell(CallResult(success=True, message="ok"))
return service_caller(new_breaker)
case GetStatus(reply_to=reply_to):
state, _ = breaker.status()
reply_to.tell(state)
return Behaviors.same()
return Behaviors.receive(receive)
The circuit breaker state machine (closed/open/half-open, failure counting, timeout tracking) lives entirely in the companion. The actor just asks questions and reports results.
Running It¶
The main() function trips the breaker with failures, observes it blocking calls, waits for the reset timeout, and watches it recover:
async def main() -> None:
async with ActorSystem() as system:
ref = system.spawn(
service_caller(
CircuitBreaker(failure_threshold=3, reset_timeout=1.0)
),
"caller",
)
print("── Sending 3 failures to trip the breaker ──")
for i in range(3):
result: CallResult = await system.ask(
ref, lambda r: Call(should_fail=True, reply_to=r), timeout=5.0
)
print(f" Call {i + 1}: {result.message}")
status: CircuitState = await system.ask(
ref, lambda r: GetStatus(reply_to=r), timeout=5.0
)
print(f" Circuit state: {status.value}")
print("\n── Calling while circuit is open ──")
result = await system.ask(
ref, lambda r: Call(should_fail=False, reply_to=r), timeout=5.0
)
print(f" Call: {result.message}")
print("\n── Waiting for reset timeout (1s) ──")
await asyncio.sleep(1.1)
print("\n── Calling after timeout (half-open) ──")
result = await system.ask(
ref, lambda r: Call(should_fail=False, reply_to=r), timeout=5.0
)
print(f" Call: {result.message}")
status = await system.ask(
ref, lambda r: GetStatus(reply_to=r), timeout=5.0
)
print(f" Circuit state: {status.value}")
asyncio.run(main())
Output:
── Sending 3 failures to trip the breaker ──
Call 1: service error
Call 2: service error
Call 3: service error
Circuit state: open
── Calling while circuit is open ──
Call: circuit open
── Waiting for reset timeout (1s) ──
── Calling after timeout (half-open) ──
Call: ok
Circuit state: closed
Three failures trip the breaker open. The next call is rejected immediately — the companion said no. After the 1-second timeout the breaker moves to half-open, the trial call succeeds, and the circuit closes again. The actor never mutated anything.
Run the Full Example¶
git clone https://github.com/gabfssilva/casty.git
cd casty
uv run python examples/guides/09_stateful_companion.py
What you learned:
- Stateful companions are frozen dataclasses whose methods return
tuple[result, new_self]— a constant fold over immutable state. - Companions can hold business rules you choose to extract — keeping the actor focused on message routing and state transitions.
- The fold signature is uniform — every method returns the pair, even read-only ones. No guessing about side effects.
- Companions compose with behavior recursion naturally — they're just another parameter in the closure.