44
55from __future__ import annotations
66
7+ from typing import TYPE_CHECKING
8+
79from aws_lambda_powertools .utilities .circuit_breaker_alpha .exceptions import CircuitBreakerConfigError
810
11+ if TYPE_CHECKING :
12+ from collections .abc import Iterable
13+
914
1015class CircuitBreakerConfig :
1116 """
@@ -24,12 +29,14 @@ class CircuitBreakerConfig:
2429 success_threshold : int
2530 Number of *consecutive* probe successes required to close a half-open circuit.
2631 Defaults to 3.
27- handled_exceptions : tuple[ type[Exception], ... ] | None
32+ handled_exceptions : type[Exception] | Iterable[type[Exception] ] | None
2833 Allowlist: only these exception types count as failures; anything else
29- propagates without affecting the circuit. Mutually exclusive with
34+ propagates without affecting the circuit. Accepts a single exception type or
35+ an iterable of them (normalized to a tuple). Mutually exclusive with
3036 ``ignored_exceptions``. Defaults to ``None`` (treated as ``(Exception,)``).
31- ignored_exceptions : tuple[type[Exception], ...] | None
32- Denylist: every exception counts as a failure *except* these. Mutually
37+ ignored_exceptions : type[Exception] | Iterable[type[Exception]] | None
38+ Denylist: every exception counts as a failure *except* these. Accepts a single
39+ exception type or an iterable of them (normalized to a tuple). Mutually
3340 exclusive with ``handled_exceptions``. Defaults to ``None``.
3441 local_cache_max_age : int
3542 Seconds a circuit's state is cached in the execution environment before a
@@ -38,8 +45,9 @@ class CircuitBreakerConfig:
3845 Raises
3946 ------
4047 CircuitBreakerConfigError
41- If both ``handled_exceptions`` and ``ignored_exceptions`` are provided, or a
42- numeric tunable is not a positive integer.
48+ If both ``handled_exceptions`` and ``ignored_exceptions`` are provided, a
49+ numeric tunable is not a positive integer, or an exception allowlist/denylist
50+ is empty or contains a value that is not an exception type.
4351
4452 Example
4553 -------
@@ -57,10 +65,16 @@ def __init__(
5765 failure_threshold : int = 5 ,
5866 recovery_timeout : int = 30 ,
5967 success_threshold : int = 3 ,
60- handled_exceptions : tuple [ type [Exception ], ... ] | None = None ,
61- ignored_exceptions : tuple [ type [Exception ], ... ] | None = None ,
68+ handled_exceptions : type [Exception ] | Iterable [ type [ Exception ] ] | None = None ,
69+ ignored_exceptions : type [Exception ] | Iterable [ type [ Exception ] ] | None = None ,
6270 local_cache_max_age : int = 5 ,
6371 ):
72+ # Normalize first: a single exception type or any iterable becomes a tuple, and a
73+ # bad value fails here (at construction) rather than as a cryptic isinstance
74+ # TypeError later, the first time the circuit evaluates a failure.
75+ handled_exceptions = self ._normalize_exceptions (handled_exceptions , "handled_exceptions" )
76+ ignored_exceptions = self ._normalize_exceptions (ignored_exceptions , "ignored_exceptions" )
77+
6478 self ._validate (
6579 failure_threshold = failure_threshold ,
6680 recovery_timeout = recovery_timeout ,
@@ -105,6 +119,48 @@ def _validate(
105119 f"local_cache_max_age must be a non-negative integer, got { local_cache_max_age !r} ." ,
106120 )
107121
122+ @classmethod
123+ def _normalize_exceptions (
124+ cls ,
125+ value : type [Exception ] | Iterable [type [Exception ]] | None ,
126+ field : str ,
127+ ) -> tuple [type [Exception ], ...] | None :
128+ """Coerce a single exception type or an iterable of them into a validated, non-empty tuple.
129+
130+ Runs at construction so a bad value fails immediately with a clear error, rather
131+ than as a cryptic ``isinstance`` ``TypeError`` from ``counts_as_failure`` the
132+ first time the circuit evaluates a failure (i.e. only once the dependency is
133+ already unhealthy).
134+ """
135+ if value is None :
136+ return None
137+
138+ invalid = f"{ field } must be an exception type or an iterable of exception types, got { value !r} ."
139+ # A str is iterable; reject it rather than iterate it as a sequence of characters.
140+ if isinstance (value , str ):
141+ raise CircuitBreakerConfigError (invalid )
142+
143+ if isinstance (value , type ):
144+ # ty (unlike mypy) does not narrow the union here, so it needs the ignore.
145+ exceptions : tuple [type [Exception ], ...] = (value ,) # ty: ignore[invalid-assignment]
146+ else :
147+ try :
148+ exceptions = tuple (value )
149+ except TypeError :
150+ raise CircuitBreakerConfigError (invalid ) from None
151+
152+ cls ._validate_exception_types (exceptions , field )
153+ return exceptions
154+
155+ @staticmethod
156+ def _validate_exception_types (exceptions : tuple [type [Exception ], ...], field : str ) -> None :
157+ """Require a non-empty tuple whose every element is an exception type."""
158+ if not exceptions :
159+ raise CircuitBreakerConfigError (f"{ field } must contain at least one exception type." )
160+ for exception in exceptions :
161+ if not (isinstance (exception , type ) and issubclass (exception , Exception )):
162+ raise CircuitBreakerConfigError (f"{ field } must contain only exception types, got { exception !r} ." )
163+
108164 def counts_as_failure (self , exception : Exception ) -> bool :
109165 """
110166 Decide whether an exception raised by the protected call counts as a circuit failure.
0 commit comments