Skip to content

Commit ec1bec2

Browse files
fix(circuit_breaker): await async coroutines inside circuit breaker failure tracking (#8703)
* fix(circuit_breaker): await async coroutines inside circuit breaker failure tracking * refactor(circuit_breaker): share the state machine between sync and async paths _route() decides CLOSED/PROBE/OPEN once; _on_closed_*/_on_probe_* hold the bookkeeping; the sync and async call methods only differ in how they invoke the protected function. Also dedupes the decorator wrappers and documents async support. * fix(circuit_breaker): run a single probe per process when coroutines call the circuit concurrently The probe lease is per thread, so concurrent coroutines in one event loop all passed the owner check and probed together. Track in-flight probes per circuit name and give the other callers the open response, matching the single-prober guarantee threads already had. * fix(circuit_breaker): ignore an in-flight probe entry inherited through fork --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 13dbc55 commit ec1bec2

6 files changed

Lines changed: 435 additions & 73 deletions

File tree

‎aws_lambda_powertools/utilities/circuit_breaker/base.py‎

Lines changed: 170 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@
99
from __future__ import annotations
1010

1111
import datetime as dt
12+
import inspect
1213
import logging
1314
import os
1415
import threading
1516
import uuid
17+
from enum import Enum
1618
from typing import TYPE_CHECKING, Any
1719

1820
from aws_lambda_powertools.utilities.circuit_breaker.exceptions import CircuitBreakerOpenError
@@ -25,6 +27,7 @@
2527
from aws_lambda_powertools.utilities.circuit_breaker.persistence.base import (
2628
CircuitBreakerPersistenceLayer,
2729
)
30+
from aws_lambda_powertools.utilities.circuit_breaker.persistence.record import CircuitStateRecord
2831
from aws_lambda_powertools.utilities.circuit_breaker.states import CircuitInfo
2932

3033
logger = logging.getLogger(__name__)
@@ -45,11 +48,25 @@
4548
# callbacks.
4649
_COUNTERS_LOCK = threading.Lock()
4750

51+
# Circuit names with a probe currently running in this process, mapped to the pid that
52+
# claimed it. The probe lease is per thread, so concurrent coroutines in one event loop
53+
# would all pass the owner check and probe together; this keeps the single-prober
54+
# guarantee within a process too. The pid lets a forked child ignore an entry it inherited.
55+
_PROBES_IN_FLIGHT: dict[str, int] = {}
56+
4857
# Identifier used to claim the half-open probe lock, unique per thread so the store's
4958
# conditional election picks a single prober across threads as well as processes.
5059
_PROBE_OWNER = threading.local()
5160

5261

62+
class _Route(Enum):
63+
"""How a call is dispatched after reading the persisted circuit state."""
64+
65+
CLOSED = "closed"
66+
PROBE = "probe"
67+
OPEN = "open"
68+
69+
5370
def _probe_owner_id() -> str:
5471
"""
5572
Return this thread's stable probe-owner identifier, minting it on first use.
@@ -130,6 +147,30 @@ def handle(self) -> Any:
130147
CircuitBreakerOpenError
131148
If the circuit is open and no callback is registered.
132149
"""
150+
route, circuit = self._route()
151+
if route is _Route.CLOSED:
152+
return self._call_closed()
153+
if route is _Route.PROBE:
154+
return self._call_probe()
155+
return self._open_response(circuit)
156+
157+
async def handle_async(self) -> Any:
158+
"""Async counterpart of :meth:`handle` for coroutine functions; same routing and bookkeeping."""
159+
route, circuit = self._route()
160+
if route is _Route.CLOSED:
161+
return await self._call_closed_async()
162+
if route is _Route.PROBE:
163+
return await self._call_probe_async()
164+
return await self._open_response_async(circuit)
165+
166+
def _route(self) -> tuple[_Route, CircuitInfo]:
167+
"""
168+
Read the persisted state and decide how this call runs.
169+
170+
Shared by the sync and async paths so the state machine lives in one place. Side
171+
effects (probe election, OPEN to HALF_OPEN notification) happen here; only the
172+
protected call itself differs between the two paths.
173+
"""
133174
record = self.persistence_store.get_state(self.name)
134175

135176
if record.state == CircuitState.CLOSED:
@@ -141,35 +182,59 @@ def handle(self) -> Any:
141182
if prev is not None and prev != CircuitState.CLOSED:
142183
_LOCAL_FAILURES[self.name] = 0
143184
_LAST_OBSERVED_STATE[self.name] = CircuitState.CLOSED
144-
return self._call_closed()
185+
route = _Route.CLOSED
186+
elif record.state == CircuitState.OPEN:
187+
route = self._route_open(record)
188+
else:
189+
route = self._route_half_open(record)
145190

146-
if record.state == CircuitState.OPEN:
147-
with _COUNTERS_LOCK:
148-
_LAST_OBSERVED_STATE[self.name] = CircuitState.OPEN
149-
# ``opened_at`` may legitimately be 0 (epoch); treat only None as missing.
150-
opened_at = record.opened_at if record.opened_at is not None else self._now()
151-
if self._now() >= opened_at + self.config.recovery_timeout:
152-
# Recovery window elapsed: try to become the single prober.
153-
if self.persistence_store.try_acquire_half_open(self.name, _probe_owner_id(), opened_at):
154-
self._reset_probe_successes()
155-
self._notify(CircuitState.OPEN, CircuitState.HALF_OPEN, opened_at=opened_at)
156-
return self._call_probe()
157-
return self._open_response(record.to_circuit_info())
158-
159-
# HALF_OPEN: only the thread that owns the probe lock runs.
191+
return route, record.to_circuit_info()
192+
193+
def _route_open(self, record: CircuitStateRecord) -> _Route:
194+
"""OPEN: reject, or elect this worker as the single prober once the recovery window elapsed."""
195+
with _COUNTERS_LOCK:
196+
_LAST_OBSERVED_STATE[self.name] = CircuitState.OPEN
197+
# ``opened_at`` may legitimately be 0 (epoch); treat only None as missing.
198+
opened_at = record.opened_at if record.opened_at is not None else self._now()
199+
if self._now() < opened_at + self.config.recovery_timeout or not self._claim_probe():
200+
return _Route.OPEN
201+
# Recovery window elapsed: try to become the single prober.
202+
if self.persistence_store.try_acquire_half_open(self.name, _probe_owner_id(), opened_at):
203+
self._reset_probe_successes()
204+
self._notify(CircuitState.OPEN, CircuitState.HALF_OPEN, opened_at=opened_at)
205+
return _Route.PROBE
206+
self._release_probe()
207+
return _Route.OPEN
208+
209+
def _route_half_open(self, record: CircuitStateRecord) -> _Route:
210+
"""HALF_OPEN: only the probe owner runs, one probe at a time; expired leases can be taken over."""
160211
with _COUNTERS_LOCK:
161212
_LAST_OBSERVED_STATE[self.name] = CircuitState.HALF_OPEN
162213
if record.half_open_owner == _probe_owner_id():
163-
return self._call_probe()
214+
# Owner thread: one probe at a time, even when coroutines run concurrently.
215+
return _Route.PROBE if self._claim_probe() else _Route.OPEN
164216

165217
# If the probe lease has expired (owner recycled mid-probe), take over.
166-
if record.probe_lease_expiry is not None and self._now() >= record.probe_lease_expiry:
218+
lease_expired = record.probe_lease_expiry is not None and self._now() >= record.probe_lease_expiry
219+
if lease_expired and self._claim_probe():
167220
logger.debug("Circuit '%s' probe lease expired; attempting takeover.", self.name)
168221
if self.persistence_store.try_acquire_half_open(self.name, _probe_owner_id(), record.opened_at or 0):
169222
self._reset_probe_successes()
170-
return self._call_probe()
223+
return _Route.PROBE
224+
self._release_probe()
225+
return _Route.OPEN
171226

172-
return self._open_response(record.to_circuit_info())
227+
def _claim_probe(self) -> bool:
228+
"""Mark a probe as in flight for this circuit; False when one is already running in this process."""
229+
with _COUNTERS_LOCK:
230+
if _PROBES_IN_FLIGHT.get(self.name) == os.getpid():
231+
return False
232+
_PROBES_IN_FLIGHT[self.name] = os.getpid()
233+
return True
234+
235+
def _release_probe(self) -> None:
236+
with _COUNTERS_LOCK:
237+
_PROBES_IN_FLIGHT.pop(self.name, None)
173238

174239
def _reset_probe_successes(self) -> None:
175240
"""
@@ -188,57 +253,94 @@ def _call_closed(self) -> Any:
188253
try:
189254
result = self.function(*self.fn_args, **self.fn_kwargs)
190255
except Exception as exc:
191-
if not self.config.counts_as_failure(exc):
192-
raise
193-
# Increment and reset atomically so exactly one thread observes the threshold
194-
# crossing; racing threads would otherwise lose increments (tripping late) or
195-
# each persist the same transition.
196-
with _COUNTERS_LOCK:
197-
failures = _LOCAL_FAILURES.get(self.name, 0) + 1
198-
tripped = failures >= self.config.failure_threshold
199-
_LOCAL_FAILURES[self.name] = 0 if tripped else failures
200-
if tripped:
201-
logger.debug("Circuit '%s' tripping CLOSED to OPEN after %d failures.", self.name, failures)
202-
opened_at = self._now()
203-
self._safe_persist(
204-
self.persistence_store.save_open,
205-
self.name,
206-
failure_count=failures,
207-
opened_at=opened_at,
208-
)
209-
self._notify(CircuitState.CLOSED, CircuitState.OPEN, opened_at=opened_at)
256+
self._on_closed_failure(exc)
210257
raise
211-
else:
212-
with _COUNTERS_LOCK:
213-
_LOCAL_FAILURES[self.name] = 0
214-
return result
258+
self._on_closed_success()
259+
return result
260+
261+
async def _call_closed_async(self) -> Any:
262+
"""Await the protected coroutine while the circuit is closed, tracking failures."""
263+
try:
264+
result = await self.function(*self.fn_args, **self.fn_kwargs)
265+
except Exception as exc:
266+
self._on_closed_failure(exc)
267+
raise
268+
self._on_closed_success()
269+
return result
270+
271+
def _on_closed_failure(self, exc: Exception) -> None:
272+
"""Count a qualifying failure while closed and trip the circuit at the threshold."""
273+
if not self.config.counts_as_failure(exc):
274+
return
275+
# Increment and reset atomically so exactly one thread observes the threshold
276+
# crossing; racing threads would otherwise lose increments (tripping late) or
277+
# each persist the same transition.
278+
with _COUNTERS_LOCK:
279+
failures = _LOCAL_FAILURES.get(self.name, 0) + 1
280+
tripped = failures >= self.config.failure_threshold
281+
_LOCAL_FAILURES[self.name] = 0 if tripped else failures
282+
if tripped:
283+
logger.debug("Circuit '%s' tripping CLOSED to OPEN after %d failures.", self.name, failures)
284+
opened_at = self._now()
285+
self._safe_persist(
286+
self.persistence_store.save_open,
287+
self.name,
288+
failure_count=failures,
289+
opened_at=opened_at,
290+
)
291+
self._notify(CircuitState.CLOSED, CircuitState.OPEN, opened_at=opened_at)
292+
293+
def _on_closed_success(self) -> None:
294+
with _COUNTERS_LOCK:
295+
_LOCAL_FAILURES[self.name] = 0
215296

216297
def _call_probe(self) -> Any:
217298
"""Run a probe during half-open, closing or reopening based on the outcome."""
218299
try:
219300
result = self.function(*self.fn_args, **self.fn_kwargs)
220301
except Exception as exc:
221-
if not self.config.counts_as_failure(exc):
222-
raise
223-
logger.debug("Circuit '%s' probe failed; reopening.", self.name)
224-
opened_at = self._now()
225-
self._safe_persist(self.persistence_store.save_reopen, self.name, opened_at=opened_at)
226-
with _COUNTERS_LOCK:
227-
_LOCAL_SUCCESSES[self.name] = 0
228-
self._notify(CircuitState.HALF_OPEN, CircuitState.OPEN, opened_at=opened_at)
302+
self._on_probe_failure(exc)
229303
raise
230-
else:
231-
with _COUNTERS_LOCK:
232-
successes = _LOCAL_SUCCESSES.get(self.name, 0) + 1
233-
closed = successes >= self.config.success_threshold
234-
_LOCAL_SUCCESSES[self.name] = 0 if closed else successes
235-
if closed:
236-
_LOCAL_FAILURES[self.name] = 0
304+
finally:
305+
self._release_probe()
306+
self._on_probe_success()
307+
return result
308+
309+
async def _call_probe_async(self) -> Any:
310+
"""Await a probe during half-open, closing or reopening based on the outcome."""
311+
try:
312+
result = await self.function(*self.fn_args, **self.fn_kwargs)
313+
except Exception as exc:
314+
self._on_probe_failure(exc)
315+
raise
316+
finally:
317+
self._release_probe()
318+
self._on_probe_success()
319+
return result
320+
321+
def _on_probe_failure(self, exc: Exception) -> None:
322+
"""A qualifying probe failure reopens the circuit."""
323+
if not self.config.counts_as_failure(exc):
324+
return
325+
logger.debug("Circuit '%s' probe failed; reopening.", self.name)
326+
opened_at = self._now()
327+
self._safe_persist(self.persistence_store.save_reopen, self.name, opened_at=opened_at)
328+
with _COUNTERS_LOCK:
329+
_LOCAL_SUCCESSES[self.name] = 0
330+
self._notify(CircuitState.HALF_OPEN, CircuitState.OPEN, opened_at=opened_at)
331+
332+
def _on_probe_success(self) -> None:
333+
"""Count a probe success and close the circuit at the threshold."""
334+
with _COUNTERS_LOCK:
335+
successes = _LOCAL_SUCCESSES.get(self.name, 0) + 1
336+
closed = successes >= self.config.success_threshold
337+
_LOCAL_SUCCESSES[self.name] = 0 if closed else successes
237338
if closed:
238-
logger.debug("Circuit '%s' closing after %d probe successes.", self.name, successes)
239-
self._safe_persist(self.persistence_store.save_closed, self.name)
240-
self._notify(CircuitState.HALF_OPEN, CircuitState.CLOSED)
241-
return result
339+
_LOCAL_FAILURES[self.name] = 0
340+
if closed:
341+
logger.debug("Circuit '%s' closing after %d probe successes.", self.name, successes)
342+
self._safe_persist(self.persistence_store.save_closed, self.name)
343+
self._notify(CircuitState.HALF_OPEN, CircuitState.CLOSED)
242344

243345
def _safe_persist(self, fn: Callable, *args: Any, **kwargs: Any) -> None:
244346
"""
@@ -271,6 +373,13 @@ def _open_response(self, circuit: CircuitInfo) -> Any:
271373
circuit=circuit,
272374
)
273375

376+
async def _open_response_async(self, circuit: CircuitInfo) -> Any:
377+
"""Produce the response for an open circuit, awaiting an async ``on_circuit_open`` callback."""
378+
response = self._open_response(circuit)
379+
if inspect.isawaitable(response):
380+
return await response
381+
return response
382+
274383
def _notify(self, from_state: CircuitState, to_state: CircuitState, opened_at: int | None = None) -> None:
275384
"""
276385
Fire the ``on_transition`` hook for a state change.

‎aws_lambda_powertools/utilities/circuit_breaker/circuit_breaker.py‎

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from __future__ import annotations
66

77
import functools
8+
import inspect
89
import logging
910
import os
1011
import warnings
@@ -90,19 +91,20 @@ def charge(order: dict) -> dict:
9091
config = config or CircuitBreakerConfig()
9192

9293
def decorator(function: Callable) -> Callable:
93-
@functools.wraps(function)
94-
def wrapper(*args, **kwargs) -> Any:
94+
def disabled() -> bool:
9595
# Skip the circuit entirely when disabled (development only).
96-
if strtobool(os.getenv(constants.CIRCUIT_BREAKER_DISABLED_ENV, "false")):
97-
warnings.warn(
98-
message="Disabling the circuit breaker is intended for development environments only "
99-
"and should not be used in production.",
100-
category=PowertoolsUserWarning,
101-
stacklevel=2,
102-
)
103-
return function(*args, **kwargs)
96+
if not strtobool(os.getenv(constants.CIRCUIT_BREAKER_DISABLED_ENV, "false")):
97+
return False
98+
warnings.warn(
99+
message="Disabling the circuit breaker is intended for development environments only "
100+
"and should not be used in production.",
101+
category=PowertoolsUserWarning,
102+
stacklevel=3,
103+
)
104+
return True
104105

105-
handler = CircuitBreakerHandler(
106+
def build_handler(args: tuple, kwargs: dict) -> CircuitBreakerHandler:
107+
return CircuitBreakerHandler(
106108
function=function,
107109
name=name,
108110
config=config,
@@ -112,7 +114,22 @@ def wrapper(*args, **kwargs) -> Any:
112114
function_args=args,
113115
function_kwargs=kwargs,
114116
)
115-
return handler.handle()
117+
118+
if inspect.iscoroutinefunction(function):
119+
120+
@functools.wraps(function)
121+
async def async_wrapper(*args, **kwargs) -> Any:
122+
if disabled():
123+
return await function(*args, **kwargs)
124+
return await build_handler(args, kwargs).handle_async()
125+
126+
return async_wrapper
127+
128+
@functools.wraps(function)
129+
def wrapper(*args, **kwargs) -> Any:
130+
if disabled():
131+
return function(*args, **kwargs)
132+
return build_handler(args, kwargs).handle()
116133

117134
return wrapper
118135

‎docs/utilities/circuit_breaker.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ There is no wrapper type to inspect. The contract is:
121121
| **Open**, `on_circuit_open` set | Whatever the callback returns |
122122
| **Open**, no callback | Raises `CircuitBreakerOpenError` (with the `CircuitInfo` attached) |
123123

124+
### Protecting async functions
125+
126+
`@circuit_breaker` also wraps `async def` functions. The coroutine is awaited inside the circuit, so failures count towards `failure_threshold`, probes run on the awaited result, and an `on_circuit_open` callback can be a coroutine function too. Call it with `await` as you normally would.
127+
128+
=== "working_with_async_functions.py"
129+
130+
```python hl_lines="17 22-28 29"
131+
--8<-- "examples/circuit_breaker/src/working_with_async_functions.py"
132+
```
133+
124134
## Handling an open circuit
125135

126136
### With a callback
@@ -181,6 +191,9 @@ After `recovery_timeout` seconds, the circuit moves to `HALF_OPEN` and elects a
181191
thread, so the single-prober guarantee spans threads as well as environments, and the in-memory failure counter is
182192
synchronized. Single-threaded functions (the normal Lambda model) are unaffected.
183193

194+
The same holds for coroutines: concurrent `await`s of the same circuit in one event loop run a single probe at a
195+
time, and the others get the open response.
196+
184197
Probe ownership belongs to the thread that won the election. If that thread never runs the circuit again (for
185198
example, a thread-per-request worker pool), recovery waits for the probe lease to expire before another thread or
186199
environment takes over.

0 commit comments

Comments
 (0)