Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Auto retry mechanism #21

Merged
merged 1 commit into from
Sep 17, 2023
Merged
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
32 changes: 32 additions & 0 deletions docs/core-concepts/auto-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Auto retry

## What is auto retry?

Auto retry is a feature that allows you to automatically retry a failed HTTP request. This is useful when you want to retry a request that failed due to a network error or a timeout.

## How does it work?

When you make a request, the request is sent to the server. If the server responds with an error, the request is retried. If the server responds with a success, the request is not retried.

## How do I use it?

To use auto retry, you need to decorate your endpoint method with `@retry`.

It takes four arguments:

- `max_retries`: The maximum number of times to retry the request.
- `backoff_factor`: The backoff factor to use when retrying the request.
- `exceptions`: The list of status codes to retry.
- `delay`: The delay between retries.


```python
from declarativex import retry, http, TimeoutException


@retry(max_retries=3, backoff_factor=0.5, exceptions=(TimeoutException,), delay=0.5)
@http("GET", "/status/500", base_url="https://httpbin.org", timeout=0.1)
def get():
pass
```

1 change: 1 addition & 0 deletions src/declarativex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@
from .methods import http
from .middlewares import Middleware
from .rate_limiter import rate_limiter
from .retry import retry

__version__ = "v1.0.0"
6 changes: 3 additions & 3 deletions src/declarativex/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
from .executors import AsyncExecutor, SyncExecutor
from .middlewares import Middleware
from .models import ClientConfiguration, EndpointConfiguration
from .utils import ReturnType
from .utils import ReturnType, DECLARED_MARK


def http(
method: str,
path: str,
*,
timeout: Optional[int] = None,
timeout: Optional[float] = None,
base_url: str = "",
default_query_params: Optional[Dict[str, Any]] = None,
default_headers: Optional[Dict[str, str]] = None,
Expand Down Expand Up @@ -59,7 +59,7 @@ def inner(*args: Any, **kwargs: Any):
endpoint_configuration=endpoint_configuration
).execute(func, *args, **kwargs)

setattr(inner, "_declarativex", True)
setattr(inner, DECLARED_MARK, True)
inner.__annotations__["return"] = (
inspect.signature(func).return_annotation
)
Expand Down
5 changes: 2 additions & 3 deletions src/declarativex/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
from typing import (
Callable,
TYPE_CHECKING,
TypeVar,
)

from .utils import ReturnType

if TYPE_CHECKING:
from .models import RawRequest

ReturnType = TypeVar("ReturnType")


class Signature(abc.ABCMeta):
expected_signature = {
Expand Down
43 changes: 13 additions & 30 deletions src/declarativex/rate_limiter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import asyncio
import time
from functools import wraps
from typing import TypeVar, Callable, Union, Awaitable
from typing import Callable, Union, Awaitable

from .exceptions import RateLimitExceeded

ReturnType = TypeVar("ReturnType")
from .utils import ReturnType, DeclaredDecorator


class Bucket:
Expand All @@ -30,14 +28,14 @@ def refill(self):
self.last_time_token_added = 0.0


class rate_limiter:
class rate_limiter(DeclaredDecorator):
def __init__(self, max_calls: int, interval: float, reject: bool = False):
self._bucket = Bucket(max_calls, interval)
self._reject = reject
self._loop = asyncio.get_event_loop()
self._lock = asyncio.Lock()

async def decorate_async(
async def _decorate_async(
self, func: Callable[..., Awaitable[ReturnType]], *args, **kwargs
) -> ReturnType:
async with self._lock:
Expand Down Expand Up @@ -71,7 +69,7 @@ async def decorate_async(
# if the bucket is empty, we have to wait
self._bucket.token_bucket -= 1.0

def decorate_sync(
def _decorate_sync(
self, func: Callable[..., ReturnType], *args, **kwargs
) -> ReturnType:
try:
Expand Down Expand Up @@ -99,32 +97,17 @@ def decorate_sync(
# if the bucket is empty, we have to wait
self._bucket.token_bucket -= 1.0

def decorate_class(self, cls: type) -> type:
for attr_name, attr_value in cls.__dict__.items():
if hasattr(attr_value, "_declarativex"):
setattr(cls, attr_name, self(attr_value))
setattr(cls, "_rate_limiter_bucket", self._bucket)
def refill(self):
self._bucket.refill()

def _decorate_class(self, cls: type) -> type:
cls = super()._decorate_class(cls)
setattr(cls, "refill", self.refill)
return cls

def __call__(
self, func_or_class: Union[Callable[..., ReturnType], type]
) -> Union[Callable[..., ReturnType], type]:
if isinstance(func_or_class, type):
return self.decorate_class(func_or_class)

if asyncio.iscoroutinefunction(func_or_class):

@wraps(func_or_class)
async def inner(*args, **kwargs):
return await self.decorate_async(
func_or_class, *args, **kwargs
)

else:

@wraps(func_or_class)
def inner(*args, **kwargs):
return self.decorate_sync(func_or_class, *args, **kwargs)

setattr(inner, "_rate_limiter_bucket", self._bucket)
inner = super().__call__(func_or_class)
setattr(inner, "refill", self.refill)
return inner
51 changes: 51 additions & 0 deletions src/declarativex/retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import asyncio
import time
from typing import Callable

from .utils import DeclaredDecorator


class retry(DeclaredDecorator):
def __init__(
self,
max_retries: int,
exceptions: tuple,
delay: float = 0.0,
backoff_factor: float = 1.0,
):
self._max_retries = max_retries
self._delay = delay
self._backoff_factor = backoff_factor
self._exceptions = exceptions

async def _decorate_async(
self, func: Callable, *args, **kwargs
):
retries = 0
current_delay = self._delay
while retries <= self._max_retries:
try:
return await func(*args, **kwargs)
except self._exceptions as e:
retries += 1
if retries > self._max_retries:
raise e
await asyncio.sleep(current_delay)
current_delay *= self._backoff_factor
return None # pragma: no cover

def _decorate_sync(
self, func: Callable, *args, **kwargs
):
retries = 0
current_delay = self._delay
while retries <= self._max_retries:
try:
return func(*args, **kwargs)
except self._exceptions as e:
retries += 1
if retries > self._max_retries:
raise e
time.sleep(current_delay)
current_delay *= self._backoff_factor
return None # pragma: no cover
62 changes: 61 additions & 1 deletion src/declarativex/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,65 @@
from typing import TypeVar
import abc
import asyncio
from functools import wraps
from typing import TypeVar, Callable, Union

from .exceptions import MisconfiguredException

ReturnType = TypeVar("ReturnType")
SUPPORTED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
DECLARED_MARK = "_declarativex_declared"


class DeclaredDecorator(abc.ABC):
@abc.abstractmethod
async def _decorate_async(
self, func: Callable, *args, **kwargs
):
raise NotImplementedError

@abc.abstractmethod
def _decorate_sync(
self, func: Callable, *args, **kwargs
):
raise NotImplementedError

def _decorate_class(self, cls: type) -> type:
for attr_name, attr_value in cls.__dict__.items():
if hasattr(attr_value, DECLARED_MARK):
setattr(cls, attr_name, self(attr_value))
setattr(cls, DECLARED_MARK, True)
return cls

@property
def mark(self) -> str:
return f"_{self.__class__.__name__}"

def __call__(
self, func_or_cls: Union[Callable[..., ReturnType], type]
) -> Union[Callable[..., ReturnType], type]:
if hasattr(func_or_cls, self.mark):
raise MisconfiguredException(
f"Cannot decorate function with "
f"@{self.__class__.__name__} twice"
)

if isinstance(func_or_cls, type):
return self._decorate_class(func_or_cls)

if asyncio.iscoroutinefunction(func_or_cls):

@wraps(func_or_cls)
async def inner(*args, **kwargs):
return await self._decorate_async(func_or_cls, *args, **kwargs)

else:

@wraps(func_or_cls)
def inner(*args, **kwargs):
return self._decorate_sync(func_or_cls, *args, **kwargs)

setattr(
inner, DECLARED_MARK, getattr(func_or_cls, DECLARED_MARK, False)
)
setattr(inner, self.mark, True)
return inner
Loading