Skip to content

Commit 9f547d8

Browse files
authored
Merge branch 'develop' into fix/circuit-breaker-thread-safety
2 parents 36e29e4 + bc78b7f commit 9f547d8

4 files changed

Lines changed: 115 additions & 10 deletions

File tree

‎aws_lambda_powertools/utilities/circuit_breaker_alpha/config.py‎

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44

55
from __future__ import annotations
66

7+
from typing import TYPE_CHECKING
8+
79
from aws_lambda_powertools.utilities.circuit_breaker_alpha.exceptions import CircuitBreakerConfigError
810

11+
if TYPE_CHECKING:
12+
from collections.abc import Iterable
13+
914

1015
class 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.

‎aws_lambda_powertools/utilities/circuit_breaker_alpha/persistence/dynamodb.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from botocore.exceptions import ClientError
1616

1717
from aws_lambda_powertools.shared import constants, user_agent
18+
from aws_lambda_powertools.utilities.circuit_breaker_alpha.exceptions import CircuitBreakerConfigError
1819
from aws_lambda_powertools.utilities.circuit_breaker_alpha.persistence.base import (
1920
CircuitBreakerExistingLockError,
2021
CircuitBreakerPersistenceLayer,
@@ -105,7 +106,9 @@ def __init__(
105106
user_agent.register_feature_to_client(client=self.client, feature="circuit_breaker")
106107

107108
if sort_key_attr == key_attr:
108-
raise ValueError(f"key_attr [{key_attr}] and sort_key_attr [{sort_key_attr}] cannot be the same!")
109+
raise CircuitBreakerConfigError(
110+
f"key_attr [{key_attr}] and sort_key_attr [{sort_key_attr}] cannot be the same!",
111+
)
109112

110113
if static_pk_value is not None and sort_key_attr is None:
111114
warnings.warn(

‎tests/functional/circuit_breaker_alpha/test_dynamodb_persistence.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from botocore.stub import Stubber
77

88
from aws_lambda_powertools.utilities.circuit_breaker_alpha.config import CircuitBreakerConfig
9+
from aws_lambda_powertools.utilities.circuit_breaker_alpha.exceptions import CircuitBreakerConfigError
910
from aws_lambda_powertools.utilities.circuit_breaker_alpha.persistence import (
1011
CircuitBreakerDynamoDBPersistence,
1112
)
@@ -370,7 +371,7 @@ def test_composite_item_to_record_reads_name_from_sort_key(composite_persistence
370371

371372
def test_sort_key_equal_to_key_attr_raises():
372373
client = boto3.client("dynamodb", config=Config(region_name="us-east-1"))
373-
with pytest.raises(ValueError, match="cannot be the same"):
374+
with pytest.raises(CircuitBreakerConfigError, match="cannot be the same"):
374375
CircuitBreakerDynamoDBPersistence(
375376
table_name=TABLE_NAME,
376377
boto3_client=client,

‎tests/unit/circuit_breaker_alpha/test_config_and_states.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,51 @@ def test_counts_as_failure_denylist():
7272
assert config.counts_as_failure(KeyError()) is True
7373

7474

75+
def test_config_normalizes_handled_exceptions_list_to_tuple():
76+
config = CircuitBreakerConfig(handled_exceptions=[TimeoutError, ConnectionError])
77+
assert config.handled_exceptions == (TimeoutError, ConnectionError)
78+
# The reported bug: a list must not break counts_as_failure when the circuit evaluates a failure.
79+
assert config.counts_as_failure(TimeoutError()) is True
80+
assert config.counts_as_failure(ValueError()) is False
81+
82+
83+
def test_config_normalizes_single_exception_type_to_tuple():
84+
config = CircuitBreakerConfig(handled_exceptions=ValueError)
85+
assert config.handled_exceptions == (ValueError,)
86+
assert config.counts_as_failure(ValueError()) is True
87+
88+
89+
def test_config_normalizes_ignored_exceptions_list_to_tuple():
90+
config = CircuitBreakerConfig(ignored_exceptions=[ValueError])
91+
assert config.ignored_exceptions == (ValueError,)
92+
assert config.counts_as_failure(ValueError()) is False
93+
assert config.counts_as_failure(KeyError()) is True
94+
95+
96+
def test_config_normalizes_iterator_of_exceptions():
97+
config = CircuitBreakerConfig(handled_exceptions=iter((TimeoutError, KeyError)))
98+
assert config.handled_exceptions == (TimeoutError, KeyError)
99+
100+
101+
@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
102+
def test_config_rejects_non_exception_type_in_list(field):
103+
with pytest.raises(CircuitBreakerConfigError, match="only exception types"):
104+
CircuitBreakerConfig(**{field: ["not-an-exception"]})
105+
106+
107+
@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
108+
@pytest.mark.parametrize("value", [5, "ValueError"])
109+
def test_config_rejects_non_iterable_or_str_exceptions(field, value):
110+
with pytest.raises(CircuitBreakerConfigError, match="iterable of exception types"):
111+
CircuitBreakerConfig(**{field: value})
112+
113+
114+
@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
115+
def test_config_rejects_empty_exceptions(field):
116+
with pytest.raises(CircuitBreakerConfigError, match="at least one exception type"):
117+
CircuitBreakerConfig(**{field: []})
118+
119+
75120
def test_open_error_carries_circuit_info():
76121
info = CircuitInfo(name="payment", state=CircuitState.OPEN, failure_count=5, opened_at=123)
77122
error = CircuitBreakerOpenError("open", circuit=info)

0 commit comments

Comments
 (0)