How-to › Ship an SDK

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.

Audience
API producer
Level
intermediate
Topic
Design SDK ergonomics per language
Languages
Python
Verified

Your Python SDK ships Client and AsyncClient as two hand-written files with the same methods. A fix lands in the sync get_meter, but the async copy keeps the bug for a release because nobody edits both files. The two already disagree on the default page size, which no test can see because each suite reads one file.

What you get

You will end up with one async source, a sync package that unasync writes from it, and a test that fails when the two classes drift apart. This is for you if your Python SDK runs over httpx and needs both calling conventions.

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 for both transports, and unasync 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

ApproachWhen it fitsWhat it costs youWhen to pick something else
httpx Client and AsyncClientA handful of operations, written by hand over the pair httpx already shipsEvery method exists twice, so every fix, default, and docstring lands twice, and the agreement test is yours to writeThe operation count passes a dozen, or the two files have disagreed once already
openapi-python-client sync and asyncio pairsThe API has an OpenAPI document and a module per endpoint is an acceptable shapeFour functions per endpoint, sync, sync_detailed, asyncio, and asyncio_detailed, called with a client argument rather than on oneYou want client.get_meter() rather than get_meter.sync(client=client), or there is no OpenAPI document
Stripe Python _async methodsOne class for both conventions, where callers accept a suffix on every async callTwice the methods on one class, over two HTTP libraries by default, requests for sync and httpx for asyncThe SDK is small enough that a second class costs less than a suffix on every call
unasyncOne async source and a sync twin with identical names, regenerated on every buildA build step, a committed output reviewers must trust, and a replacement for every Async name httpx spells differentlyThe 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.

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 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.

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 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.

PYTHONPATH=vendor python3 build_sync.py
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.

    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.

PYTHONPATH=vendor python3 compare.py
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 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.

PYTHONPATH=vendor python3 demo.py
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 and regenerate the sync package into a temporary directory.

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
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"
PYTHONPATH=vendor python3 test_clients.py
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.

PYTHONPATH=vendor python3 pitfall.py
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 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.

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.

Footnotes

  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 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. ↩︎ Back to text

  2. unasync is kept by the python-trio organization, 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 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. ↩︎ Back to text

  3. PEP 492, 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.