Django: a pattern for settings-configured API clients

Here’s an example of a common pattern in Django projects:
from acme.api import APIClient
from django.conf import settings
acme_client = APIClient(api_key=settings.ACME_API_KEY)
def order_anvil() -> None:
acme_client.anvils.order(...)
An API client is instantiated as a module-level variable based on some settings. This approach has some drawbacks:
The client doesn’t get reinstantiated when settings change. This typically occurs during tests, where
@override_settingswould be useful:from django.test import TestCase, override_settings @override_settings(ACME_API_KEY="example-key") class ACMETests(TestCase): ...
Tests instead need workarounds, like patching with
unittest.mock.patch.Some API clients are expensive to instantiate, even issuing HTTP requests. This cost is paid at import time, thanks to module-level instantiation. This adds overhead to code paths that don’t use the client, such as unrelated management commands.
Here’s an alternative pattern that avoids these problems:
from functools import cache
from acme.api import APIClient
from django.conf import settings
from django.core.signals import setting_changed
from django.dispatch import receiver
@cache
def get_acme_client() -> APIClient:
return APIClient(api_key=settings.ACME_API_KEY)
@receiver(setting_changed)
def reset_acme_client(*, setting, **kwargs):
if setting == "ACME_API_KEY":
get_acme_client.cache_clear()
def order_anvil() -> None:
get_acme_client().anvils.order(...)
Notes:
- The client is now instantiated on first use, within
get_acme_client(). This function is decorated withfunctools.cache, so the client is cached after the first call. - A new signal receiver function,
reset_acme_client(), resets the cache when the API key setting changes. This is registered to receive thesetting_changedsignal, fired by Django’s setting-changing tools for tests, including@override_settings. order_anvil()now callsget_acme_client()to fetch the cached client.
This pattern requires a bit more code, but it is the easiest way to avoid the previous problems. It’s used for several settings-controlled objects inside Django. For example, the loading of password hashers:
@functools.lru_cache
def get_hashers(): ...
@functools.lru_cache
def get_hashers_by_algorithm(): ...
@receiver(setting_changed)
def reset_hashers(*, setting, **kwargs):
if setting == "PASSWORD_HASHERS":
get_hashers.cache_clear()
get_hashers_by_algorithm.cache_clear()
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts:
- Django: How to profile and improve startup time
- Django settings patterns to avoid
- Django: avoid “useless use of
.all()”
Tags: django