Skip to content

Commit 69e01a5

Browse files
Modernize codebase to be fully async (#77)
* Modernize codebase to be fully async * Make API poll timeout configurable * Default to 5 seconds on API polling requests 5 seconds is also httpx's default.
1 parent 7879bdc commit 69e01a5

File tree

9 files changed

+76
-92
lines changed

9 files changed

+76
-92
lines changed

requirements-dev.in

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,4 @@ pytest
99
pytest-asyncio
1010
pytest-mock
1111
reorder-python-imports
12-
httpx
1312
certifi
14-
15-
# lock the version because `starlette`(from requirements.in) explicitly depends on it
16-
# but httpx tries to fetch the latest version causing conflict between
17-
# requirements.txt and requirements-dev.txt
18-
anyio==3.7.1
19-

requirements-dev.txt

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,14 @@
44
#
55
# pip-compile requirements-dev.in
66
#
7-
anyio==3.7.1
8-
# via
9-
# -r requirements-dev.in
10-
# httpcore
11-
astroid==2.15.6
7+
astroid==2.15.8
128
# via pylint
139
autopep8==2.0.4
1410
# via -r requirements-dev.in
1511
black==23.9.1
1612
# via -r requirements-dev.in
1713
certifi==2023.7.22
18-
# via
19-
# -r requirements-dev.in
20-
# httpcore
21-
# httpx
14+
# via -r requirements-dev.in
2215
cfgv==3.4.0
2316
# via pre-commit
2417
classify-imports==4.2.0
@@ -33,18 +26,8 @@ filelock==3.12.4
3326
# via virtualenv
3427
flake8==6.1.0
3528
# via -r requirements-dev.in
36-
h11==0.14.0
37-
# via httpcore
38-
httpcore==0.18.0
39-
# via httpx
40-
httpx==0.25.0
41-
# via -r requirements-dev.in
42-
identify==2.5.28
29+
identify==2.5.29
4330
# via pre-commit
44-
idna==3.4
45-
# via
46-
# anyio
47-
# httpx
4831
iniconfig==2.0.0
4932
# via pytest
5033
isort==5.12.0
@@ -84,7 +67,7 @@ pycodestyle==2.11.0
8467
# flake8
8568
pyflakes==3.1.0
8669
# via flake8
87-
pylint==2.17.5
70+
pylint==2.17.6
8871
# via -r requirements-dev.in
8972
pytest==7.4.2
9073
# via
@@ -97,13 +80,8 @@ pytest-mock==3.11.1
9780
# via -r requirements-dev.in
9881
pyyaml==6.0.1
9982
# via pre-commit
100-
reorder-python-imports==3.10.0
83+
reorder-python-imports==3.11.0
10184
# via -r requirements-dev.in
102-
sniffio==1.3.0
103-
# via
104-
# anyio
105-
# httpcore
106-
# httpx
10785
tomlkit==0.12.1
10886
# via pylint
10987
virtualenv==20.24.5

requirements.in

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
fastapi
22
uvicorn
3-
requests
43
marshmallow
54
flagsmith-flag-engine
65
python-decouple
76
python-dotenv
87
pydantic
98
orjson
9+
httpx
1010
# sse-stuff
1111
sse-starlette
1212
asyncio
1313
redis
14-

requirements.txt

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,44 @@
22
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
5-
# pip-compile
5+
# pip-compile requirements.in
66
#
77
anyio==3.7.1
88
# via
99
# fastapi
10+
# httpcore
1011
# starlette
1112
asyncio==3.4.3
1213
# via -r requirements.in
1314
certifi==2023.7.22
14-
# via requests
15-
charset-normalizer==3.2.0
16-
# via requests
15+
# via
16+
# httpcore
17+
# httpx
1718
click==8.1.7
1819
# via uvicorn
19-
fastapi==0.103.1
20+
fastapi==0.103.2
2021
# via -r requirements.in
21-
flagsmith-flag-engine==4.0.4
22+
flagsmith-flag-engine==4.1.0
2223
# via -r requirements.in
2324
h11==0.14.0
24-
# via uvicorn
25+
# via
26+
# httpcore
27+
# uvicorn
28+
httpcore==0.18.0
29+
# via httpx
30+
httpx==0.25.0
31+
# via -r requirements.in
2532
idna==3.4
2633
# via
2734
# anyio
28-
# requests
35+
# httpx
2936
marshmallow==3.20.1
3037
# via -r requirements.in
3138
orjson==3.9.7
3239
# via -r requirements.in
3340
packaging==23.1
3441
# via marshmallow
35-
pydantic==1.10.12
42+
pydantic==1.10.13
3643
# via
3744
# -r requirements.in
3845
# fastapi
@@ -44,26 +51,25 @@ python-decouple==3.8
4451
# via -r requirements.in
4552
python-dotenv==1.0.0
4653
# via -r requirements.in
47-
redis==4.6.0
48-
# via -r requirements.in
49-
requests==2.31.0
54+
redis==5.0.1
5055
# via -r requirements.in
5156
semver==2.13.0
5257
# via flagsmith-flag-engine
5358
sniffio==1.3.0
54-
# via anyio
59+
# via
60+
# anyio
61+
# httpcore
62+
# httpx
5563
sse-starlette==1.6.5
5664
# via -r requirements.in
5765
starlette==0.27.0
5866
# via
5967
# fastapi
6068
# sse-starlette
61-
typing-extensions==4.7.1
69+
typing-extensions==4.8.0
6270
# via
6371
# fastapi
6472
# pydantic
6573
# pydantic-collections
66-
urllib3==2.0.6
67-
# via requests
6874
uvicorn==0.23.2
6975
# via -r requirements.in

src/cache.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import logging
22
from datetime import datetime
33

4+
import httpx
45
import orjson
5-
import requests
66

77
from .exceptions import FlagsmithUnknownKeyError
88
from .settings import Settings
@@ -14,25 +14,25 @@ class CacheService:
1414
def __init__(self, settings: Settings):
1515
self.settings = settings
1616
self.last_updated_at = None
17-
self._session = requests.Session()
1817
self._cache = {}
18+
self._client = httpx.AsyncClient(timeout=settings.api_poll_timeout)
1919

20-
def fetch_document(self, server_side_key):
21-
url = f"{self.settings.api_url}/environment-document/"
22-
response = self._session.get(
23-
url, headers={"X-Environment-Key": server_side_key}
20+
async def fetch_document(self, server_side_key):
21+
response = await self._client.get(
22+
url=f"{self.settings.api_url}/environment-document/",
23+
headers={"X-Environment-Key": server_side_key},
2424
)
2525
response.raise_for_status()
2626
return orjson.loads(response.text)
2727

28-
def refresh(self):
28+
async def refresh(self):
2929
received_error = False
3030
for key_pair in self.settings.environment_key_pairs:
3131
try:
32-
self._cache[key_pair.client_side_key] = self.fetch_document(
32+
self._cache[key_pair.client_side_key] = await self.fetch_document(
3333
key_pair.server_side_key
3434
)
35-
except (requests.exceptions.HTTPError, orjson.JSONDecodeError):
35+
except (httpx.HTTPError, orjson.JSONDecodeError):
3636
received_error = True
3737
logger.exception(
3838
f"Failed to fetch document for {key_pair.client_side_key}"

src/main.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from contextlib import suppress
23
from datetime import datetime
34

@@ -44,7 +45,7 @@ async def unknown_key_error(request, exc):
4445

4546
@app.get("/health", response_class=ORJSONResponse, deprecated=True)
4647
@app.get("/proxy/health", response_class=ORJSONResponse)
47-
def health_check():
48+
async def health_check():
4849
with suppress(TypeError):
4950
last_updated = datetime.now() - cache_service.last_updated_at
5051
buffer = 30 * len(settings.environment_key_pairs) # 30s per environment
@@ -55,7 +56,7 @@ def health_check():
5556

5657

5758
@app.get("/api/v1/flags/", response_class=ORJSONResponse)
58-
def flags(feature: str = None, x_environment_key: str = Header(None)):
59+
async def flags(feature: str = None, x_environment_key: str = Header(None)):
5960
environment_document = cache_service.get_environment(x_environment_key)
6061
environment = build_environment_model(environment_document)
6162

@@ -87,7 +88,7 @@ def flags(feature: str = None, x_environment_key: str = Header(None)):
8788

8889

8990
@app.post("/api/v1/identities/", response_class=ORJSONResponse)
90-
def identity(
91+
async def identity(
9192
input_data: IdentityWithTraits,
9293
x_environment_key: str = Header(None),
9394
):
@@ -116,9 +117,13 @@ def identity(
116117

117118

118119
@app.on_event("startup")
119-
@repeat_every(seconds=settings.api_poll_frequency, raise_exceptions=True)
120-
def refresh_cache():
121-
cache_service.refresh()
120+
@repeat_every(
121+
seconds=settings.api_poll_frequency,
122+
raise_exceptions=True,
123+
logger=logging.getLogger(__name__),
124+
)
125+
async def refresh_cache():
126+
await cache_service.refresh()
122127

123128

124129
app.add_middleware(

src/settings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ class EnvironmentKeyPair(BaseModel):
2424
class Settings(BaseSettings):
2525
environment_key_pairs: List[EnvironmentKeyPair]
2626
api_url: HttpUrl = "https://edge.api.flagsmith.com/api/v1"
27-
api_poll_frequency: int = 10
27+
api_poll_frequency: int = 10 # minutes
28+
api_poll_timeout: int = 5 # seconds
2829

2930
# sse settings
3031
stream_delay: int = 1 # seconds

tests/test_cache.py

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import unittest.mock
22

3+
import httpx
34
import pytest
4-
import requests
55

66
from src.cache import CacheService
77
from src.exceptions import FlagsmithUnknownKeyError
@@ -16,62 +16,64 @@
1616
)
1717

1818

19-
def test_refresh_makes_correct_http_call(mocker):
19+
@pytest.mark.asyncio
20+
async def test_refresh_makes_correct_http_call(mocker):
2021
# Given
21-
mocked_get = mocker.patch("src.cache.requests.Session.get")
22+
mocked_get = mocker.patch("src.cache.httpx.AsyncClient.get")
2223
mocked_get.side_effect = [
23-
unittest.mock.AsyncMock(text='{"key1": "value1"}'),
24-
unittest.mock.AsyncMock(text='{"key2": "value2"}'),
24+
unittest.mock.Mock(text='{"key1": "value1"}'),
25+
unittest.mock.Mock(text='{"key2": "value2"}'),
2526
]
2627
mocked_datetime = mocker.patch("src.cache.datetime")
28+
2729
cache_service = CacheService(settings)
2830

2931
# When
30-
cache_service.refresh()
32+
await cache_service.refresh()
3133

3234
# Then
3335
mocked_get.assert_has_calls(
3436
[
3537
mocker.call(
36-
f"{settings.api_url}/environment-document/",
38+
url=f"{settings.api_url}/environment-document/",
3739
headers={
3840
"X-Environment-Key": settings.environment_key_pairs[
3941
0
4042
].server_side_key
4143
},
42-
)
43-
],
44-
[
44+
),
4545
mocker.call(
46-
f"{settings.api_url}/environment-document/",
46+
url=f"{settings.api_url}/environment-document/",
4747
headers={
4848
"X-Environment-Key": settings.environment_key_pairs[
4949
1
5050
].server_side_key
5151
},
52-
)
53-
],
52+
),
53+
]
5454
)
5555
assert cache_service.last_updated_at == mocked_datetime.now.return_value
5656

5757

58-
def test_refresh_does_not_update_last_updated_at_if_any_request_fails(mocker):
58+
@pytest.mark.asyncio
59+
async def test_refresh_does_not_update_last_updated_at_if_any_request_fails(mocker):
5960
# Given
60-
mocked_session = mocker.patch("src.cache.requests.Session")
61-
mocked_session.return_value.get.side_effect = [
62-
mocker.MagicMock(),
63-
requests.exceptions.HTTPError(),
61+
mocked_get = mocker.patch("src.cache.httpx.AsyncClient.get")
62+
mocked_get.side_effect = [
63+
httpx.ConnectTimeout("timeout"),
64+
unittest.mock.Mock(text='{"key2": "value2"}'),
6465
]
6566
cache_service = CacheService(settings)
6667

6768
# When
68-
cache_service.refresh()
69+
await cache_service.refresh()
6970

7071
# Then
7172
assert cache_service.last_updated_at is None
7273

7374

74-
def test_get_environment_works_correctly(mocker):
75+
@pytest.mark.asyncio
76+
async def test_get_environment_works_correctly(mocker):
7577
# Given
7678
cache_service = CacheService(settings)
7779
doc_1 = {"key1": "value1"}
@@ -83,7 +85,7 @@ def test_get_environment_works_correctly(mocker):
8385
)
8486

8587
# When
86-
cache_service.refresh()
88+
await cache_service.refresh()
8789

8890
# Next, test that get environment return correct document
8991
assert (

0 commit comments

Comments
 (0)