Skip to content

Commit 0c311b7

Browse files
removed loguru, added more namespaces
1 parent d9afa9e commit 0c311b7

File tree

9 files changed

+148
-107
lines changed

9 files changed

+148
-107
lines changed

circuit_breaker_box/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from circuit_breaker_box.circuit_breaker_base import BaseCircuitBreaker
2+
from circuit_breaker_box.circuit_breaker_in_memory import CircuitBreakerInMemory
3+
from circuit_breaker_box.circuit_breaker_redis import CircuitBreakerRedis
4+
from circuit_breaker_box.errors import BaseCircuitBreakerError, HostUnavailableError
5+
6+
7+
__all__ = [
8+
"BaseCircuitBreaker",
9+
"BaseCircuitBreakerError",
10+
"CircuitBreakerInMemory",
11+
"CircuitBreakerRedis",
12+
"HostUnavailableError",
13+
]

circuit_breaker_box/circuit_breaker.py

Lines changed: 0 additions & 96 deletions
This file was deleted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import abc
2+
import dataclasses
3+
import typing
4+
5+
6+
@dataclasses.dataclass(kw_only=True, slots=True)
7+
class BaseCircuitBreaker:
8+
reset_timeout_in_seconds: int
9+
max_failure_count: int
10+
11+
@abc.abstractmethod
12+
async def increment_failures_count(self, host: str) -> None: ...
13+
14+
@abc.abstractmethod
15+
async def is_host_available(self, host: str) -> bool: ...
16+
17+
@abc.abstractmethod
18+
async def raise_host_unavailable_error(self, host: str) -> typing.NoReturn: ...
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import dataclasses
2+
import logging
3+
import typing
4+
5+
from cachetools import TTLCache
6+
7+
from circuit_breaker_box import BaseCircuitBreaker, errors
8+
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@dataclasses.dataclass(kw_only=True, slots=True)
14+
class CircuitBreakerInMemory(BaseCircuitBreaker):
15+
max_cache_size: int
16+
cache_hosts_with_errors: TTLCache[typing.Any, typing.Any] = dataclasses.field(init=False)
17+
18+
def __post_init__(self) -> None:
19+
self.cache_hosts_with_errors: TTLCache[typing.Any, typing.Any] = TTLCache(
20+
maxsize=self.max_cache_size, ttl=self.reset_timeout_in_seconds
21+
)
22+
23+
async def increment_failures_count(self, host: str) -> None:
24+
if host in self.cache_hosts_with_errors:
25+
self.cache_hosts_with_errors[host] = self.cache_hosts_with_errors[host] + 1
26+
logger.debug("Incremented error for host: '%s', errors: %s", host, self.cache_hosts_with_errors[host])
27+
else:
28+
self.cache_hosts_with_errors[host] = 1
29+
logger.debug("Added host: %s, errors: %s", host, self.cache_hosts_with_errors[host])
30+
31+
async def is_host_available(self, host: str) -> bool:
32+
failures_count: typing.Final = int(self.cache_hosts_with_errors.get(host) or 0)
33+
is_available: bool = failures_count <= self.max_failure_count
34+
logger.debug(
35+
"host: '%s', failures_count: '%s', self.max_failure_count: '%s', is_available: '%s'",
36+
host,
37+
failures_count,
38+
self.max_failure_count,
39+
is_available,
40+
)
41+
return is_available
42+
43+
async def raise_host_unavailable_error(self, host: str) -> typing.NoReturn:
44+
msg = f"Host {host} is unavailable"
45+
raise errors.HostUnavailableError(msg)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import dataclasses
2+
import logging
3+
import typing
4+
5+
import tenacity
6+
from redis import asyncio as aioredis
7+
from redis.exceptions import ConnectionError as RedisConnectionError
8+
from redis.exceptions import WatchError
9+
10+
from circuit_breaker_box import BaseCircuitBreaker, errors
11+
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
def _log_attempt(retry_state: tenacity.RetryCallState) -> None:
17+
logger.info("Attempt redis_reconnect: %s", retry_state)
18+
19+
20+
@dataclasses.dataclass(kw_only=True, slots=True)
21+
class CircuitBreakerRedis(BaseCircuitBreaker):
22+
redis_connection: "aioredis.Redis[str]"
23+
24+
@tenacity.retry(
25+
stop=tenacity.stop_after_attempt(3),
26+
wait=tenacity.wait_exponential_jitter(),
27+
retry=tenacity.retry_if_exception_type((WatchError, RedisConnectionError, ConnectionResetError, TimeoutError)),
28+
reraise=True,
29+
before=_log_attempt,
30+
)
31+
async def increment_failures_count(self, host: str) -> None:
32+
redis_key: typing.Final = f"circuit-breaker-{host}"
33+
increment_result: int = await self.redis_connection.incr(redis_key)
34+
logger.debug("Incremented error for redis_key: %s, increment_result: %s", redis_key, increment_result)
35+
is_expire_set: bool = await self.redis_connection.expire(redis_key, self.reset_timeout_in_seconds)
36+
logger.debug("Expire set for redis_key: %s, is_expire_set: %s", redis_key, is_expire_set)
37+
38+
@tenacity.retry(
39+
stop=tenacity.stop_after_attempt(3),
40+
wait=tenacity.wait_exponential_jitter(),
41+
retry=tenacity.retry_if_exception_type((WatchError, RedisConnectionError, ConnectionResetError, TimeoutError)),
42+
reraise=True,
43+
before=_log_attempt,
44+
)
45+
async def is_host_available(self, host: str) -> bool:
46+
failures_count: typing.Final = int(await self.redis_connection.get(f"circuit-breaker-{host}") or 0)
47+
is_available: bool = failures_count <= self.max_failure_count
48+
logger.debug(
49+
"host: '%s', failures_count: '%s', self.max_failure_count: '%s', is_available: '%s'",
50+
host,
51+
failures_count,
52+
self.max_failure_count,
53+
is_available,
54+
)
55+
return is_available
56+
57+
async def raise_host_unavailable_error(self, host: str) -> typing.NoReturn:
58+
msg = f"Host {host} is unavailable"
59+
raise errors.HostUnavailableError(msg)

examples/example_circuit_breaker.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import asyncio
2+
import logging
23
import typing
34

45
import fastapi
56

6-
from circuit_breaker_box.circuit_breaker import CircuitBreakerInMemory
7+
from circuit_breaker_box import CircuitBreakerInMemory
78

89

910
HTTP_MAX_TRIES = 4
1011
MAX_CACHE_SIZE = 256
1112
CIRCUIT_BREAKER_MAX_FAILURE_COUNT = 1
1213
RESET_TIMEOUT_IN_SECONDS = 10
13-
SOME_HOST = "some_host"
14+
SOME_HOST = "http://example.com/"
1415

1516

1617
class CustomCircuitBreakerInMemory(CircuitBreakerInMemory):
@@ -19,6 +20,7 @@ async def raise_host_unavailable_error(self, host: str) -> typing.NoReturn:
1920

2021

2122
async def main() -> None:
23+
logging.basicConfig(level=logging.DEBUG)
2224
circuit_breaker = CustomCircuitBreakerInMemory(
2325
reset_timeout_in_seconds=RESET_TIMEOUT_IN_SECONDS,
2426
max_failure_count=CIRCUIT_BREAKER_MAX_FAILURE_COUNT,

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ requires-python = ">=3.10,<4"
66
dependencies = [
77
"cachetools",
88
"httpx",
9-
"loguru",
109
"redis",
1110
"tenacity",
1211
]
@@ -79,6 +78,6 @@ run.concurrency = ["thread"]
7978
report.exclude_also = ["if typing.TYPE_CHECKING:"]
8079

8180
[project.urls]
82-
Issues = "https://github.com/community-of-python/circuit-breaker-box/issues"
8381
Repository = "https://github.com/community-of-python/circuit-breaker-box"
82+
Issues = "https://github.com/community-of-python/circuit-breaker-box/issues"
8483
Changelogs = "https://github.com/community-of-python/circuit-breaker-box/releases"

tests/conftest.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import dataclasses
2+
import logging
23
import typing
34

45
import pytest
5-
from loguru import logger
66
from redis import asyncio as aioredis
77

8-
from circuit_breaker_box.circuit_breaker import CircuitBreakerInMemory, CircuitBreakerRedis
8+
from circuit_breaker_box import CircuitBreakerInMemory, CircuitBreakerRedis
99
from examples.example_circuit_breaker import CustomCircuitBreakerInMemory
1010

1111

12+
logger = logging.getLogger(__name__)
13+
1214
HTTP_MAX_TRIES = 4
1315
MAX_CACHE_SIZE = 256
1416
CIRCUIT_BREAKER_MAX_FAILURE_COUNT = 1
@@ -21,16 +23,16 @@ class TestRedisConnection(aioredis.Redis): # type: ignore[type-arg]
2123
errors: int = 0
2224

2325
async def incr(self, host: str | bytes, amount: int = 1) -> int:
24-
logger.debug(f"host: {host!s}, amount: {amount}")
26+
logger.debug("host: %s, amount: %d{amount}", host, amount)
2527
self.errors = self.errors + 1
2628
return amount + 1
2729

2830
async def expire(self, *args: typing.Any, **kwargs: typing.Any) -> bool: # noqa: ANN401
29-
logger.debug(f"{args=}, {kwargs=}")
31+
logger.debug(args, kwargs)
3032
return True
3133

3234
async def get(self, host: str | bytes) -> int:
33-
logger.debug(f"host: {host!s}, errors: {self.errors}")
35+
logger.debug("host: %s, errors: %s", host, self.errors)
3436
return self.errors
3537

3638

tests/test_circuit_breaker.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import fastapi
22
import pytest
33

4-
from circuit_breaker_box import errors
5-
from circuit_breaker_box.circuit_breaker import CircuitBreakerInMemory, CircuitBreakerRedis
4+
from circuit_breaker_box import CircuitBreakerInMemory, CircuitBreakerRedis, errors
65
from examples.example_circuit_breaker import CustomCircuitBreakerInMemory
76
from tests.conftest import HTTP_MAX_TRIES, SOME_HOST
87

0 commit comments

Comments
 (0)