# How to offer sync and async clients from one Python SDK

> Write the client once as coroutines over httpx, generate the sync twin with unasync at build time, and pin both to one method set with a test.

Source: https://voxgig.com/howto/sync-and-async-clients-from-one-python-sdk

- Audience: api-producer
- Level: intermediate
- Languages: python
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Write `AsyncClient` once, as coroutines over `httpx.AsyncClient`, in a `_async` package. Generate `Client` from it with unasync, which drops every `async` and `await`, renames the double-underscore methods, and maps `AsyncClient` and `aclose` to the names httpx spells differently. Commit the output, then test that both classes expose the same methods with the same parameters and that a fresh unasync run reproduces the committed file.

---
## You will need

Python 3.11 or later, [httpx](https://www.python-httpx.org/advanced/clients/) for both
transports, and [unasync](https://unasync.readthedocs.io/en/latest/) at build time only,
because the generated package imports httpx and never unasync. Verified 2026-10-21 against
Python 3.13.16, httpx 0.28.1, unasync 0.6.0, stripe 16.0.0, and `openapi-python-client` 0.29.1.
The sample serves both clients from one `httpx.MockTransport`, and the pitfall script runs a
keep-alive server on the loopback interface to count connections, so nothing here reaches a
network. The `openapi-python-client` output under `generated/` was produced once from the sample's
OpenAPI document and committed.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [httpx Client and AsyncClient](https://www.python-httpx.org/advanced/clients/) | A handful of operations, written by hand over the pair httpx already ships | Every method exists twice, so every fix, default, and docstring lands twice, and the agreement test is yours to write | The operation count passes a dozen, or the two files have disagreed once already |
| [`openapi-python-client` sync and `asyncio` pairs](https://github.com/openapi-generators/openapi-python-client) | The API has an OpenAPI document and a module per endpoint is an acceptable shape | Four functions per endpoint, `sync`, `sync_detailed`, `asyncio`, and `asyncio_detailed`, called with a client argument rather than on one | You want `client.get_meter()` rather than `get_meter.sync(client=client)`, or there is no OpenAPI document |
| [Stripe Python `_async` methods](https://github.com/stripe/stripe-python) | One class for both conventions, where callers accept a suffix on every async call | Twice the methods on one class, over two HTTP libraries by default, `requests` for sync and httpx for async | The SDK is small enough that a second class costs less than a suffix on every call |
| [unasync](https://unasync.readthedocs.io/en/latest/) | One async source and a sync twin with identical names, regenerated on every build | A build step, a committed output reviewers must trust, and a replacement for every `Async` name httpx spells differently | The two halves must behave differently, or the package has no build step to hang it on |

`openapi-python-client` and Stripe both keep the two conventions side by side, as four functions
per endpoint module in one case and as an `_async` suffix on one class in the other.[^1] Both
outputs are generated, which is how their halves stay in step. A hand-written pair over httpx
has no generator behind it, so agreement rests on whoever makes the next edit. unasync gives
hand-written code the generated property, and charges a build step plus a replacement for every
name httpx spells differently.

## Write the client once, as coroutines

The async source is the only file anyone edits. It lives in `meterco/_async/`, and everything
that is not a coroutine lives outside that directory, so the generated package imports the
shared code instead of carrying a copy.

```py title="meterco/_async/client.py"
class AsyncClient:
    def __init__(
        self,
        api_key: str,
        base_url: str = "https://api.meterco.example/v1",
        transport: httpx.AsyncBaseTransport | None = None,
    ):
        self._http = httpx.AsyncClient(
            base_url=base_url,
            headers={"authorization": f"Bearer {api_key}", "user-agent": "meterco-python/1.0"},
            transport=transport,
        )

    async def __aenter__(self) -> "AsyncClient":
        return self

    async def __aexit__(self, *exc_info) -> None:
        await self.aclose()

    async def aclose(self) -> None:
        await self._http.aclose()

    async def get_meter(self, id: str) -> dict:
        return await self._request("GET", f"/meters/{segment(id)}")
```

Three choices in that file serve the generator. `MeterCoError` is imported from
`meterco/errors.py` rather than defined here, so both clients raise one class and a caller
catches it without knowing which client raised. The transport is a constructor argument, which
is what lets the tests serve both clients from one `MockTransport`. The client is a context
manager with an explicit `aclose`, because httpx's
[async documentation](https://www.python-httpx.org/async/) closes a client with `async with` or
`await client.aclose()`, and a client given neither keeps the connections it pooled. The pitfall
section counts them.

Every id goes through `segment`, which lives in `meterco/_paths.py` for the same reason as the
error class. It escapes `/`, `?`, and `#` and refuses an empty id, `.`, and `..`, so
`update_meter("../admin/keys", ...)` cannot send a `PATCH` to `/v1/admin/keys` with the key
attached.

## Generate the sync half with unasync

unasync works on tokens rather than on a parse tree.[^2] It deletes `async` and `await` together
with the whitespace after them, and renames the nine names in its default table, `__aenter__` to
`__enter__` among them.[^3] Any other name that starts with `Async` and a capital letter becomes
`Sync`. That last rule would write `httpx.SyncClient`, which does not exist, so the rule here
maps the names httpx spells differently.

```py title="build_sync.py"
RULE = unasync.Rule(
    "/_async/",
    "/_sync/",
    additional_replacements={
        "AsyncClient": "Client",
        "AsyncBaseTransport": "BaseTransport",
        "aclose": "close",
    },
)


def build(root: Path = HERE) -> list[Path]:
    sources = sorted((root / "meterco" / "_async").glob("*.py"))
    unasync.unasync_files([str(p) for p in sources], rules=[RULE])
    return [root / "meterco" / "_sync" / p.name for p in sources]
```

`unasync_files` is the entry point the
[documentation](https://unasync.readthedocs.io/en/latest/) gives for running outside
`setuptools`; the same `Rule` goes to `cmdclass_build_py` when a `setup.py` builds the package.
Run it after every edit to the source.

```bash
PYTHONPATH=vendor python3 build_sync.py
```

```text output
wrote meterco/_sync/__init__.py: 1 lines
wrote meterco/_sync/client.py: 49 lines
```

The output is the same file with the coroutine machinery removed.

```py title="meterco/_sync/client.py"
    def __enter__(self) -> "Client":
        return self

    def __exit__(self, *exc_info) -> None:
        self.close()

    def close(self) -> None:
        self._http.close()

    def get_meter(self, id: str) -> dict:
        return self._request("GET", f"/meters/{segment(id)}")
```

Commit it. A reader of the sync package sees ordinary Python, a type checker sees ordinary
signatures, and a reviewer sees the diff of the generated file beside the diff of the source.
Docstrings survive untouched, because unasync applies its renaming to a string's whole text
rather than to the words inside it, so write them to read correctly in both packages.

## Compare the method tables

The other two designs are measured rather than described. The script walks a module that
`openapi-python-client` generated from the sample's OpenAPI document, a `StripeClient` service
from the installed stripe package, and the two classes from this page.

```bash
PYTHONPATH=vendor python3 compare.py
```

```text output
openapi-python-client, module meter_co_api_client/api/meters/get_meter.py
  async  asyncio
  async  asyncio_detailed
  sync   sync
  sync   sync_detailed
  one Client class carries both httpx clients: get_httpx_client(), get_async_httpx_client()

stripe 16.0.0, StripeClient('sk_test_x').v1.customers
  sync  create           async  create_async
  sync  delete           async  delete_async
  sync  delete_discount  async  delete_discount_async
  sync  list             async  list_async
  sync  retrieve         async  retrieve_async
  sync  search           async  search_async
  sync  update           async  update_async
  7 pairs on one class, 0 methods without a twin

meterco, this page: meterco._async.client.AsyncClient and the unasync output meterco._sync.client.Client
  AsyncClient  ['__aenter__', '__aexit__', 'aclose', 'get_meter', 'list_meters', 'update_meter']
  Client       ['__enter__', '__exit__', 'close', 'get_meter', 'list_meters', 'update_meter']
```

`openapi-python-client` answers the question per endpoint: one module, four functions, and a
single `Client` whose `get_httpx_client` and `get_async_httpx_client` hold one httpx client of
each kind. Stripe answers it per method: seven operations on the customers service, each with an
`_async` twin on the same class, which its [README](https://github.com/stripe/stripe-python)
describes as suffixing the method name with `_async`. The unasync output answers it per class,
with identical names, and a caller switches by changing one import.

## Check it worked

Run both clients against one transport first. `httpx.MockTransport` is a `BaseTransport` and an
`AsyncBaseTransport` at once, so one handler serves both.

```bash
PYTHONPATH=vendor python3 demo.py
```

```text output
AsyncClient  get_meter('mtr_1')              {'id': 'mtr_1', 'serial': 'SN-40199', 'state': 'installed'}
AsyncClient  list_meters(state='installed')  ['mtr_1']
AsyncClient  get_meter('mtr_404')            raises meterco: 404
Client       get_meter('mtr_1')              {'id': 'mtr_1', 'serial': 'SN-40199', 'state': 'installed'}
Client       list_meters(state='installed')  ['mtr_1']
Client       get_meter('mtr_404')            raises meterco: 404

the two classes, method by method
  async def __aenter__()                                                 def __enter__()
  async def __aexit__(*exc_info)                                         def __exit__(*exc_info)
  async def _request(method: 'str', path: 'str', **kwargs)               def _request(method: 'str', path: 'str', **kwargs)
  async def aclose()                                                     def close()
  async def get_meter(id: 'str')                                         def get_meter(id: 'str')
  async def list_meters(state: 'str | None' = None, limit: 'int' = 20)   def list_meters(state: 'str | None' = None, limit: 'int' = 20)
  async def update_meter(id: 'str', state: 'str')                        def update_meter(id: 'str', state: 'str')
```

Then run the tests. The two that matter walk the classes with
[`inspect.signature`](https://docs.python.org/3/library/inspect.html#inspect.signature) and
regenerate the sync package into a temporary directory.

```py title="test_clients.py"
def test_every_pair_has_the_same_parameters():
    for a_name, fn in methods(AsyncClient).items():
        s_name = RENAMED.get(a_name, a_name)
        assert s_name in methods(Client), f"{s_name} missing from Client"
        assert without_return(fn) == without_return(methods(Client)[s_name]), a_name
```

```py title="test_clients.py"
def test_committed_sync_files_are_what_unasync_writes():
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        shutil.copytree(HERE / "meterco", root / "meterco", ignore=shutil.ignore_patterns("__pycache__", "_sync"))
        for written in build_sync.build(root):
            committed = HERE / "meterco" / "_sync" / written.name
            assert written.read_bytes() == committed.read_bytes(), f"{committed.name} is stale: run build_sync.py"
```

```bash
PYTHONPATH=vendor python3 test_clients.py
```

```text output
ok test_public_method_sets_match
ok test_every_pair_has_the_same_parameters
ok test_every_method_is_a_coroutine_on_one_side_only
ok test_both_clients_give_the_same_answer_through_one_transport
ok test_both_clients_raise_the_same_error
ok test_an_id_is_one_path_segment
ok test_committed_sync_files_are_what_unasync_writes
7 cases, 0 failures
```

`RENAMED` holds the three names allowed to differ, and the first test refuses any other
difference. The last test catches a hand edit to the generated file. The committed bytes have to
equal what a fresh run writes, so a fix made in `_sync` fails the build until it is made in
`_async` and regenerated.

## When it goes wrong

`pitfall.py` shows three failures, the first two against a keep-alive server on the loopback
interface that counts the connections it has open.

```bash
PYTHONPATH=vendor python3 pitfall.py
```

```text output
asyncio.run around one AsyncClient, two calls
  call 1: mtr_1
  call 2: RuntimeError: Event loop is closed

holding clients open
  5 clients dropped, no aclose:       5 connections open on the server
  the same 5 after gc.collect():      0 connections open on the server
  5 calls, 5 clients, async with:     0 connections open on the server

unasync with no additional_replacements
  class SyncClient:
  transport: httpx.SyncBaseTransport | None = None,
  self._http = httpx.SyncClient(
  def __enter__(self) -> "SyncClient":
  self.aclose()
  def aclose(self) -> None:
  self._http.aclose()
  constructing it: AttributeError: module 'httpx' has no attribute 'SyncClient'
```

The first is the shortcut that avoids a second class altogether: keep one `AsyncClient` and
wrap each call in `asyncio.run`. The first call succeeds.
[`asyncio.run`](https://docs.python.org/3/library/asyncio-runner.html#asyncio.run) creates a
new event loop and closes it at the end. The connection pooled during the first call belongs to
a loop that is gone, so the second call fails with `Event loop is closed`. A sync client
built that way answers one call and fails on the next.

The second is the client nobody closes. Five clients made five calls and were dropped without
`aclose`. The server held all five connections open until `gc.collect()` reached the clients,
because nothing else closes a dropped client's pool. The script pauses automatic collection for
that count, so it does not depend on when the collector would run. The same five calls under
`async with` left none open. A client created per request and never closed holds one connection
per request until the collector runs, instead of reusing one.

The third is unasync's default rules on the same source. With no `additional_replacements`, the
prefix rule writes `httpx.SyncClient` and `httpx.SyncBaseTransport`, and `aclose` keeps its
name. The file imports cleanly and fails on the first construction with
`AttributeError: module 'httpx' has no attribute 'SyncClient'`, which is why the test suite
constructs the sync client rather than only inspecting it.

## When not to do this

Do not generate the sync half when the two halves need to behave differently. unasync produces
a line-for-line twin, so a sync client that should block on a thread pool, batch its calls, or
skip a feature the async client has cannot come out of it. Write two classes then, and keep the
method-set test so they agree on names at least.

Do not wrap coroutines in `asyncio.run` to get a sync API without a second class. The pitfall
section shows it failing on its second call. The runner also refuses to start inside a caller that
already has an event loop running in the same thread, which rules out every async framework
your users might be on.

Do not adopt the build step for a client with three methods. The committed output and the drift
test cost more than writing the second class by hand, and the method-set test works as well on
two hand-written files as on a generated pair.

Calling the generated file read-only means little without the test that makes it so. Without
the byte comparison, the first urgent fix lands in `_sync`, the next regeneration erases it, and the
bug returns with no diff to explain why.

## Related how-tos

- [Give a Ruby SDK keyword arguments and Faraday middleware](/howto/ruby-sdk-keyword-arguments-and-faraday-middleware)

- [Build a PHP SDK that takes any PSR-18 HTTP client](/howto/php-sdk-on-psr-18-http-clients)

- [Wrap an SDK transport in a middleware chain](/howto/wrap-an-sdk-transport-with-middleware)

## Last verified

Verified 2026-10-21 against Python 3.13.16, httpx 0.28.1, unasync 0.6.0, stripe 16.0.0, and
`openapi-python-client` 0.29.1. Every output block is what the command preceding it printed. The
`openapi-python-client` module under `generated/` was generated once and committed, and the stripe
figures come from walking the installed package, with no request made to Stripe.

[^1]: The two generators agree on the mechanism and differ on the spelling. `openapi-python-client`
names its coroutine `asyncio`, after the [standard library module](https://docs.python.org/3/library/asyncio.html)
that runs it, and its blocking function `sync`, a word Python itself does not use for the
purpose. Stripe appends `_async`, so its sync method carries no marker and its async method
carries a suffix the language does not read. A caller who uses both SDKs learns both spellings.

[^2]: unasync is kept by the [python-trio organization](https://github.com/python-trio/unasync),
and its README traces the idea to a urllib3 issue. The same README lists three users: the
official Elasticsearch Python client, Hip, and httpcore, which it says uses a different
implementation of the same idea. [httpcore](https://github.com/encode/httpcore) describes itself
as the layer beneath a higher level client library such as httpx. So the `httpx.Client` this page
generates a twin of is itself served by a sync package written from an async one, and the
technique sits two layers deep in its own comparison.

[^3]: [PEP 492](https://peps.python.org/pep-0492/), created in April 2015 for Python 3.5, added
the `async def` and `await` syntax and the magic methods `__aenter__`, `__aexit__`, `__aiter__`,
and `__anext__`. Those four are nearly half of unasync's default table of nine entries; the rest
cover `asynccontextmanager`, three names from `typing`, and `StopAsyncIteration`. Everything else
the tool knows is a prefix rule: a name beginning `Async` followed by a capital letter becomes
`Sync`, whether or not anything by that name exists.