Skip to content

Commit

Permalink
Merge pull request #10 from migibert/feature/registry
Browse files Browse the repository at this point in the history
Add a registry to easily retrieve overall circuits state
  • Loading branch information
etimberg authored May 26, 2021
2 parents 8662748 + d5c5d10 commit 5af7f75
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 5 deletions.
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,103 @@ def function_that_can_fail():
...
```

For readiness probes (PaaS, k8s, ...) it is common to expose the different circuit breakers state.
```python
from pycircuitbreaker import circuit, CircuitBreakerRegistry


registry = CircuitBreakerRegistry()


@app.route("/ready")
def ready():
content = {
"circuits": {
cb.id: cb.state.name for cb in registry.get_circuits()
}
}
status = (
200 if len(list(registry.get_open_circuits())) == 0 else 500
)
return content, status, {"Cache-Control": "no-cache"}
```

Note that the registry is not automatically managed by the library, it is the application responsibility to register created circuit breakers.

It is also possible to reuse the same circuit breaker for different functions that rely on the same external dependency.
```python
def db_breaker(func: Callable) -> Callable:
breaker = CircuitBreaker(
breaker_id="db",
error_threshold=5,
recovery_timeout=30,
recovery_threshold=1,
exception_blacklist=[DisconnectionError, TimeoutError],
)

@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return breaker.call(func, *args, **kwargs)

return circuit_wrapper

@db_breaker
def call_to_db():
...

@db_breaker
def another_call_to_db():
...
```

### Complete usage example
```python
from pycircuitbreaker import CircuitBreakerRegistry, CircuitBreaker


registry = CircuitBreakerRegistry()


@app.route("/ready")
def ready():
content = {
"circuits": {
cb.id: cb.state.name for cb in registry.get_circuits()
}
}
status = (
200 if len(list(registry.get_open_circuits())) == 0 else 500
)
return content, status, {"Cache-Control": "no-cache"}


def db_breaker(func: Callable) -> Callable:
breaker = CircuitBreaker(
breaker_id="db",
error_threshold=5,
recovery_timeout=30,
recovery_threshold=1,
exception_blacklist=[DisconnectionError, TimeoutError],
)
registry.register(breaker)

@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return breaker.call(func, *args, **kwargs)

return circuit_wrapper


@db_breaker
def call_to_db():
...


@db_breaker
def another_call_to_db():
...
```

### Reset Strategies

By default, pycircuitbreaker operates such that a single success resets the error state of a closed breaker. This makes sense for a service that rarely fails, but in certains cases this can pose a problem. If the `error_threshold` is set to `5`, but only 4/5 external requests fail, the breaker will never open. To get around this, the [strategy setting](#strategy) may be used. By setting this to `pycircuitbreaker.CircuitBreakerStrategy.NET_ERROR`, the net error count (errors - successes) will be used to trigger the breaker.
Expand Down
4 changes: 2 additions & 2 deletions pycircuitbreaker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .pycircuitbreaker import circuit, CircuitBreaker
from .exceptions import CircuitBreakerException
from .pycircuitbreaker import circuit, CircuitBreaker, CircuitBreakerRegistry
from .exceptions import CircuitBreakerException, CircuitBreakerRegistryException
from .state import CircuitBreakerState
from .strategies import CircuitBreakerStrategy
4 changes: 4 additions & 0 deletions pycircuitbreaker/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ def __str__(self):
f"until {self._breaker.recovery_start_time.isoformat()} "
f"({self._breaker.error_count} errors, {seconds_remaining} sec remaining)"
)


class CircuitBreakerRegistryException(Exception):
pass
24 changes: 21 additions & 3 deletions pycircuitbreaker/pycircuitbreaker.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from datetime import datetime, timedelta
from functools import lru_cache, wraps
from typing import Callable, Iterable, Optional
from typing import Callable, Iterable, Optional, List
from uuid import uuid4

from .exceptions import CircuitBreakerException
from .exceptions import CircuitBreakerException, CircuitBreakerRegistryException
from .state import CircuitBreakerState
from .strategies import CircuitBreakerStrategy, get_strategy

Expand Down Expand Up @@ -160,13 +160,31 @@ def success_count(self) -> int:
return self._strategy.success_count


class CircuitBreakerRegistry:
def __init__(self) -> None:
self._registry: Dict[Any, CircuitBreaker] = {}

def register(self, circuit: CircuitBreaker) -> None:
if circuit.id in self._registry:
raise CircuitBreakerRegistryException()
self._registry[circuit.id] = circuit

def get_open_circuits(self) -> List[CircuitBreaker]:
return [
cb for cb in self._registry.values() if cb.state == CircuitBreakerState.OPEN
]

def get_circuits(self) -> List[CircuitBreaker]:
return list(self._registry.values())


def circuit(func: Callable, **kwargs) -> Callable:
"""
Decorates the supplied function with the circuit breaker pattern.
"""
if not callable(func):
raise ValueError(
f"Circuit breakers can only wrap somthing that is callable. Attempted to wrap {func}"
f"Circuit breakers can only wrap something that is callable. Attempted to wrap {func}"
)

breaker = CircuitBreaker(**kwargs)
Expand Down
89 changes: 89 additions & 0 deletions tests/test_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from pycircuitbreaker import (
CircuitBreaker,
CircuitBreakerRegistry,
CircuitBreakerRegistryException,
)

import pytest


def test_register_new_circuit_breaker():
registry = CircuitBreakerRegistry()
circuit_breaker = CircuitBreaker()

registry.register(circuit_breaker)

registered = registry.get_circuits()
assert len(registered) == 1
assert registered[0] == circuit_breaker


def test_register_existing_circuit_breaker():
registry = CircuitBreakerRegistry()
circuit_breaker = CircuitBreaker()

registry.register(circuit_breaker)

with pytest.raises(CircuitBreakerRegistryException):
registry.register(circuit_breaker)

registered = registry.get_circuits()
assert len(registered) == 1
assert registered[0] == circuit_breaker


def test_get_circuits_not_empty():
registry = CircuitBreakerRegistry()
db_circuit = CircuitBreaker(breaker_id="db")
service_circuit = CircuitBreaker(breaker_id="service")

registry.register(db_circuit)
registry.register(service_circuit)

registered = registry.get_circuits()
assert len(registered) == 2
assert db_circuit in registered
assert service_circuit in registered


def test_get_circuits_empty():
registry = CircuitBreakerRegistry()

registered = registry.get_circuits()

assert len(registered) == 0


def test_get_open_circuits_empty():
registry = CircuitBreakerRegistry()

opened = registry.get_open_circuits()

assert len(opened) == 0


def test_get_open_circuits_when_all_circuits_are_closed():
registry = CircuitBreakerRegistry()
circuit = CircuitBreaker(breaker_id="closed_breaker", error_threshold=1)
registry.register(circuit)

opened = registry.get_open_circuits()

assert len(opened) == 0


def test_get_open_circuits_when_one_circuit_is_open(error_func):
registry = CircuitBreakerRegistry()
open_circuit = CircuitBreaker(breaker_id="open_breaker", error_threshold=1)
closed_circuit = CircuitBreaker(breaker_id="closed_breaker")
registry.register(open_circuit)
registry.register(closed_circuit)

with pytest.raises(IOError):
open_circuit.call(error_func)

opened = registry.get_open_circuits()

assert len(opened) == 1
assert open_circuit in opened
assert closed_circuit not in opened

0 comments on commit 5af7f75

Please sign in to comment.