Bridging Callback APIs to async with Futures¶
You have a library that does not speak async. It either hands you a result through a completion callback — client.fetch(url, on_done=...) — or it runs work on its own thread and signals you when finished. Your service is built on async/await, and you want to call this library as if it were a coroutine: result = await client_async.fetch(url). The bridge between the two worlds is a single asyncio.Future: you create one, register the library's callback to resolve it, and await the future. The subtlety is doing this safely — futures are not thread-safe, can only be resolved once, and will hang the caller forever if the callback never fires.
This guide builds that bridge step by step: create the future, resolve it from a callback, await it under a deadline, resolve it safely from another thread, and finally fold it all into a reusable async helper.
Prerequisites¶
- Python 3.11+ — this guide uses
asyncio.timeout(), the context-manager deadline introduced in 3.11. - Familiarity with the low-level mechanics in Future Objects & Callbacks, the overview this page expands on, and with how the loop schedules callbacks described in Asyncio Fundamentals & Event Loop Architecture.
- A callback- or thread-based library to wrap. The examples use a small fake SDK so they run as-is.
The fake SDK used throughout — a callback API and, later, a threaded one:
1. Create a Future with loop.create_future()¶
The bridge starts with one future, created from the running loop. Use loop.create_future() rather than asyncio.Future(): it binds the future to the active loop and honours custom loop implementations like uvloop.
Verify: fut.done() is False immediately after creation and True after set_result; await fut returns the value you set. The repr() is your friend — it prints the state and any registered callbacks.
2. Resolve it from a callback¶
Now wire the library's callback to resolve the future. The callback closes over fut and calls set_result on success or set_exception on failure, mapping the library's (value, error) convention onto the future's state.
Verify: a successful key returns the value; the "boom" key surfaces the library's error as a real raised exception at the await, with the original traceback preserved. The if fut.done() guard means a misbehaving library that calls back twice cannot crash you with InvalidStateError.
3. Await it with a timeout¶
A bridge that can hang is a liability — if the library never calls back, your coroutine waits forever. Wrap the await in asyncio.timeout() so a missing callback becomes a TimeoutError you can handle, and so cancellation cleans the future up.
Verify: against SilentClient the call returns after ~0.2s with a TimeoutError instead of hanging. When the timeout fires, asyncio.timeout() cancels the await fut, which leaves the future cancelled — so a late callback hitting the if fut.done() guard is a no-op rather than an error.
4. Resolve safely from another thread¶
When the library runs its callback on its own thread (the ThreadedClient), you must not touch the future from that thread — futures and the loop are not thread-safe. Capture the running loop on the async side and resolve through loop.call_soon_threadsafe, which enqueues the resolution on the loop thread and wakes the selector. This is the same cross-thread discipline covered in hybrid concurrency models.
Verify: the threaded fetch returns the correct value with no RuntimeError. If you (wrongly) call fut.set_result directly inside _on_done, you will hit a RuntimeError about the future being attached to a different loop, or intermittent corruption — proof that call_soon_threadsafe is doing real work.
5. Wrap it as a reusable async helper¶
Fold the plumbing into one helper so callers never see a future. This version handles both same-thread and threaded callbacks by always hopping through call_soon_threadsafe (a no-op cost when already on the loop thread), guards against double-resolution, and takes a timeout.
Verify: both clients return through the same helper. Because the helper is generic over register, you can wrap any callback API by passing a lambda that adapts its signature to on_done(value, error).
Verification¶
Run any snippet directly; each is self-contained against the fake clients defined above. Expected signals:
- Success path returns the produced value; error path raises the library's exception at the
awaitwith its original traceback intact. - Timeout path (
SilentClient) returns control after the deadline withTimeoutError, never hanging. - Threaded path resolves with no
RuntimeErrorabout loops; remove thecall_soon_threadsafehop and you should see that error appear, confirming the bridge is exercising the cross-thread path. - Add
repr(fut)logging and you will see the future move from<Future pending>to<Future finished result=...>exactly once per call.
Pitfalls & edge cases¶
- Setting the result twice. If the library can call back more than once (retries, success-then-late-error), the second
set_result/set_exceptionraisesInvalidStateError. Always guard withif fut.done(): returnas the first line of the resolver. - Resolving from the wrong thread without
call_soon_threadsafe. Callingfut.set_resultdirectly from a library thread is undefined behaviour: you may corrupt loop state or hit a "different loop"RuntimeError. Route every off-thread resolution throughcall_soon_threadsafe. - Never resolving — the silent hang. A library that finishes via an error path without invoking your callback leaves the future
PENDINGand the awaiter stuck. Theasyncio.timeout()wrapper is mandatory, not optional, for any third-party callback you do not fully trust. - Ignoring cancellation cleanup. When the await is cancelled (timeout or caller cancellation), the future is cancelled but the library may still call back later. The
if fut.done()guard makes that late callback a harmless no-op; without it the lateset_resultraisesInvalidStateErrorinsidecall_soon_threadsafe, surfacing only in the loop's exception handler. - Leaking registrations. If the library lets you deregister a callback, do it in a
finallyafter the await; otherwise a callback that never fires keeps the closure (and anything it captured) alive for the process lifetime.
Frequently Asked Questions¶
Why use loop.create_future() instead of asyncio.Future()?
loop.create_future() binds the future to the currently running loop and lets a custom loop implementation, such as uvloop, supply its own future class. Constructing asyncio.Future() directly bypasses that and can attach the future to the wrong loop, leading to RuntimeError when you resolve or await it.
How do I stop a wrapped callback API from hanging my coroutine?
Wrap the await on the future in async with asyncio.timeout(timeout). If the library never invokes your callback, the timeout cancels the await and raises TimeoutError instead of leaving the future PENDING forever. Combine it with an if fut.done() guard so a late callback after the timeout is a harmless no-op.
What happens if the library calls my callback twice?
The second call to set_result or set_exception raises InvalidStateError because the future is already done. Guard the resolver with if fut.done(): return as its first line so only the first callback resolves the future and any subsequent calls are ignored.
Do I need call_soon_threadsafe if the callback runs on the same thread?
Strictly no, but routing every resolution through loop.call_soon_threadsafe makes one helper correct for both same-thread and threaded callbacks at negligible cost. If you know the callback always fires on the loop thread you can call set_result directly, but a single safe path avoids subtle bugs when a library later changes its threading behaviour.
Related¶
- Future Objects & Callbacks — up to the overview for the full Future state machine and the rest of the callback API catalogue.
- Asyncio Fundamentals & Event Loop Architecture — how the loop schedules the callbacks that resolve your futures.
- Hybrid concurrency models — broader patterns for sharing state and results between async tasks and threads.