Allow requests session customization, document use for automatic retries

* Refactor create_session() out of AnymailRequestsBackend
* Document automatic retries with Requests
This commit is contained in:
David Gilman
2022-06-26 21:52:46 -04:00
committed by GitHub
parent 48de044c9b
commit e3cd4df1fc
4 changed files with 73 additions and 9 deletions

View File

@@ -30,6 +30,13 @@ vNext
*Unreleased changes* *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 Fixes
~~~~~ ~~~~~
@@ -1340,6 +1347,7 @@ Features
.. _@costela: https://github.com/costela .. _@costela: https://github.com/costela
.. _@coupa-anya: https://github.com/coupa-anya .. _@coupa-anya: https://github.com/coupa-anya
.. _@decibyte: https://github.com/decibyte .. _@decibyte: https://github.com/decibyte
.. _@dgilmanAIDENTIFIED: https://github.com/dgilmanAIDENTIFIED
.. _@dominik-lekse: https://github.com/dominik-lekse .. _@dominik-lekse: https://github.com/dominik-lekse
.. _@erikdrums: https://github.com/erikdrums .. _@erikdrums: https://github.com/erikdrums
.. _@ewingrj: https://github.com/ewingrj .. _@ewingrj: https://github.com/ewingrj

View File

@@ -25,17 +25,12 @@ class AnymailRequestsBackend(AnymailBaseBackend):
return False # already exists return False # already exists
try: try:
self.session = requests.Session() self.session = self.create_session()
except requests.RequestException: except Exception:
if not self.fail_silently: if not self.fail_silently:
raise raise
else:
self.session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format( return True
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
def close(self): def close(self):
if self.session is None: if self.session is None:
@@ -57,6 +52,23 @@ class AnymailRequestsBackend(AnymailBaseBackend):
"or you are incorrectly calling _send directly.)".format(class_name=class_name)) "or you are incorrectly calling _send directly.)".format(class_name=class_name))
return super()._send(message) 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): def post_to_esp(self, payload, message):
"""Post payload to ESP send API endpoint, and return the raw response. """Post payload to ESP send API endpoint, and return the raw response.

View File

@@ -276,6 +276,7 @@ intersphinx_mapping = {
'python': ('https://docs.python.org/3.10', None), 'python': ('https://docs.python.org/3.10', None),
'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'), 'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'),
'requests': ('https://requests.readthedocs.io/en/stable/', None), 'requests': ('https://requests.readthedocs.io/en/stable/', None),
'urllib3': ('https://urllib3.readthedocs.io/en/stable/', None),
} }

View File

@@ -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 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 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. 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