Working with async
As explained in Getting Started, Rodi's objective is to
simplify constructing objects based on constructors and class properties.
Support for async resolution is intentionally out of the scope of the library because
constructing objects should be lightweight.
This page provides guidelines for working with objects that require asynchronous
initialization or disposal.
A common example
A common example of objects requiring asynchronous disposal are objects that
handle TCP/IP connection pooling, such as HTTP
clients and database clients.
These objects are typically implemented as context managers in Python because
they need to manage connection pooling and gracefully close TCP connections
upon disposal.
Python provides asynchronous
context managers for this kind of scenario.
Consider the following example, of a SendGrid
API client to send emails using the
SendGrid API, with asynchronous code and using httpx
.
| # domain/emails.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
# TODO: use Pydantic for the Email object.
@dataclass
class Email:
recipients: list[str]
sender: str
sender_name: str
subject: str
body: str
cc: list[str] = None
bcc: list[str] = None
class EmailHandler(ABC): # interface
@abstractmethod
async def send(self, email: Email) -> None:
pass
|
| # data/apis/sendgrid.py
import os
from dataclasses import dataclass
import httpx
from domain.emails import Email, EmailHandler
@dataclass
class SendGridClientSettings:
api_key: str
@classmethod
def from_env(cls):
api_key = os.environ.get("SENDGRID_API_KEY")
if not api_key:
raise ValueError("SENDGRID_API_KEY environment variable is required")
return cls(api_key=api_key)
class SendGridClient(EmailHandler):
def __init__(
self, settings: SendGridClientSettings, http_client: httpx.AsyncClient
):
if not settings.api_key:
raise ValueError("API key is required")
self.http_client = http_client
self.api_key = settings.api_key
async def send(self, email: Email) -> None:
response = await self.http_client.post(
"https://api.sendgrid.com/v3/mail/send",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=self.get_body(email),
)
# TODO: in case of error, log response.text
response.raise_for_status() # Raise an error for bad responses
def get_body(self, email: Email) -> dict:
return {
"personalizations": [
{
"to": [{"email": recipient} for recipient in email.recipients],
"subject": email.subject,
"cc": [{"email": cc} for cc in email.cc] if email.cc else None,
"bcc": [{"email": bcc} for bcc in email.bcc] if email.bcc else None,
}
],
"from": {"email": email.sender, "name": email.sender_name},
"content": [{"type": "text/html", "value": email.body}],
}
|
The official SendGrid Python SDK does not support async.
At the time of this writing, the official SendGrid Python SDK does not support async
.
Its documentation provides a wrong example for async
code (see issue #988).
The SendGrid REST API is very well documented and comfortable to use! Use a class like
the one shown on this page to send emails using SendGrid in async code.
The SendGridClient depends on an instance of SendGridClientSettings
(providing a
SendGrid API Key), and on an instance of httpx.AsyncClient
to make async HTTP requests.
The code below shows how to register the object that requires asynchronous
initialization and use it across the lifetime of your application.
| # main.py
import asyncio
from contextlib import asynccontextmanager
import httpx
from rodi import Container
from data.apis.sendgrid import SendGridClient, SendGridClientSettings
from domain.emails import EmailHandler
@asynccontextmanager
async def register_http_client(container: Container):
async with httpx.AsyncClient() as http_client:
print("HTTP client initialized")
container.add_instance(http_client)
yield
print("HTTP client disposed")
async def application_runtime(container: Container):
# Entry point for what your application does
email_handler = container.resolve(EmailHandler)
assert isinstance(email_handler, SendGridClient)
assert isinstance(email_handler.http_client, httpx.AsyncClient)
# We can use the HTTP Client during the lifetime of the Application
print("All is good! ✨")
def sendgrid_settings_factory() -> SendGridClientSettings:
return SendGridClientSettings.from_env()
async def main():
# Bootstrap code for the application
container = Container()
container.add_singleton_by_factory(sendgrid_settings_factory)
container.add_singleton(EmailHandler, SendGridClient)
async with register_http_client(container) as http_client:
container.add_instance(
http_client
) # <-- Configure the HTTP client as singleton
await application_runtime(container)
if __name__ == "__main__":
asyncio.run(main())
|
The above code displays the following:
$ SENDGRID_API_KEY="***" python main.py
HTTP client initialized
All is good! ✨
HTTP client disposed
Considerations
- It is not Rodi's responsibility to administer the lifecycle of the
application. It is the responsibility of the code that bootstraps the
application, to handle objects that require asynchronous initialization and
disposal.
- Python's
asynccontextmanager
is convenient for these scenarios.
- In the example above, the HTTP Client is configured as singleton to benefit from TCP
connection pooling. It would also be possible to configure it as transient or scoped
service, as long as all instances share the same connection pool. In the case of
httpx
, you can read on this subject here: Why use a Client?.
- Dependency Injection likes custom classes to describe settings for types,
because registering simple types (
str
, int
, float
, etc.) in the container does
not scale and should be avoided.
The next page explains how Rodi handles context managers.
Last modified on: 2025-04-17 07:04:37