litestar-org / litestar

Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
https://litestar.dev/
MIT License
5.43k stars 371 forks source link

Unexpected `ContextVar` handling for lifespan context managers #3781

Open rmorshea opened 1 day ago

rmorshea commented 1 day ago

Description

A ContextVar set within a lifespan context manager is not available inside request handlers. By contrast, doing the same thing via an application level dependency does appear set in the require handler.

MCVE

import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextvars import ContextVar

from litestar import Litestar
from litestar import get
from litestar.testing import AsyncTestClient

VAR = ContextVar[int]("VAR", default=0)

@asynccontextmanager
async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    finally:
        VAR.reset(token)

@get("/example")
async def example() -> int:
    return VAR.get()

app = Litestar([example], lifespan=[set_var()])

async def test() -> None:
    async with AsyncTestClient(app) as client:
        response = await client.get("/example")
        assert (value := response.json()) == 1, f"Expected 1, got {value}"

if __name__ == "__main__":
    asyncio.run(test())

Steps to reproduce

Run the example above. Either via python example.py or uvicorn example:app and check the /example route.

We should expect the response to be 1 since that's what's set during the lifespan context manager, but it's always 0.

If you instead swap the lifespan context manager for a dependency it works as expected:

import asyncio
from collections.abc import AsyncIterator
from contextvars import ContextVar

from litestar import Litestar
from litestar import get
from litestar.testing import AsyncTestClient

VAR = ContextVar[int]("VAR", default=0)

async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    finally:
        VAR.reset(token)

@get("/example")
async def example(set_var: None) -> int:
    return VAR.get()

app = Litestar([example], dependencies={"set_var": set_var})

async def test() -> None:
    async with AsyncTestClient(app) as client:
        response = await client.get("/example")
        assert (value := response.json()) == 1, f"Expected 1, got {value}"

if __name__ == "__main__":
    asyncio.run(test())

Litestar Version

2.12.1

Platform


[!NOTE]
While we are open for sponsoring on GitHub Sponsors and OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.

Fund with Polar

euri10 commented 1 day ago

vey interesting, I played a litttle with it, changing lifespan to the old way with on_startup / on_sutdown and the result is even weirder.

I didn't go through all this thread but that seems related https://github.com/pytest-dev/pytest-asyncio/issues/127

note on my "tests" I didnt use pytest-asyncio but anyio

rmorshea commented 1 day ago

It looks like this is expected behavior in Starlette/FastAPI. The response there is pretty turse so it's hard to see exactly why.

rmorshea commented 1 day ago

If this turns out to be a fundamental limitation it might be nice to support dependencies that have side effects but don't actually return a value. I could imagine something similar to Pytest's auto use fixtures:

VAR = ContextVar("VAR", default=0)

async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    finally:
        VAR.reset(token)

@get("/", dependencies={"set_var": Provide(set_var, always_use=True)})
async def get_thing() -> None:
    assert VAR.get() == 1
euri10 commented 1 day ago

yep, pytest-asyncio might add another layer of complexity but this fails without regardless so the issue lies elsewhere,

now adding some goold old-style prints to this:

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from contextvars import ContextVar

from litestar import Litestar
from litestar import get

VAR = ContextVar("VAR", default=0)
print(f"1: {VAR}")

@asynccontextmanager
async def set_var() -> AsyncIterator[None]:
    token = VAR.set(1)
    try:
        yield
    # except Exception as exc:
    #     print(exc)
    finally:
        print("Finally")
        VAR.reset(token)

@get("/example")
async def example() -> int:
    print(VAR)
    return VAR.get()

app = Litestar([example], lifespan=[set_var()], debug=True)

if __name__ == "__main__":
    # asyncio.run(test())
    print(f"1: {VAR}")
    import uvicorn
    uvicorn.run("cv:app", reload=True)

I get this log, note that there is something going on if I understand it clearly with the way the var is created / initialized as the handler seems to not use the same var that was created in lifespan

/home/lotso/PycharmProjects/abdul/.venv/bin/python /home/lotso/PycharmProjects/abdul/cv.py 
1: <ContextVar name='VAR' default=0 at 0x7fb24500be20>
1: <ContextVar name='VAR' default=0 at 0x7fb24500be20>
INFO:     Will watch for changes in these directories: ['/home/lotso/PycharmProjects/abdul']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [436898] using WatchFiles
1: <ContextVar name='VAR' default=0 at 0x7fabc7fedfd0>
1: <ContextVar name='VAR' default=0 at 0x7fabc57972e0>
INFO:     Started server process [436904]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO - 2024-10-09 08:08:12,131 - watchfiles.main - main - 3 changes detected
<ContextVar name='VAR' default=0 at 0x7fabc57972e0>
INFO:     127.0.0.1:57262 - "GET /example HTTP/1.1" 200 OK
rmorshea commented 1 day ago

Here's a longer running issue: https://github.com/encode/uvicorn/issues/1151