Files
2026-06-01 21:42:05 +00:00

118 lines
4.1 KiB
Markdown

# Async Boundaries
Keep sync and async APIs as separate, explicit surfaces when their semantics differ.
## Why
Async stops being an implementation detail as soon as it changes:
- how resources are acquired and released
- whether methods must be awaited
- which transport or session types are valid
- how cancellation behaves
- whether the caller needs an event loop
Trying to hide that behind one magical API usually makes things worse. The common failure mode is a fake sync wrapper over async internals: it breaks inside existing event loops, hides resource lifetime, and muddies cancellation.
Mature libraries usually accept the split and make it visible.
## The pattern
1. Expose separate sync and async entrypoints when semantics differ.
2. Keep their shapes parallel where that helps learnability.
3. Keep resource and transport types distinct.
4. Make lifecycle visible with normal sync and async context management.
5. Do not smuggle event-loop control into a sync-looking API.
## When to use
Use this when:
- your library owns network, database, filesystem, or other long-lived resources
- sync and async variants need different transport or session implementations
- callers need predictable lifetime control
- the library must work in many runtime environments
## When not to use
Do not split APIs just for symmetry.
Skip the split when:
- async adds no meaningful semantic difference
- the operation is trivial and one-shot
- a separate async API would mostly duplicate noise
But if the alternative requires hidden `asyncio.run(...)`, loop-detection tricks, or silent runtime switching, the split is probably the cleaner design.
## Preferred shape
```python
class Client:
def get(self, url: str) -> Response:
...
class AsyncClient:
async def get(self, url: str) -> Response:
...
```
Make the surfaces parallel enough to learn once, but distinct enough that their lifecycle stays honest.
## Why this works
- callers immediately know whether `await` is involved
- transport types stay correct
- connection pooling and cleanup remain visible
- cancellation behavior is not hidden behind sync-looking calls
## Counterexamples
### Fake sync wrapper over async internals
```python
def get(url: str) -> Response:
return asyncio.run(_async_get(url))
```
This breaks in environments that already have an event loop and hides lifecycle costs.
### One class with mode flags
```python
client = Client(async_mode=True)
```
Now methods, cleanup, and caller expectations depend on ambient configuration instead of the type.
### Shared transport types that are not really shared
If one path needs `BaseTransport` and the other needs `AsyncBaseTransport`, pretending they are interchangeable is lying to the caller.
## Source signals
### HTTPX
- `httpx/_client.py:594-661` defines `Client` as the sync entrypoint and documents that it can be shared between threads.
- `httpx/_client.py:639-660` types the sync constructor in sync-native terms, including `BaseTransport`.
- `httpx/_client.py:1275-1304` uses normal sync context management for lifecycle.
- `httpx/_client.py:1307-1375` defines `AsyncClient` separately and documents task-sharing semantics.
- `httpx/_client.py:1316-1318` shows async usage with `async with` and `await`.
- `httpx/_client.py:1353-1374` keeps the constructor parallel but switches to async-native types like `AsyncBaseTransport`.
- `httpx/_client.py:1445-1452` initializes `AsyncHTTPTransport` on the async path rather than pretending the sync transport is reusable.
### SQLAlchemy
- `examples/asyncio/async_orm.py:15-18` imports async-specific engine and session primitives.
- `examples/asyncio/async_orm.py:61-67` creates an `async_sessionmaker(...)` and enters explicit async session and transaction scopes.
- `examples/asyncio/async_orm.py:78-104` uses async-native query and commit methods.
- `examples/inheritance/joined.py:16` imports the sync `Session` separately.
- `examples/inheritance/joined.py:93-120` shows the corresponding sync session lifetime and explicit commit boundary.
## Bottom line
If sync and async usage have different semantics, give them different types.
Parallel APIs are good.
Pretending the difference is not there is not.