diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 61d3c6c..7f092c3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -30,6 +30,13 @@ vNext *Unreleased changes* +Features +~~~~~~~~ + +* Support customizing the requests.Session for requests-based backends, + and document how this can be used to mount an adapter that simplifies + automatic retry logic. (Thanks to `@dgilmanAIDENTIFIED`_.) + Fixes ~~~~~ @@ -1340,6 +1347,7 @@ Features .. _@costela: https://github.com/costela .. _@coupa-anya: https://github.com/coupa-anya .. _@decibyte: https://github.com/decibyte +.. _@dgilmanAIDENTIFIED: https://github.com/dgilmanAIDENTIFIED .. _@dominik-lekse: https://github.com/dominik-lekse .. _@erikdrums: https://github.com/erikdrums .. _@ewingrj: https://github.com/ewingrj diff --git a/anymail/backends/base_requests.py b/anymail/backends/base_requests.py index 64848b6..c0b9ff9 100644 --- a/anymail/backends/base_requests.py +++ b/anymail/backends/base_requests.py @@ -25,17 +25,12 @@ class AnymailRequestsBackend(AnymailBaseBackend): return False # already exists try: - self.session = requests.Session() - except requests.RequestException: + self.session = self.create_session() + except Exception: if not self.fail_silently: raise - else: - self.session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format( - esp=self.esp_name.lower(), version=__version__, - orig=self.session.headers.get("User-Agent", "")) - if self.debug_api_requests: - self.session.hooks['response'].append(self._dump_api_request) - return True + + return True def close(self): if self.session is None: @@ -57,6 +52,23 @@ class AnymailRequestsBackend(AnymailBaseBackend): "or you are incorrectly calling _send directly.)".format(class_name=class_name)) return super()._send(message) + def create_session(self): + """Create the requests.Session object used by this instance of the backend. + If subclassed, you can modify the Session returned from super() to give + it your own configuration. + + This must return an instance of requests.Session.""" + session = requests.Session() + + session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format( + esp=self.esp_name.lower(), version=__version__, + orig=session.headers.get("User-Agent", "")) + + if self.debug_api_requests: + session.hooks['response'].append(self._dump_api_request) + + return session + def post_to_esp(self, payload, message): """Post payload to ESP send API endpoint, and return the raw response. diff --git a/docs/conf.py b/docs/conf.py index 9d1738e..ac36fa5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -276,6 +276,7 @@ intersphinx_mapping = { 'python': ('https://docs.python.org/3.10', None), 'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'), 'requests': ('https://requests.readthedocs.io/en/stable/', None), + 'urllib3': ('https://urllib3.readthedocs.io/en/stable/', None), } diff --git a/docs/tips/transient_errors.rst b/docs/tips/transient_errors.rst index ad2f15a..e5bae86 100644 --- a/docs/tips/transient_errors.rst +++ b/docs/tips/transient_errors.rst @@ -23,3 +23,46 @@ transient ESP errors depends on your Django project: In addition to handling connectivity issues, either of these approaches also has the advantage of moving email sending to a background thread. This is a best practice for sending email from Django, as it allows your web views to respond faster. + +Automatic retries +----------------- + +Backends that use :pypi:`requests` for network calls can configure its built-in retry +functionality. Subclass the Anymail backend and mount instances of +:class:`~requests.adapters.HTTPAdapter` and :class:`~urllib3.util.Retry` configured with +your settings on the :class:`~requests.Session` object in `create_session()`. + +Automatic retries aren't a substitute for sending emails in a background thread, they're +a way to simplify your retry logic within the worker. Be aware that retrying `read` and `other` +failures may result in sending duplicate emails. Requests will only attempt to retry idempotent +HTTP verbs by default, you may need to whitelist the verbs used by your backend's API in +`allowed_methods` to actually get any retries. It can also automatically retry error HTTP +status codes for you but you may need to configure `status_forcelist` with the error HTTP status +codes used by your backend provider. + + .. code-block:: python + + import anymail.backends.mandrill + from django.conf import settings + import requests.adapters + + + class RetryableMandrillEmailBackend(anymail.backends.mandrill.EmailBackend): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + retry = requests.adapters.Retry( + total=settings.EMAIL_TOTAL_RETRIES, + connect=settings.EMAIL_CONNECT_RETRIES, + read=settings.EMAIL_READ_RETRIES, + status=settings.EMAIL_HTTP_STATUS_RETRIES, + other=settings.EMAIL_OTHER_RETRIES, + allowed_methods=False, # Retry all HTTP verbs + status_forcelist=settings.EMAIL_HTTP_STATUS_RETRYABLE, + backoff_factor=settings.EMAIL_RETRY_BACKOFF_FACTOR, + ) + self.retryable_adapter = requests.adapters.HTTPAdapter(max_retries=retry) + + def create_session(self): + session = super().create_session() + session.mount("https://", self.retryable_adapter) + return session