Skip to content

Commit 2ddd81d

Browse files
davidhuserJonasKsclaude
authored
feat: add asyncio.Lock to prevent concurrent refreshes (#234)
* feat: add asyncio.Lock to prevent concurrent refreshes * fix: lazy init of lock * fix: lock at module level to not have different event loops * chore: remove python 3.9 support * fix: store refresh lock on instance not on module * chore: upgrade dev dependencies * fix: revert anyio to v < 4 --------- Co-authored-by: Jonas Krüger Svensson <jonas@vibber.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a2cee1 commit 2ddd81d

7 files changed

Lines changed: 49 additions & 68 deletions

File tree

.github/workflows/codeql-analysis.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020

2121
- uses: actions/setup-python@v6
2222
with:
23-
python-version: "3.11"
23+
python-version: "3.12"
2424

2525
- name: Install uv
2626
uses: astral-sh/setup-uv@v6

docs/docs/usage-and-faq/accessing_the_user.mdx

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ You can access your user object in two ways, either with `Depends(<schema name>)
1010

1111
### `Depends(<schema name>)`
1212

13-
<Tabs groupId="python-version">
14-
<TabItem value="Python 3.9 or above">
1513

1614
```python title="depends_api_example.py"
1715
from fastapi import APIRouter, Depends
@@ -33,40 +31,10 @@ async def hello_user(user: User = Depends(azure_scheme)) -> dict[str, bool]:
3331
"""
3432
return user.dict()
3533
```
36-
</TabItem>
37-
38-
<TabItem value="Python 3.8">
39-
40-
```python title="depends_api_example.py"
41-
from fastapi import APIRouter, Depends
42-
from typing import Dict
43-
44-
from demo_project.api.dependencies import azure_scheme
45-
from fastapi_azure_auth.user import User
46-
47-
router = APIRouter()
48-
49-
50-
@router.get(
51-
'/hello-user',
52-
response_model=User,
53-
operation_id='helloWorldApiKey',
54-
)
55-
async def hello_user(user: User = Depends(azure_scheme)) -> Dict[str, bool]:
56-
"""
57-
Wonder how this auth is done?
58-
"""
59-
return user.dict()
60-
```
61-
</TabItem>
62-
63-
</Tabs>
6434

6535

6636
### `request.state.user`
6737

68-
<Tabs groupId="python-version">
69-
<TabItem value="Python 3.9 or above">
7038

7139
```python title="request_state_user_api_example.py"
7240
from fastapi import APIRouter, Depends, Request
@@ -89,34 +57,3 @@ async def hello_user(request: Request) -> dict[str, bool]:
8957
"""
9058
return request.state.user.dict()
9159
```
92-
93-
</TabItem>
94-
95-
<TabItem value="Python 3.8">
96-
97-
```python title="request_state_user_api_example.py"
98-
from fastapi import APIRouter, Depends, Request
99-
from typing import Dict
100-
101-
from demo_project.api.dependencies import azure_scheme
102-
from fastapi_azure_auth.user import User
103-
104-
router = APIRouter()
105-
106-
107-
@router.get(
108-
'/hello-user',
109-
response_model=User,
110-
operation_id='helloWorldApiKey',
111-
dependencies=[Depends(azure_scheme)]
112-
)
113-
async def hello_user(request: Request) -> Dict[str, bool]:
114-
"""
115-
Wonder how this auth is done?
116-
"""
117-
return request.state.user.dict()
118-
```
119-
120-
</TabItem>
121-
122-
</Tabs>

fastapi_azure_auth/openid_config.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import TYPE_CHECKING, Any, NotRequired, TypedDict
55

66
import jwt
7+
from anyio import Lock
78
from fastapi import HTTPException, status
89
from httpx2 import AsyncClient
910

@@ -63,12 +64,24 @@ def __init__(
6364
self.token_endpoint: str
6465
self.issuer: str
6566

67+
self._refresh_lock: Lock = Lock()
68+
69+
def _config_is_fresh(self) -> bool:
70+
refresh_time = datetime.now() - timedelta(hours=24)
71+
return bool(self._config_timestamp and self._config_timestamp >= refresh_time)
72+
6673
async def load_config(self) -> None:
6774
"""
6875
Loads config from the OpenID Connect metadata endpoint if it's over 24 hours old (or don't exist)
6976
"""
70-
refresh_time = datetime.now() - timedelta(hours=24)
71-
if not self._config_timestamp or self._config_timestamp < refresh_time:
77+
# Fast path without the lock: this runs on every authenticated request.
78+
if self._config_is_fresh():
79+
return
80+
async with self._refresh_lock:
81+
# Re-check inside the lock: another task may have refreshed while we waited,
82+
# so concurrent requests result in a single fetch.
83+
if self._config_is_fresh():
84+
return
7285
try:
7386
log.debug('Loading Azure Entra ID OpenID configuration.')
7487
await self._load_openid_config()

mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Global options
22
[mypy]
3-
python_version = 3.11
3+
python_version = 3.12
44
# flake8-mypy expects the two following for sensible formatting
55
show_column_numbers = True
66
show_error_context = False

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ classifiers = [
4343
]
4444
dependencies = [
4545
"fastapi>=0.111.1",
46+
"anyio>=4.4.0",
4647
"cryptography>=48.0.1",
4748
"httpx2>=2.0.0",
4849
"pydantic>=2.6.2",

tests/test_provider_config.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
from datetime import datetime, timedelta
33

4+
import anyio
5+
import httpx
46
import pytest
57
from asgi_lifespan import LifespanManager
68
from httpx2 import ASGITransport, AsyncClient
@@ -9,7 +11,7 @@
911
from demo_project.main import app
1012
from fastapi_azure_auth import HttpClientConfig, SingleTenantAzureAuthorizationCodeBearer
1113
from fastapi_azure_auth.openid_config import OpenIdConfig
12-
from tests.utils import build_access_token, build_openid_keys, openid_configuration
14+
from tests.utils import build_access_token, build_openid_keys, keys_url, openid_config_url, openid_configuration
1315

1416

1517
@pytest.mark.anyio
@@ -72,6 +74,32 @@ async def test_custom_config_id(httpx2_mock):
7274
assert len(openid_config.signing_keys) == 2
7375

7476

77+
@pytest.mark.anyio
78+
async def test_concurrent_refresh_requests(httpx2_mock):
79+
"""Concurrent refreshes of a stale config should result in exactly one fetch."""
80+
81+
# respx requires classic httpx.Response objects from side_effect callables (lundberg/respx#324)
82+
async def slow_config_response(*args, **kwargs):
83+
await anyio.sleep(0.2)
84+
return httpx.Response(200, json=openid_configuration())
85+
86+
async def slow_keys_response(*args, **kwargs):
87+
await anyio.sleep(0.2)
88+
return httpx.Response(200, json=build_openid_keys())
89+
90+
config_route = httpx2_mock.get(openid_config_url()).mock(side_effect=slow_config_response)
91+
keys_route = httpx2_mock.get(keys_url()).mock(side_effect=slow_keys_response)
92+
93+
openid_config = OpenIdConfig('vibber_tenant_id')
94+
async with anyio.create_task_group() as task_group:
95+
for _ in range(5):
96+
task_group.start_soon(openid_config.load_config)
97+
98+
assert len(config_route.calls) == 1, 'Config endpoint called multiple times'
99+
assert len(keys_route.calls) == 1, 'Keys endpoint called multiple times'
100+
assert len(openid_config.signing_keys) == 2
101+
102+
75103
def _capture_client_kwargs(mocker):
76104
"""Patch the AsyncClient used for config fetching so the kwargs it receives can be asserted."""
77105
import fastapi_azure_auth.openid_config as openid_config_module

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)