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: add exponential retry mechanism #139

Closed
wants to merge 3 commits into from
Closed
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
124 changes: 76 additions & 48 deletions docs/guides/cookbook.rst
Original file line number Diff line number Diff line change
@@ -1,48 +1,76 @@
Cookbook
========

How to change API requests timeout?
-----------------------------------

There are two ways to change the timeout of the :module:`requests` module that makes the API calls for you:

* Via an environment variable named ``NOVU_PYTHON_REQUESTS_TIMEOUT``.
You can define it in seconds and it will overloads all timeouts in this package.
* Via the API constructor directly, passing :attr:`~novu.api.base.Api.requests_timeout` in keywords arguments during the initialisation.

.. code-block:: python

EventApi(...,...,request_timeout=60).trigger_bulk(...)

How to take control over the session of ``requests``?
-----------------------------------------------------

Sometime, you may want to setup advanced usage involving the use of a ``requests`` :class:`~requests.Session`.

By default, a session is declared for every request you make to the API, but you can also
provide your own session and reuse it over and over again.

To illustrate this use case, let's take the example of setting up an automatic retry mechanism on
requests when the API responds with a 502, 503 or 504 HTTP codes.

To do this, before initializing the Event API, we will first instantiate a :class:`~requests.Session` from the
:module:`requests` module, then we will inject it into the keywords arguments of our API constructor. We finally
call the method we want to use and retry.

.. code-block:: python

from requests import Session
from requests.adapters import HTTPAdapter, Retry

from novu.api import EventApi

session = Session()
retries = Retry(total=5, backoff_factor=1, status_forcelist=[ 502, 503, 504 ])
session.mount("https://api.novu.co", HTTPAdapter(max_retries=retries))

event_api = EventApi("https://api.novu.co/api/", "<NOVU_API_TOKEN>", session=session)
event_api.trigger(
name="<YOUR_TEMPLATE_NAME>",
recipients="<YOUR_SUBSCRIBER_ID>",
payload={}, # Your Novu payload goes here
)
Cookbook
========

How to change API requests timeout?
-----------------------------------

There are two ways to change the timeout of the :module:`requests` module that makes the API calls for you:

* Via an environment variable named ``NOVU_PYTHON_REQUESTS_TIMEOUT``.
You can define it in seconds and it will overloads all timeouts in this package.
* Via the API constructor directly, passing :attr:`~novu.api.base.Api.requests_timeout` in keywords arguments during the initialisation.

.. code-block:: python

EventApi(...,...,request_timeout=60).trigger_bulk(...)

How to add retry mechanism for API call?
----------------------------------------

In order to enhance the resilience and reliability of the SDK, we provide Exponential Retry mechanism to retry failed API requests.

This means that the waiting time between each retry is multiplied by a factor that increases with each retry.
For example, the first retry may wait for 1 second, the second retry may wait for 2 seconds, the third retry may wait for 4 seconds, and so on until it stops at nth retry.
You can configure this interval using ``initial_delay``, ``wait_min``, ``wait_max``, and ``retry_max`` attributes.

This mechanism reduces the number of failed requests and prevents server overload by gradually increasing the waiting time between retries
and use an ``Idempotency-Key`` HTTP Header to ensure the idempotent processing of requests.

To enable this feature, first create a :class:`~novu.api.base.RetryConfig` class and use it in API constructor.

.. code-block:: python

from novu.api import EventApi
from novu.api.base import RetryConfig

retry_config = RetryConfig(initial_delay=1, wait_min=2, wait_max=100, retry_max=6)

event_api = EventApi("https://api.novu.co", "<NOVU_API_TOKEN>", retry_config=retry_config)
event_api.trigger(
name="<YOUR_TEMPLATE_NAME>",
recipients="<YOUR_SUBSCRIBER_ID>",
payload={}, # Your Novu payload goes here
)

How to take control over the session of ``requests``?
-----------------------------------------------------

Sometime, you may want to setup advanced usage involving the use of a ``requests`` :class:`~requests.Session`.

By default, a session is declared for every request you make to the API, but you can also
provide your own session and reuse it over and over again.

To illustrate this use case, let's take the example of setting up an automatic retry mechanism on
requests when the API responds with a 502, 503 or 504 HTTP codes.

To do this, before initializing the Event API, we will first instantiate a :class:`~requests.Session` from the
:module:`requests` module, then we will inject it into the keywords arguments of our API constructor. We finally
call the method we want to use and retry.

.. code-block:: python

from requests import Session
from requests.adapters import HTTPAdapter, Retry

from novu.api import EventApi

session = Session()
retries = Retry(total=5, backoff_factor=1, status_forcelist=[ 502, 503, 504 ])
session.mount("https://api.novu.co", HTTPAdapter(max_retries=retries))

event_api = EventApi("https://api.novu.co", "<NOVU_API_TOKEN>", session=session)
event_api.trigger(
name="<YOUR_TEMPLATE_NAME>",
recipients="<YOUR_SUBSCRIBER_ID>",
payload={}, # Your Novu payload goes here
)
54 changes: 53 additions & 1 deletion novu/api/base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""This module is used to defined an abstract class for all reusable methods to communicate with the Novu API"""
import copy
import dataclasses
import logging
import os
from json.decoder import JSONDecodeError
from typing import Generic, List, Optional, Type, TypeVar, Union
from uuid import uuid4

import requests
from tenacity import retry, stop_after_attempt, wait_chain, wait_exponential, wait_fixed

from novu.config import NovuConfig
from novu.dto.base import CamelCaseDto
Expand All @@ -17,6 +20,28 @@
_C_co = TypeVar("_C_co", bound=CamelCaseDto, covariant=True)


def retry_backoff(f):
"""Decorator to handle retry mechanism based on user configuration"""

def wrapper(*args, **kwargs):
retry_config: RetryConfig = args[0].retry_config

if retry_config:
func = retry(
wait=wait_chain(
*[wait_fixed(retry_config.initial_delay)]
+ [wait_exponential(min=retry_config.wait_min, max=retry_config.wait_max)]
),
stop=stop_after_attempt(retry_config.retry_max),
)(f)
else:
func = f

return func(*args, **kwargs)

return wrapper


class PaginationIterator(Generic[_C_co]): # pylint: disable=R0902
"""The class is a generic iterator which allow to iterate directly on result without
looking for pagination during handling.
Expand Down Expand Up @@ -79,12 +104,32 @@ def __fetch_data(self):
self.__index = 0


@dataclasses.dataclass
class RetryConfig: # pylint: disable=R0903
"""Configuration Class to add Exponential Retry Mechanism"""

initial_delay: int
"""Initial delay for first retry"""

wait_min: int
"""Minimum time to wait"""

wait_max: int
"""Maximum time to wait"""

retry_max: int
"""Maximum number of retries"""


class Api: # pylint: disable=R0903
"""Base class for all API in the Novu client"""

requests_timeout: int = 5
"""This field allow you to change the :param:`~requests.request.timeout` params which is used during API calls."""

retry_config: Optional[RetryConfig] = None
"""This field allow you to add Exponential Retry Mechanism for API calls."""

session: Optional[requests.Session] = None
"""This field allow you to use a :class:`~requests.Session` during API calls."""

Expand All @@ -93,6 +138,7 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
config = NovuConfig()
Expand All @@ -101,11 +147,16 @@ def __init__(
api_key = api_key or config.api_key

self._url = url
self._headers = {"Authorization": f"ApiKey {api_key}"}
if retry_config:
self._headers = {"Authorization": f"ApiKey {api_key}", "Idempotency-Key": str(uuid4())}
else:
self._headers = {"Authorization": f"ApiKey {api_key}"}

self.requests_timeout = requests_timeout or int(os.getenv("NOVU_PYTHON_REQUESTS_TIMEOUT", "5"))
self.retry_config = retry_config
self.session = session

@retry_backoff
def handle_request(
self,
method: str,
Expand All @@ -130,6 +181,7 @@ def handle_request(
Returns:
Return parsed response.
"""

if headers:
_headers = copy.deepcopy(self._headers)
_headers.update(copy.deepcopy(headers))
Expand Down
7 changes: 5 additions & 2 deletions novu/api/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import requests

from novu.api.base import Api
from novu.api.base import Api, RetryConfig
from novu.constants import BLUEPRINTS_ENDPOINT
from novu.dto.blueprint import BlueprintDto, GroupedBlueprintDto

Expand All @@ -17,9 +17,12 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
super().__init__(url=url, api_key=api_key, requests_timeout=requests_timeout, session=session)
super().__init__(
url=url, api_key=api_key, requests_timeout=requests_timeout, retry_config=retry_config, session=session
)

self._blueprint_url = f"{self._url}{BLUEPRINTS_ENDPOINT}"

Expand Down
7 changes: 5 additions & 2 deletions novu/api/change.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import requests

from novu.api.base import Api
from novu.api.base import Api, RetryConfig
from novu.constants import CHANGES_ENDPOINT
from novu.dto.change import ChangeDto, PaginatedChangeDto

Expand All @@ -18,9 +18,12 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
super().__init__(url=url, api_key=api_key, requests_timeout=requests_timeout, session=session)
super().__init__(
url=url, api_key=api_key, requests_timeout=requests_timeout, retry_config=retry_config, session=session
)

self._change_url = f"{self._url}{CHANGES_ENDPOINT}"

Expand Down
7 changes: 5 additions & 2 deletions novu/api/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import requests

from novu.api.base import Api
from novu.api.base import Api, RetryConfig
from novu.constants import ENVIRONMENTS_ENDPOINT
from novu.dto import EnvironmentApiKeyDto, EnvironmentDto

Expand All @@ -18,9 +18,12 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
super().__init__(url=url, api_key=api_key, requests_timeout=requests_timeout, session=session)
super().__init__(
url=url, api_key=api_key, requests_timeout=requests_timeout, retry_config=retry_config, session=session
)

self._environment_url = f"{self._url}{ENVIRONMENTS_ENDPOINT}"

Expand Down
7 changes: 5 additions & 2 deletions novu/api/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import requests

from novu.api.base import Api
from novu.api.base import Api, RetryConfig
from novu.constants import EVENTS_ENDPOINT
from novu.dto.event import EventDto, InputEventDto
from novu.dto.topic import TriggerTopicDto
Expand All @@ -20,9 +20,12 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
super().__init__(url=url, api_key=api_key, requests_timeout=requests_timeout, session=session)
super().__init__(
url=url, api_key=api_key, requests_timeout=requests_timeout, retry_config=retry_config, session=session
)

self._event_url = f"{self._url}{EVENTS_ENDPOINT}"

Expand Down
7 changes: 5 additions & 2 deletions novu/api/execution_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import requests

from novu.api.base import Api
from novu.api.base import Api, RetryConfig
from novu.constants import EXECUTION_DETAILS_ENDPOINT
from novu.dto import ExecutionDetailDto

Expand All @@ -19,9 +19,12 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
super().__init__(url=url, api_key=api_key, requests_timeout=requests_timeout, session=session)
super().__init__(
url=url, api_key=api_key, requests_timeout=requests_timeout, retry_config=retry_config, session=session
)

self._execution_detail_url = f"{self._url}{EXECUTION_DETAILS_ENDPOINT}"

Expand Down
7 changes: 5 additions & 2 deletions novu/api/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import requests

from novu.api.base import Api
from novu.api.base import Api, RetryConfig
from novu.constants import FEEDS_ENDPOINT
from novu.dto.feed import FeedDto

Expand All @@ -18,9 +18,12 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
super().__init__(url=url, api_key=api_key, requests_timeout=requests_timeout, session=session)
super().__init__(
url=url, api_key=api_key, requests_timeout=requests_timeout, retry_config=retry_config, session=session
)

self._feed_url = f"{self._url}{FEEDS_ENDPOINT}"

Expand Down
7 changes: 5 additions & 2 deletions novu/api/inbound_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import requests

from novu.api.base import Api
from novu.api.base import Api, RetryConfig
from novu.constants import INBOUND_PARSE_ENDPOINT


Expand All @@ -17,9 +17,12 @@ def __init__(
url: Optional[str] = None,
api_key: Optional[str] = None,
requests_timeout: Optional[int] = None,
retry_config: Optional[RetryConfig] = None,
session: Optional[requests.Session] = None,
) -> None:
super().__init__(url=url, api_key=api_key, requests_timeout=requests_timeout, session=session)
super().__init__(
url=url, api_key=api_key, requests_timeout=requests_timeout, retry_config=retry_config, session=session
)

self._inbound_parse_url = f"{self._url}{INBOUND_PARSE_ENDPOINT}"

Expand Down
Loading