Accepting Sync or Async Callbacks¶
Libraries and frameworks often accept a user-supplied callback — a hook, a handler, a filter — and want to allow both plain functions and coroutine functions. There are two ways to tell them apart: inspect the callable before calling it, or call it and inspect what comes back. Only the second is reliable. Measured on Python 3.14 with nine kinds of callable: inspect.iscoroutinefunction returned the right answer for async def functions, bound async methods, functools.partial of an async function and AsyncMock, but returned False for an object with an async __call__, a lambda returning a coroutine, a functools.wraps wrapper around an async function, and a function returning a Future — so code that branched on it called those four and got back an un-awaited object. Calling first and awaiting when inspect.isawaitable(result) handled all nine. It cost 125 ns per async call against 93 ns for a direct await, and 243 ns per sync call against 60 ns. A sync callback that blocked for 50 ms, called ten times, stalled the loop for 500 ms; run in asyncio.to_thread it took 55 ms with 3 ms of lag. This guide builds a callback runner that accepts both kinds safely.
Prerequisites¶
- Python 3.11+.
- Coroutine function markers, from replacing removed asyncio APIs.
- The topic overview, Coroutine Design Patterns.
1. Call first, then await if the result is awaitable¶
The robust rule is to look at what the callback returns, not at what it is:
import inspect
from typing import Awaitable, Callable, TypeVar
T = TypeVar("T")
R = TypeVar("R")
async def call_callback(cb: Callable[[T], R | Awaitable[R]], arg: T) -> R:
result = cb(arg)
if inspect.isawaitable(result):
result = await result
return result
Measured across nine callable types, this returned a plain value every time. It handles anything that returns an awaitable — coroutines, futures, tasks, objects with __await__ — whether or not the callable was declared async def. It does not care whether the callable is a function, a method, a partial, a lambda or an instance with __call__. The type hint Callable[[T], R | Awaitable[R]] documents both kinds for users and type checkers.
Verify: a test passes every callable kind your users might supply through call_callback and gets plain values back.
2. Know why inspecting the callable fails¶
inspect.iscoroutinefunction answers "was this function defined with async def?" — a property of the code object, not of what calling it produces. Four common callables return awaitables without being coroutine functions:
class Handler:
async def __call__(self, event): ... # the instance is not a coroutine function
on_event = lambda e: handle(e) # returns a coroutine; lambda is plain
def retry(fn):
@functools.wraps(fn)
def wrapper(*args): # plain def, returns fn's coroutine
return fn(*args)
return wrapper
Measured: iscoroutinefunction returned False for all of these, and code that branched on it called them without awaiting — producing an un-awaited coroutine, which in turn triggers "coroutine ... was never awaited" warnings and silently skips the callback's work. Decorators are the most common source in real code: any decorator written as a plain def wrapper turns an async function into something that looks sync. Library authors can mark such wrappers with inspect.markcoroutinefunction (Python 3.12+), but a callback runner should not depend on every user having done so.
Verify: your library contains no if iscoroutinefunction(cb) branches deciding whether to await a user callback.
3. Keep the overhead in proportion¶
Checking the result adds a small cost to every call, which matters only for callbacks invoked in tight loops:
async def bench(runner, cb, n=200_000):
start = time.perf_counter()
for i in range(n):
await runner(cb, i)
return (time.perf_counter() - start) / n * 1e9 # ns per call
Measured per call: a direct await cb(x) of an async callback 93 ns, call_callback with the same callback 125 ns; a direct call of a sync callback 60 ns, call_callback with it 243 ns; and branching on iscoroutinefunction first cost 508 ns per sync call — the inspection is slower than the check it replaces. A few hundred nanoseconds is negligible against any callback that does real work. If profiling shows otherwise, resolve the kind once at registration — call it once in a test harness, or require users to declare it — rather than per call.
Verify: callback dispatch does not appear in the profile of a realistic workload.
4. Keep blocking sync callbacks off the loop¶
A sync callback runs on the event loop. If it blocks — file I/O, a requests call, heavy computation — it blocks everything:
async def call_callback(cb, arg, *, blocking_ok: bool = False):
if blocking_ok and not _returns_awaitable(cb):
return await asyncio.to_thread(cb, arg) # declared blocking: run in a thread
result = cb(arg)
if inspect.isawaitable(result):
result = await result
return result
Measured with a sync callback that slept 50 ms, invoked ten times concurrently: on the loop, 502 ms in total and a maximum loop lag of 500 ms — the calls ran one after another and nothing else ran meanwhile; through asyncio.to_thread, 55 ms in total with 3 ms of lag. Running user callbacks in threads changes their semantics — they run concurrently, may touch shared state from another thread, and cannot use the loop's objects directly — so make it an explicit option users choose when registering a callback, not an automatic behaviour. _returns_awaitable can be a registration-time flag set by the user or by the decorator they use to register.
Verify: blocking callbacks are either declared and run in threads, or documented as forbidden, and loop lag stays low when they run.
5. Treat callback errors as the callback's, not yours¶
A user callback that raises should not take down the component that called it, and its error should say whose code failed. Wrap the call, attribute the error, and decide per hook whether it is fatal:
class CallbackError(Exception):
def __init__(self, name: str, cause: BaseException):
super().__init__(f"callback {name!r} failed: {cause!r}")
self.name, self.cause = name, cause
async def run_hook(name: str, cb, arg, *, fatal: bool = False):
try:
return await call_callback(cb, arg)
except asyncio.CancelledError:
raise # cancellation is never the callback's error
except Exception as exc:
if fatal:
raise CallbackError(name, exc) from exc
log.exception("callback %s failed; continuing", name)
return None
Re-raising CancelledError untouched keeps cancellation working when the callback is awaited inside a task that is being shut down. For callbacks invoked per event rather than per request, isolating each one in its own queue and worker, as in building an async event emitter, keeps a slow or failing callback from delaying the others.
Verify: a failing non-fatal hook is logged with its name and does not stop processing; a fatal one raises CallbackError naming it.
Verification¶
A callback runner accepts both kinds correctly when:
- It calls first and awaits awaitable results, never branching on
iscoroutinefunction. - Tests cover partials, callable objects, lambdas and decorated functions.
- Blocking sync callbacks run in threads only when declared, and loop lag stays low.
- Errors are attributed to the callback, and cancellation passes through untouched.
Diagnostic Hook: run the test suite with -W error::RuntimeWarning so that any "coroutine ... was never awaited" fails the build. A callback runner that branches on the wrong check produces exactly that warning for the callables it misclassifies, and nothing else points at it.
Pitfalls & edge cases¶
if iscoroutinefunction(cb): await cb(x). Measured: 4 of 9 callable kinds returned un-awaited.- Plain-
defdecorators on async functions. They hide the async-ness from inspection. - Blocking sync callbacks on the loop. Measured: 500 ms of loop lag for ten 50 ms calls.
- Catching
BaseExceptionaround callbacks. It swallows cancellation.
Frequently Asked Questions¶
How do I accept both sync and async callbacks in Python?
Call the callback, then await the result if inspect.isawaitable(result) is true. This handled all nine callable kinds tested, including partials, callable objects, lambdas and decorated functions.
Why does inspect.iscoroutinefunction return False for my async callback?
It checks how the callable was defined, not what it returns. Objects with async call, lambdas returning coroutines, plain-def decorators around async functions and functions returning Futures all returned False.
What does checking isawaitable cost?
About 30 ns per async call (125 ns against 93 ns direct) and 180 ns per sync call (243 ns against 60 ns) in testing; negligible unless callbacks run in a tight loop.
Should sync callbacks run in a thread?
Only if they block and users opt in: ten 50 ms blocking callbacks stalled the loop for 500 ms on the loop and 3 ms via to_thread, but threads change the callback's concurrency semantics.
Related¶
- Coroutine Design Patterns — up to the topic overview.
- Walking trees concurrently with bounded fan-out — another pattern for unknown-size work.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.