-
Notifications
You must be signed in to change notification settings - Fork 214
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Run the app as ASGI * Fix static file handling on localhost Note: this does not apply to production because we serve static files from Nginx there: those static file requests never make it to the Django application and, indeed, it is not configured to serve static files. This change uses the ASGI static file handler that the Django `runserver` management command uses and correctly handles streaming responses. The only consequence of not doing this is that warnings will appear locally and, if for some reason local interactions are bypassing the static file cache on the browser, you could get a memory leak. Again, that only applies to local environments. Python code never interacts with, considers, or is configured for static files in production, so this is not an issue for production. The correct behaviour for production, which you can test by setting ENVIRONMENT to something other than `local` in `api/.env`, is to 404 on static files. * Add aiohttp client sharing * Add aiohttp session manager tests * Use clearer middleware pattern and consistent app export name * Fix default environment in api env template * Switch to django-asgi-lifecycle instead of custom asgi lifecycle implementation
- Loading branch information
1 parent
b91cdc3
commit 1de2d21
Showing
11 changed files
with
725 additions
and
621 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import asyncio | ||
import logging | ||
import weakref | ||
|
||
import aiohttp | ||
import sentry_sdk | ||
from django_asgi_lifespan.signals import asgi_shutdown | ||
|
||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
_SESSIONS: weakref.WeakKeyDictionary[ | ||
asyncio.AbstractEventLoop, aiohttp.ClientSession | ||
] = weakref.WeakKeyDictionary() | ||
|
||
_LOCKS: weakref.WeakKeyDictionary[ | ||
asyncio.AbstractEventLoop, asyncio.Lock | ||
] = weakref.WeakKeyDictionary() | ||
|
||
|
||
@asgi_shutdown.connect | ||
async def _close_sessions(sender, **kwargs): | ||
logger.debug("Closing aiohttp sessions on application shutdown") | ||
|
||
closed_sessions = 0 | ||
|
||
while _SESSIONS: | ||
loop, session = _SESSIONS.popitem() | ||
try: | ||
await session.close() | ||
closed_sessions += 1 | ||
except BaseException as exc: | ||
logger.error(exc) | ||
sentry_sdk.capture_exception(exc) | ||
|
||
logger.debug("Successfully closed %s session(s)", closed_sessions) | ||
|
||
|
||
async def get_aiohttp_session() -> aiohttp.ClientSession: | ||
""" | ||
Safely retrieve a shared aiohttp session for the current event loop. | ||
If the loop already has an aiohttp session associated, it will be reused. | ||
If the loop has not yet had an aiohttp session created for it, a new one | ||
will be created and returned. | ||
While the main application will always run in the same loop, and while | ||
that covers 99% of our use cases, it is still possible for `async_to_sync` | ||
to cause a new loop to be created if, for example, `force_new_loop` is | ||
passed. In order to prevent surprises should that ever be the case, this | ||
function assumes that it's possible for multiple loops to be present in | ||
the lifetime of the application and therefore we need to verify that each | ||
loop gets its own session. | ||
""" | ||
|
||
loop = asyncio.get_running_loop() | ||
|
||
if loop not in _LOCKS: | ||
_LOCKS[loop] = asyncio.Lock() | ||
|
||
async with _LOCKS[loop]: | ||
if loop not in _SESSIONS: | ||
create_session = True | ||
msg = "No session for loop. Creating new session." | ||
elif _SESSIONS[loop].closed: | ||
create_session = True | ||
msg = "Loop's previous session closed. Creating new session." | ||
else: | ||
create_session = False | ||
msg = "Reusing existing session for loop." | ||
|
||
logger.info(msg) | ||
|
||
if create_session: | ||
session = aiohttp.ClientSession() | ||
_SESSIONS[loop] = session | ||
|
||
return _SESSIONS[loop] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import pytest | ||
from asgiref.sync import async_to_sync | ||
|
||
from conf.asgi import application | ||
|
||
|
||
@pytest.fixture(scope="session", autouse=True) | ||
def ensure_asgi_lifecycle(): | ||
""" | ||
Call application shutdown lifecycle event. | ||
This cannot be an async fixture because the scope is session | ||
and pytest-asynio's `event_loop` fixture, which is auto-used | ||
for async tests and fixtures, is function scoped, which is | ||
incomatible with session scoped fixtures. `async_to_sync` works | ||
fine here, so it's not a problem. | ||
This cannot yet call the startup signal due to: | ||
https://github.com/illagrenan/django-asgi-lifespan/pull/80 | ||
""" | ||
scope = {"type": "lifespan"} | ||
|
||
async def noop(*args, **kwargs): | ||
... | ||
|
||
async def shutdown(): | ||
return {"type": "lifespan.shutdown"} | ||
|
||
yield | ||
async_to_sync(application)(scope, shutdown, noop) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import asyncio | ||
|
||
import pytest | ||
|
||
from api.utils.aiohttp import get_aiohttp_session | ||
|
||
|
||
@pytest.fixture(autouse=True) | ||
def get_new_loop(): | ||
loops: list[asyncio.AbstractEventLoop] = [] | ||
|
||
def _get_new_loop(): | ||
loop = asyncio.new_event_loop() | ||
loops.append(loop) | ||
return loop | ||
|
||
yield _get_new_loop | ||
|
||
for loop in loops: | ||
loop.close() | ||
|
||
|
||
def test_reuses_session_within_same_loop(get_new_loop): | ||
loop = get_new_loop() | ||
|
||
session_1 = loop.run_until_complete(get_aiohttp_session()) | ||
session_2 = loop.run_until_complete(get_aiohttp_session()) | ||
|
||
assert session_1 is session_2 | ||
|
||
|
||
def test_creates_new_session_for_separate_loops(get_new_loop): | ||
loop_1 = get_new_loop() | ||
loop_2 = get_new_loop() | ||
|
||
loop_1_session = loop_1.run_until_complete(get_aiohttp_session()) | ||
loop_2_session = loop_2.run_until_complete(get_aiohttp_session()) | ||
|
||
assert loop_1_session is not loop_2_session | ||
|
||
|
||
def test_multiple_loops_reuse_separate_sessions(get_new_loop): | ||
loop_1 = get_new_loop() | ||
loop_2 = get_new_loop() | ||
|
||
loop_1_session_1 = loop_1.run_until_complete(get_aiohttp_session()) | ||
loop_1_session_2 = loop_1.run_until_complete(get_aiohttp_session()) | ||
loop_2_session_1 = loop_2.run_until_complete(get_aiohttp_session()) | ||
loop_2_session_2 = loop_2.run_until_complete(get_aiohttp_session()) | ||
|
||
assert loop_1_session_1 is loop_1_session_2 | ||
assert loop_2_session_1 is loop_2_session_2 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters