Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 64 additions & 8 deletions aws_lambda_powertools/utilities/circuit_breaker_alpha/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@

from __future__ import annotations

from typing import TYPE_CHECKING

from aws_lambda_powertools.utilities.circuit_breaker_alpha.exceptions import CircuitBreakerConfigError

if TYPE_CHECKING:
from collections.abc import Iterable


class CircuitBreakerConfig:
"""
Expand All @@ -24,12 +29,14 @@ class CircuitBreakerConfig:
success_threshold : int
Number of *consecutive* probe successes required to close a half-open circuit.
Defaults to 3.
handled_exceptions : tuple[type[Exception], ...] | None
handled_exceptions : type[Exception] | Iterable[type[Exception]] | None
Allowlist: only these exception types count as failures; anything else
propagates without affecting the circuit. Mutually exclusive with
propagates without affecting the circuit. Accepts a single exception type or
an iterable of them (normalized to a tuple). Mutually exclusive with
``ignored_exceptions``. Defaults to ``None`` (treated as ``(Exception,)``).
ignored_exceptions : tuple[type[Exception], ...] | None
Denylist: every exception counts as a failure *except* these. Mutually
ignored_exceptions : type[Exception] | Iterable[type[Exception]] | None
Denylist: every exception counts as a failure *except* these. Accepts a single
exception type or an iterable of them (normalized to a tuple). Mutually
exclusive with ``handled_exceptions``. Defaults to ``None``.
local_cache_max_age : int
Seconds a circuit's state is cached in the execution environment before a
Expand All @@ -38,8 +45,9 @@ class CircuitBreakerConfig:
Raises
------
CircuitBreakerConfigError
If both ``handled_exceptions`` and ``ignored_exceptions`` are provided, or a
numeric tunable is not a positive integer.
If both ``handled_exceptions`` and ``ignored_exceptions`` are provided, a
numeric tunable is not a positive integer, or an exception allowlist/denylist
is empty or contains a value that is not an exception type.

Example
-------
Expand All @@ -57,10 +65,16 @@ def __init__(
failure_threshold: int = 5,
recovery_timeout: int = 30,
success_threshold: int = 3,
handled_exceptions: tuple[type[Exception], ...] | None = None,
ignored_exceptions: tuple[type[Exception], ...] | None = None,
handled_exceptions: type[Exception] | Iterable[type[Exception]] | None = None,
ignored_exceptions: type[Exception] | Iterable[type[Exception]] | None = None,
local_cache_max_age: int = 5,
):
# Normalize first: a single exception type or any iterable becomes a tuple, and a
# bad value fails here (at construction) rather than as a cryptic isinstance
# TypeError later, the first time the circuit evaluates a failure.
handled_exceptions = self._normalize_exceptions(handled_exceptions, "handled_exceptions")
ignored_exceptions = self._normalize_exceptions(ignored_exceptions, "ignored_exceptions")

self._validate(
failure_threshold=failure_threshold,
recovery_timeout=recovery_timeout,
Expand Down Expand Up @@ -105,6 +119,48 @@ def _validate(
f"local_cache_max_age must be a non-negative integer, got {local_cache_max_age!r}.",
)

@classmethod
def _normalize_exceptions(
cls,
value: type[Exception] | Iterable[type[Exception]] | None,
field: str,
) -> tuple[type[Exception], ...] | None:
"""Coerce a single exception type or an iterable of them into a validated, non-empty tuple.

Runs at construction so a bad value fails immediately with a clear error, rather
than as a cryptic ``isinstance`` ``TypeError`` from ``counts_as_failure`` the
first time the circuit evaluates a failure (i.e. only once the dependency is
already unhealthy).
"""
if value is None:
return None

invalid = f"{field} must be an exception type or an iterable of exception types, got {value!r}."
# A str is iterable; reject it rather than iterate it as a sequence of characters.
if isinstance(value, str):
raise CircuitBreakerConfigError(invalid)

if isinstance(value, type):
# ty (unlike mypy) does not narrow the union here, so it needs the ignore.
exceptions: tuple[type[Exception], ...] = (value,) # ty: ignore[invalid-assignment]
else:
try:
exceptions = tuple(value)
except TypeError:
raise CircuitBreakerConfigError(invalid) from None

cls._validate_exception_types(exceptions, field)
return exceptions

@staticmethod
def _validate_exception_types(exceptions: tuple[type[Exception], ...], field: str) -> None:
"""Require a non-empty tuple whose every element is an exception type."""
if not exceptions:
raise CircuitBreakerConfigError(f"{field} must contain at least one exception type.")
for exception in exceptions:
if not (isinstance(exception, type) and issubclass(exception, Exception)):
raise CircuitBreakerConfigError(f"{field} must contain only exception types, got {exception!r}.")

def counts_as_failure(self, exception: Exception) -> bool:
"""
Decide whether an exception raised by the protected call counts as a circuit failure.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from botocore.exceptions import ClientError

from aws_lambda_powertools.shared import constants, user_agent
from aws_lambda_powertools.utilities.circuit_breaker_alpha.exceptions import CircuitBreakerConfigError
from aws_lambda_powertools.utilities.circuit_breaker_alpha.persistence.base import (
CircuitBreakerExistingLockError,
CircuitBreakerPersistenceLayer,
Expand Down Expand Up @@ -105,7 +106,9 @@ def __init__(
user_agent.register_feature_to_client(client=self.client, feature="circuit_breaker")

if sort_key_attr == key_attr:
raise ValueError(f"key_attr [{key_attr}] and sort_key_attr [{sort_key_attr}] cannot be the same!")
raise CircuitBreakerConfigError(
f"key_attr [{key_attr}] and sort_key_attr [{sort_key_attr}] cannot be the same!",
)

if static_pk_value is not None and sort_key_attr is None:
warnings.warn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from botocore.stub import Stubber

from aws_lambda_powertools.utilities.circuit_breaker_alpha.config import CircuitBreakerConfig
from aws_lambda_powertools.utilities.circuit_breaker_alpha.exceptions import CircuitBreakerConfigError
from aws_lambda_powertools.utilities.circuit_breaker_alpha.persistence import (
CircuitBreakerDynamoDBPersistence,
)
Expand Down Expand Up @@ -371,7 +372,7 @@ def test_composite_item_to_record_reads_name_from_sort_key(composite_persistence

def test_sort_key_equal_to_key_attr_raises():
client = boto3.client("dynamodb", config=Config(region_name="us-east-1"))
with pytest.raises(ValueError, match="cannot be the same"):
with pytest.raises(CircuitBreakerConfigError, match="cannot be the same"):
CircuitBreakerDynamoDBPersistence(
table_name=TABLE_NAME,
boto3_client=client,
Expand Down
45 changes: 45 additions & 0 deletions tests/unit/circuit_breaker_alpha/test_config_and_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,51 @@ def test_counts_as_failure_denylist():
assert config.counts_as_failure(KeyError()) is True


def test_config_normalizes_handled_exceptions_list_to_tuple():
config = CircuitBreakerConfig(handled_exceptions=[TimeoutError, ConnectionError])
assert config.handled_exceptions == (TimeoutError, ConnectionError)
# The reported bug: a list must not break counts_as_failure when the circuit evaluates a failure.
assert config.counts_as_failure(TimeoutError()) is True
assert config.counts_as_failure(ValueError()) is False


def test_config_normalizes_single_exception_type_to_tuple():
config = CircuitBreakerConfig(handled_exceptions=ValueError)
assert config.handled_exceptions == (ValueError,)
assert config.counts_as_failure(ValueError()) is True


def test_config_normalizes_ignored_exceptions_list_to_tuple():
config = CircuitBreakerConfig(ignored_exceptions=[ValueError])
assert config.ignored_exceptions == (ValueError,)
assert config.counts_as_failure(ValueError()) is False
assert config.counts_as_failure(KeyError()) is True


def test_config_normalizes_iterator_of_exceptions():
config = CircuitBreakerConfig(handled_exceptions=iter((TimeoutError, KeyError)))
assert config.handled_exceptions == (TimeoutError, KeyError)


@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
def test_config_rejects_non_exception_type_in_list(field):
with pytest.raises(CircuitBreakerConfigError, match="only exception types"):
CircuitBreakerConfig(**{field: ["not-an-exception"]})


@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
@pytest.mark.parametrize("value", [5, "ValueError"])
def test_config_rejects_non_iterable_or_str_exceptions(field, value):
with pytest.raises(CircuitBreakerConfigError, match="iterable of exception types"):
CircuitBreakerConfig(**{field: value})


@pytest.mark.parametrize("field", ["handled_exceptions", "ignored_exceptions"])
def test_config_rejects_empty_exceptions(field):
with pytest.raises(CircuitBreakerConfigError, match="at least one exception type"):
CircuitBreakerConfig(**{field: []})


def test_open_error_carries_circuit_info():
info = CircuitInfo(name="payment", state=CircuitState.OPEN, failure_count=5, opened_at=123)
error = CircuitBreakerOpenError("open", circuit=info)
Expand Down