question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

AsyncClient ignores the startup and shutdown events

See original GitHub issue

This is very close to #1072, but for AsyncClient.

The problem is that AsyncClient ignores the startup and shutdown events.

To Reproduce

Steps to reproduce the behavior with a minimum self-contained file.

Replace each part with your own scenario:

  • Create a main.py file with:
from fastapi import FastAPI, Request

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}


async def add_test_header(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Test-Header"] = 'Hello'
    return response


@app.on_event("startup")
def setup():
    # example code that runs on startup
    global add_test_header
    print('executing startup!!')
    add_test_header = app.middleware("http")(add_test_header)
  • Create a test_startup.py file with:
import pytest

from httpx import AsyncClient

from main import app


@pytest.fixture()
async def client():
    async with AsyncClient(app=app, base_url="http://test") as 
        yield client


@pytest.mark.asyncio
async def test_read_main(client):
    response = await client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}
    assert 'X-Test-Header' in response.headers

Run pytest -s -v test_startup.py You will see an AssertionError for the X-Test-Header not being there The test should pass

So far I am using a hack to make it work:

@pytest.fixture()
async def client():
    """Test client pytest fixture.

    Example:
        >>> from httpx import Response
        >>>
        >>>
        >>> @pytest.mark.asyncio
        >>> async def test_health_check(client):
        >>>    resp: Response = await client.get("/health_check")
        >>>    assert resp.status_code == 200

    """
    app = build_app()
    async with AsyncClient(app=app, base_url="http://test") as client:
        await connect_to_db(app)
        yield client
    await db_teardown(app)

but probably some solution or a note in the docs would compliment the project

Issue Analytics

  • State:open
  • Created 3 years ago
  • Reactions:5
  • Comments:20 (5 by maintainers)

github_iconTop GitHub Comments

35reactions
blazewiczcommented, Mar 17, 2021

It’s all discussed in https://github.com/encode/httpx/issues/350 and related issues, but I’m gonna leave here a short summary.

As described in https://github.com/encode/httpx/issues/1441, app lifecycle managemenent won’t be added to HTTPX’s AsyncClient because its considered out of its scope.

Suggested solution for original problem is to use LifespanManager from asgi-lifespan in pair with AsyncClient.

So test_startup.py could be changed to:

import pytest

from asgi_lifespan import LifespanManager
from httpx import AsyncClient

from main import app


@pytest.fixture()
async def client():
    async with AsyncClient(app=app, base_url="http://test") as client, LifespanManager(app):
        yield client


@pytest.mark.asyncio
async def test_read_main(client):
    response = await client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}
    assert 'X-Test-Header' in response.headers

I think it should be at least mentioned in “Testing Events: startup - shutdown” section of FastAPI docs.

4reactions
j-carsoncommented, Aug 31, 2021

Now that my tests are running, I will suggest that the so-called hack suggested by the original poster may in fact be desired best practice. This is because, once I got the first test running under pytest-asyncio I hit another round of problems trying to get multiple tests to run until I realized something else was going on besides just startup/shutdown confusion:

Like all of pytest, pytest-asyncio is built around the concept of running individually isolated unit tests. Thus, it creates a new event loop for each unit test. This can be a good thing, you’ll be able to isolate xyz was never awaited errors to one unit test. But it can also lead to confusing results if your test tries to reuse the app variable as in from main import app: I was trying to run a test that was marked with parametrize and no matter what I did, only one iteration would run and then I’d started getting weird errors about event loops. To be fully isolated unit tests, you have to create a new FastAPI instance each time as the OP did with their build_app function.

Here is how I refactored my code:

# This code block is my refactored main.py
def app_factory():
   myapp = FastAPI()
   myapp.include_router(module_a.router)
   myapp.include_router(module_b.router)
   return myapp

async def app_startup(app):
    pass # startup goes here

async def app_shutdown(app):
    pass # shutdown goes here

app = app_factory()
@app.on_event("startup")
async def startup():
    await app_startup(app)

@app.on_event("shutdown")
async def shutdown():
    await app_shutdown(app)

Then, in my tests, I have a fixture that creates a brand new FastAPI instance for each test function so that nothing gets confused by all the new event loops being created and destroyed under the covers by pytest-asyncio.

# this code block goes in conftest.py and simultaneously solves both the startup/shutdown doesn't run problem
# as well as my issues with running parametrized tests

from main import app_factory, app_startup, app_shutdown

@pytest.fixture
async def app():
     app = app_factory()
     await app_startup(app)
     yield app
     await app_shutdown(app)

And here’s a stub for a test:


@pytest.mark.parametrize("param_a,param_b", [[1,2],[3,4],[5,6]])
@pytest.mark.asyncio
async def test_module_a(
    app,
    param_a,
    param_b
):
    async with AsyncClient(app=app, base_url="http://test") as ac:
        params = {}
        params["param_a"] = param_a
        params["param_b"] = param_b
        response = await ac.get("/module_a/some_api", params=params)
        assert response.status_code == 200  

Edit to add: If you don’t mind having your event_loop stick around between tests, this issue shows how to prevent pytest-asyncio from creating new loops during the test: https://github.com/tiangolo/fastapi/issues/2006

Edited to patch up the pseudo-code a bit more.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Startup and Shutdown — Quart 0.17.0 documentation
... ability for awaiting coroutines before the first byte is received and after the final byte is sent, through the startup and shutdown...
Read more >
Alienvault HIDS does not collect Windows Reboot or ...
Default configuration prevents the event from being delivered, requiring special considerations if reboot/shutdown events are required data.
Read more >
Async Support - HTTPX
Startup /shutdown of ASGI apps. It is not in the scope of HTTPX to trigger lifespan events of your app. However it is...
Read more >
How to schedule a task to run when shutting down windows
In all other cases (start menu shutdown), the computer kernel hibernated, and revieved on boot, and GPO startup and shutdown scipts are ignored....
Read more >
ASP.NET Core WebSockets and Application Lifetime ...
ASP.NET Core WebSockets and Application Lifetime Shutdown Events ... None); } catch { // this may throw on shutdown and can be ignored...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found