-
Notifications
You must be signed in to change notification settings - Fork 29
🎨 Add Reusable Lifespan Contexts for RabbitMQ and Redis in servicelib.fastapi
#7547
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
pcrespov
merged 10 commits into
ITISFoundation:master
from
pcrespov:mai/extends-lifespan
Apr 17, 2025
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
fd9055f
✨ Enhance lifespan error handling and add lifespan call tracking
pcrespov 16cd4a1
✨ Add JSON schema examples to RedisSettings model configuration
pcrespov 9fed19b
✨ Implement Redis lifespan management and validation in FastAPI
pcrespov cf8c1f1
✨ Add lifespan call tracking for PostgreSQL database in FastAPI
pcrespov f5c7438
✨ Add RabbitMQ lifespan management and validation in FastAPI
pcrespov cd98e87
✨ Refactor lifespan utility functions and enhance error handling in F…
pcrespov b7a9039
✨ Refactor lifespan management by introducing a context manager for l…
pcrespov f7f3b24
✨ Refactor import statements and update deprecated function usage in …
pcrespov 3370053
@bisgaard-itis review: doc
pcrespov 4a16eed
Merge branch 'master' into mai/extends-lifespan
pcrespov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
69 changes: 67 additions & 2 deletions
69
packages/service-library/src/servicelib/fastapi/lifespan_utils.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,77 @@ | ||
import contextlib | ||
from collections.abc import Iterator | ||
from typing import Final | ||
|
||
from common_library.errors_classes import OsparcErrorMixin | ||
from fastapi import FastAPI | ||
from fastapi_lifespan_manager import State | ||
|
||
from ..logging_utils import log_context | ||
|
||
|
||
class LifespanError(OsparcErrorMixin, RuntimeError): ... | ||
|
||
|
||
class LifespanOnStartupError(LifespanError): | ||
msg_template = "Failed during startup of {module}" | ||
msg_template = "Failed during startup of {lifespan_name}" | ||
|
||
|
||
class LifespanOnShutdownError(LifespanError): | ||
msg_template = "Failed during shutdown of {module}" | ||
msg_template = "Failed during shutdown of {lifespan_name}" | ||
|
||
|
||
class LifespanAlreadyCalledError(LifespanError): | ||
msg_template = "The lifespan '{lifespan_name}' has already been called." | ||
|
||
|
||
class LifespanExpectedCalledError(LifespanError): | ||
msg_template = "The lifespan '{lifespan_name}' was not called. Ensure it is properly configured and invoked." | ||
|
||
|
||
_CALLED_LIFESPANS_KEY: Final[str] = "_CALLED_LIFESPANS" | ||
|
||
|
||
def is_lifespan_called(state: State, lifespan_name: str) -> bool: | ||
# NOTE: This assert is meant to catch a common mistake: | ||
# The `lifespan` function should accept up to two *optional* positional arguments: (app: FastAPI, state: State). | ||
# Valid signatures include: `()`, `(app)`, `(app, state)`, or even `(_, state)`. | ||
# It's easy to accidentally swap or misplace these arguments. | ||
assert not isinstance( # nosec | ||
state, FastAPI | ||
), "Did you swap arguments? `lifespan(app, state)` expects (app: FastAPI, state: State)" | ||
|
||
called_lifespans = state.get(_CALLED_LIFESPANS_KEY, set()) | ||
return lifespan_name in called_lifespans | ||
|
||
|
||
def mark_lifespace_called(state: State, lifespan_name: str) -> State: | ||
"""Validates if a lifespan has already been called and records it in the state. | ||
Raises LifespanAlreadyCalledError if the lifespan has already been called. | ||
""" | ||
if is_lifespan_called(state, lifespan_name): | ||
raise LifespanAlreadyCalledError(lifespan_name=lifespan_name) | ||
|
||
called_lifespans = state.get(_CALLED_LIFESPANS_KEY, set()) | ||
called_lifespans.add(lifespan_name) | ||
return {_CALLED_LIFESPANS_KEY: called_lifespans} | ||
|
||
|
||
def ensure_lifespan_called(state: State, lifespan_name: str) -> None: | ||
"""Ensures that a lifespan has been called. | ||
Raises LifespanNotCalledError if the lifespan has not been called. | ||
""" | ||
if not is_lifespan_called(state, lifespan_name): | ||
raise LifespanExpectedCalledError(lifespan_name=lifespan_name) | ||
|
||
|
||
@contextlib.contextmanager | ||
def lifespan_context( | ||
logger, level, lifespan_name: str, state: State | ||
) -> Iterator[State]: | ||
"""Helper context manager to log lifespan event and mark lifespan as called.""" | ||
|
||
with log_context(logger, level, lifespan_name): | ||
# Check if lifespan has already been called | ||
called_state = mark_lifespace_called(state, lifespan_name) | ||
|
||
yield called_state |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
47 changes: 47 additions & 0 deletions
47
packages/service-library/src/servicelib/fastapi/rabbitmq_lifespan.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import logging | ||
from collections.abc import AsyncIterator | ||
|
||
from fastapi import FastAPI | ||
from fastapi_lifespan_manager import State | ||
from pydantic import BaseModel, ValidationError | ||
from settings_library.rabbit import RabbitSettings | ||
|
||
from ..rabbitmq import wait_till_rabbitmq_responsive | ||
from .lifespan_utils import ( | ||
LifespanOnStartupError, | ||
lifespan_context, | ||
) | ||
|
||
_logger = logging.getLogger(__name__) | ||
|
||
|
||
class RabbitMQConfigurationError(LifespanOnStartupError): | ||
msg_template = "Invalid RabbitMQ config on startup : {validation_error}" | ||
|
||
|
||
class RabbitMQLifespanState(BaseModel): | ||
RABBIT_SETTINGS: RabbitSettings | ||
|
||
|
||
async def rabbitmq_connectivity_lifespan( | ||
_: FastAPI, state: State | ||
) -> AsyncIterator[State]: | ||
"""Ensures RabbitMQ connectivity during lifespan. | ||
|
||
For creating clients, use additional lifespans like rabbitmq_rpc_client_context. | ||
""" | ||
_lifespan_name = f"{__name__}.{rabbitmq_connectivity_lifespan.__name__}" | ||
|
||
with lifespan_context(_logger, logging.INFO, _lifespan_name, state) as called_state: | ||
|
||
# Validate input state | ||
try: | ||
rabbit_state = RabbitMQLifespanState.model_validate(state) | ||
rabbit_dsn_with_secrets = rabbit_state.RABBIT_SETTINGS.dsn | ||
except ValidationError as exc: | ||
raise RabbitMQConfigurationError(validation_error=exc, state=state) from exc | ||
|
||
# Wait for RabbitMQ to be responsive | ||
await wait_till_rabbitmq_responsive(rabbit_dsn_with_secrets) | ||
|
||
yield {"RABBIT_CONNECTIVITY_LIFESPAN_NAME": _lifespan_name, **called_state} | ||
pcrespov marked this conversation as resolved.
Show resolved
Hide resolved
|
64 changes: 64 additions & 0 deletions
64
packages/service-library/src/servicelib/fastapi/redis_lifespan.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import asyncio | ||
import logging | ||
from collections.abc import AsyncIterator | ||
from typing import Annotated | ||
|
||
from fastapi import FastAPI | ||
from fastapi_lifespan_manager import State | ||
from pydantic import BaseModel, StringConstraints, ValidationError | ||
from settings_library.redis import RedisDatabase, RedisSettings | ||
|
||
from ..logging_utils import log_catch, log_context | ||
from ..redis import RedisClientSDK | ||
from .lifespan_utils import LifespanOnStartupError, lifespan_context | ||
|
||
_logger = logging.getLogger(__name__) | ||
|
||
|
||
class RedisConfigurationError(LifespanOnStartupError): | ||
msg_template = "Invalid redis config on startup : {validation_error}" | ||
|
||
|
||
class RedisLifespanState(BaseModel): | ||
REDIS_SETTINGS: RedisSettings | ||
REDIS_CLIENT_NAME: Annotated[str, StringConstraints(min_length=3, max_length=32)] | ||
REDIS_CLIENT_DB: RedisDatabase | ||
|
||
|
||
async def redis_client_sdk_lifespan(_: FastAPI, state: State) -> AsyncIterator[State]: | ||
_lifespan_name = f"{__name__}.{redis_client_sdk_lifespan.__name__}" | ||
|
||
with lifespan_context(_logger, logging.INFO, _lifespan_name, state) as called_state: | ||
|
||
# Validate input state | ||
try: | ||
redis_state = RedisLifespanState.model_validate(state) | ||
redis_dsn_with_secrets = redis_state.REDIS_SETTINGS.build_redis_dsn( | ||
redis_state.REDIS_CLIENT_DB | ||
) | ||
except ValidationError as exc: | ||
raise RedisConfigurationError(validation_error=exc, state=state) from exc | ||
|
||
# Setup client | ||
with log_context( | ||
_logger, | ||
logging.INFO, | ||
f"Creating redis client with name={redis_state.REDIS_CLIENT_NAME}", | ||
): | ||
# NOTE: sdk integrats waiting until connection is ready | ||
# and will raise an exception if it cannot connect | ||
redis_client = RedisClientSDK( | ||
redis_dsn_with_secrets, | ||
client_name=redis_state.REDIS_CLIENT_NAME, | ||
) | ||
|
||
try: | ||
yield {"REDIS_CLIENT_SDK": redis_client, **called_state} | ||
finally: | ||
# Teardown client | ||
with log_catch(_logger, reraise=False): | ||
await asyncio.wait_for( | ||
redis_client.shutdown(), | ||
# NOTE: shutdown already has a _HEALTHCHECK_TASK_TIMEOUT_S of 10s | ||
timeout=20, | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.