Skip to content

Commit 5a7cb97

Browse files
committed
updates redis lifespan
1 parent 0137533 commit 5a7cb97

File tree

3 files changed

+217
-0
lines changed

3 files changed

+217
-0
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import asyncio
2+
import logging
3+
from collections.abc import AsyncIterator
4+
from typing import Annotated
5+
6+
from fastapi import FastAPI
7+
from fastapi_lifespan_manager import State
8+
from pydantic import BaseModel, ConfigDict, StringConstraints, ValidationError
9+
from servicelib.logging_utils import log_catch, log_context
10+
from settings_library.redis import RedisDatabase, RedisSettings
11+
12+
from ..redis import RedisClientSDK
13+
from .lifespan_utils import LifespanOnStartupError
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
class RedisConfigurationError(LifespanOnStartupError):
19+
msg_template = "Invalid redis config on startup : {validation_error}"
20+
21+
22+
class RedisLifespanState(BaseModel):
23+
REDIS_SETTINGS: RedisSettings
24+
REDIS_CLIENT_NAME: Annotated[str, StringConstraints(min_length=3, max_length=32)]
25+
REDIS_CLIENT_DB: RedisDatabase
26+
27+
model_config = ConfigDict(
28+
extra="allow",
29+
arbitrary_types_allowed=True, # RedisClientSDK has some arbitrary types and this class will never be serialized
30+
)
31+
32+
33+
async def redis_database_lifespan(_: FastAPI, state: State) -> AsyncIterator[State]:
34+
35+
with log_context(_logger, logging.INFO, f"{__name__}"):
36+
37+
# Validate input state
38+
try:
39+
redis_state = RedisLifespanState.model_validate(state)
40+
redis_dsn_with_secrets = redis_state.REDIS_SETTINGS.build_redis_dsn(
41+
redis_state.REDIS_CLIENT_DB
42+
)
43+
44+
except ValidationError as exc:
45+
raise RedisConfigurationError(validation_error=exc, state=state) from exc
46+
47+
# Setup client
48+
with log_context(
49+
_logger,
50+
logging.INFO,
51+
f"Creating redis client with name={redis_state.REDIS_CLIENT_NAME}",
52+
):
53+
54+
redis_client = RedisClientSDK(
55+
redis_dsn_with_secrets,
56+
client_name=redis_state.REDIS_CLIENT_NAME,
57+
)
58+
59+
try:
60+
61+
yield {"REDIS_CLIENT_SDK": redis_client}
62+
63+
finally:
64+
# Teardown client
65+
if redis_client:
66+
with log_catch(_logger, reraise=False):
67+
await asyncio.wait_for(
68+
redis_client.shutdown(),
69+
# NOTE: shutdown already has a _HEALTHCHECK_TASK_TIMEOUT_S of 10s
70+
timeout=20,
71+
)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
4+
# pylint: disable=unused-argument
5+
# pylint: disable=unused-variable
6+
7+
from collections.abc import AsyncIterator
8+
from typing import Annotated, Any
9+
10+
import pytest
11+
import servicelib.fastapi.redis_lifespan
12+
from asgi_lifespan import LifespanManager as ASGILifespanManager
13+
from fastapi import FastAPI
14+
from fastapi_lifespan_manager import LifespanManager, State
15+
from pydantic import Field
16+
from pytest_mock import MockerFixture, MockType
17+
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
18+
from pytest_simcore.helpers.typing_env import EnvVarsDict
19+
from servicelib.fastapi.redis_lifespan import (
20+
RedisConfigurationError,
21+
RedisLifespanState,
22+
redis_database_lifespan,
23+
)
24+
from settings_library.application import BaseApplicationSettings
25+
from settings_library.redis import RedisDatabase, RedisSettings
26+
27+
28+
@pytest.fixture
29+
def mock_redis_client_sdk(mocker: MockerFixture) -> MockType:
30+
return mocker.patch.object(
31+
servicelib.fastapi.redis_lifespan,
32+
"RedisClientSDK",
33+
return_value=mocker.AsyncMock(),
34+
)
35+
36+
37+
@pytest.fixture
38+
def app_environment(monkeypatch: pytest.MonkeyPatch) -> EnvVarsDict:
39+
return setenvs_from_dict(
40+
monkeypatch, RedisSettings.model_json_schema()["examples"][0]
41+
)
42+
43+
44+
@pytest.fixture
45+
def app_lifespan(
46+
app_environment: EnvVarsDict,
47+
mock_redis_client_sdk: MockType,
48+
) -> LifespanManager:
49+
assert app_environment
50+
51+
class AppSettings(BaseApplicationSettings):
52+
CATALOG_REDIS: Annotated[
53+
RedisSettings,
54+
Field(json_schema_extra={"auto_default_from_env": True}),
55+
]
56+
57+
async def my_app_settings(app: FastAPI) -> AsyncIterator[State]:
58+
app.state.settings = AppSettings.create_from_envs()
59+
60+
yield RedisLifespanState(
61+
REDIS_SETTINGS=app.state.settings.CATALOG_REDIS,
62+
REDIS_CLIENT_NAME="test_client",
63+
REDIS_CLIENT_DB=RedisDatabase.LOCKS,
64+
).model_dump()
65+
66+
app_lifespan = LifespanManager()
67+
app_lifespan.add(my_app_settings)
68+
app_lifespan.add(redis_database_lifespan)
69+
70+
assert not mock_redis_client_sdk.called
71+
72+
return app_lifespan
73+
74+
75+
async def test_lifespan_redis_database_in_an_app(
76+
is_pdb_enabled: bool,
77+
app_environment: EnvVarsDict,
78+
mock_redis_client_sdk: MockType,
79+
app_lifespan: LifespanManager,
80+
):
81+
app = FastAPI(lifespan=app_lifespan)
82+
83+
async with ASGILifespanManager(
84+
app,
85+
startup_timeout=None if is_pdb_enabled else 10,
86+
shutdown_timeout=None if is_pdb_enabled else 10,
87+
) as asgi_manager:
88+
# Verify that the Redis client SDK was created
89+
mock_redis_client_sdk.assert_called_once_with(
90+
app.state.settings.CATALOG_REDIS.build_redis_dsn(RedisDatabase.LOCKS),
91+
client_name="test_client",
92+
)
93+
94+
# Verify that the Redis client SDK is in the lifespan manager state
95+
assert "REDIS_CLIENT_SDK" in asgi_manager._state # noqa: SLF001
96+
assert app.state.settings.CATALOG_REDIS
97+
assert (
98+
asgi_manager._state["REDIS_CLIENT_SDK"] # noqa: SLF001
99+
== mock_redis_client_sdk.return_value
100+
)
101+
102+
# Verify that the Redis client SDK was shut down
103+
redis_client: Any = mock_redis_client_sdk.return_value
104+
redis_client.shutdown.assert_called_once()
105+
106+
107+
async def test_lifespan_redis_database_with_invalid_settings(
108+
is_pdb_enabled: bool,
109+
):
110+
async def my_app_settings(app: FastAPI) -> AsyncIterator[State]:
111+
yield {"REDIS_SETTINGS": None}
112+
113+
app_lifespan = LifespanManager()
114+
app_lifespan.add(my_app_settings)
115+
app_lifespan.add(redis_database_lifespan)
116+
117+
app = FastAPI(lifespan=app_lifespan)
118+
119+
with pytest.raises(RedisConfigurationError, match="Invalid redis") as excinfo:
120+
async with ASGILifespanManager(
121+
app,
122+
startup_timeout=None if is_pdb_enabled else 10,
123+
shutdown_timeout=None if is_pdb_enabled else 10,
124+
):
125+
...
126+
127+
exception = excinfo.value
128+
assert isinstance(exception, RedisConfigurationError)
129+
assert exception.validation_error
130+
assert exception.state["REDIS_SETTINGS"] is None

packages/settings-library/src/settings_library/redis.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pydantic.networks import RedisDsn
44
from pydantic.types import SecretStr
5+
from pydantic_settings import SettingsConfigDict
56

67
from .base import BaseCustomSettings
78
from .basic_types import PortInt
@@ -45,3 +46,18 @@ def build_redis_dsn(self, db_index: RedisDatabase) -> str:
4546
path=f"{db_index}",
4647
)
4748
)
49+
50+
model_config = SettingsConfigDict(
51+
json_schema_extra={
52+
"examples": [
53+
# minimal required
54+
{
55+
"REDIS_SECURE": "0",
56+
"REDIS_HOST": "localhost",
57+
"REDIS_PORT": "6379",
58+
"REDIS_USER": "user",
59+
"REDIS_PASSWORD": "secret",
60+
}
61+
],
62+
}
63+
)

0 commit comments

Comments
 (0)