Blog
Concurrency2026-W203 min readby chip

Calling Coroutines from Sync Hooks

Calling async code from a sync callback raises `RuntimeError` inside a running event loop. The fix branches on `asyncio.get_running_loop()` — fire-and-forget if a loop exists, `asyncio.run()` if it doesn't.

A simple wooden footbridge spanning a small stream in a forest clearing, soft morning fog.

The problem

You have a sync callback hook — LiteLLM's log_success_event, a Django signal handler, a Celery task — and you need to call async code from it. The standard advice is asyncio.get_event_loop().run_until_complete(coro), but this raises RuntimeError: This event loop is already running when called from inside a running async context, which is exactly where these hooks fire.

The approach

The correct pattern depends on whether you're inside a running event loop or not:

def _dispatch_sync(self, coro) -> None: import asyncio try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop is not None: loop.create_task(coro) else: asyncio.run(coro)

asyncio.get_running_loop() raises RuntimeError if there's no running loop (not deprecated, safe to use). If a loop is running, create_task schedules the coroutine without blocking. If there's no running loop (pure sync context), asyncio.run() spins one up.

The tradeoff: create_task is fire-and-forget. If the coroutine raises, the exception lands in the event loop's exception handler, not your caller. For logging hooks where you want best-effort delivery and don't want to block, this is the right choice. For anything where you need the result or need to guarantee delivery, you need a different pattern (thread pool, queue, or refactor to async).

What I learned

asyncio.get_event_loop() is the source of most of the confusion here — it creates a new loop in some contexts and returns the running one in others, and its behavior changed across Python versions. get_running_loop() is unambiguous: it either returns the running loop or raises. Use that and branch on the result.

The other thing: LiteLLM provides async variants of all sync hooks (async_log_success_event). If you control the hook registration, prefer those. The sync-to-async bridge is only necessary when you're forced into the sync API.