99from __future__ import annotations
1010
1111import datetime as dt
12+ import inspect
1213import logging
1314import os
1415import threading
1516import uuid
17+ from enum import Enum
1618from typing import TYPE_CHECKING , Any
1719
1820from aws_lambda_powertools .utilities .circuit_breaker .exceptions import CircuitBreakerOpenError
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
3033logger = logging .getLogger (__name__ )
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+
5370def _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.
0 commit comments