Skip to content

fix: run sync client guardrails when an event loop is already running - #131

Open
Linxiushen wants to merge 1 commit into
openai:mainfrom
Linxiushen:fix-sync-client-running-event-loop
Open

Linxiushen wants to merge 1 commit into
openai:mainfrom
Linxiushen:fix-sync-client-running-event-loop

Conversation

@Linxiushen

Copy link
Copy Markdown

Problem

GuardrailsOpenAI._run_stage_guardrails() and its GuardrailsAzureOpenAI counterpart bridge the synchronous API into the async guardrail runtime with:

loop = asyncio.get_event_loop()   # falls back to new_event_loop() on RuntimeError
...
return loop.run_until_complete(_run_async())

Inside a running event loop (an async FastAPI/Starlette handler, a notebook cell, a pytest-asyncio test, any coroutine that calls a sync helper) get_event_loop() returns the running loop and run_until_complete() raises:

RuntimeError: This event loop is already running

So the first chat.completions.create(...) / responses.create(...) that reaches a configured guardrail stage crashes. The plain openai.OpenAI client these classes are documented as drop-in replacements for (README, docs/quickstart.md) does not have this problem: it just blocks on the HTTP call. Reproduced with the quickstart configuration plus one Keyword Filter guardrail, called from inside asyncio.run(main()); the same call against openai.OpenAI only raises the expected connection error.

Fix

Add a module-level _run_coroutine_blocking() helper:

  • if the calling thread already drives a loop, run the coroutine with asyncio.run on a single worker thread, with the caller's contextvars copied. This is the same ThreadPoolExecutor(max_workers=1) + copy_context() pattern resources/chat and resources/responses already use;
  • otherwise keep the existing get_event_loop() / new_event_loop() path exactly as it was.

Both sync clients call the helper instead of the inline loop handling. The change is purely additive: the path that worked before is untouched, so the existing test_run_stage_guardrails_creates_event_loop still exercises real code. I deliberately did not switch the no-loop path to asyncio.run, which would clear the thread's current loop on return and change behaviour for existing callers.

Verification

  • Two new tests in tests/unit/test_client_sync.py, written like the existing _run_stage_guardrails tests: GuardrailsOpenAI returning a result from inside a running loop, and GuardrailsAzureOpenAI surfacing a tripwire across the thread boundary. Both fail on main with the RuntimeError above and pass with the change.
  • tests/unit/test_client_sync.py, test_client_async.py, test_resources_chat.py, test_resources_responses.py, test_streaming.py, test_runtime.py: 82 passed.
  • Full tests/unit before/after: the set of failing tests is identical (all failures are presidio/spacy-dependent PII tests that my offline environment stubs out; unrelated).
  • Behaviour differential: tripwire triggered, suppress_tripwire, clean pass-through and empty-stage short circuit give byte-identical results and exception types with and without a running loop.
  • Reproduced the bug and the fix on Python 3.11, 3.12, 3.13 and 3.14.
  • ruff format --check / ruff check clean; mypy src tests reports the same 8 pre-existing errors before and after, none in client.py. Not run: make pyright (needs node, not available offline).

This fix was developed with AI assistance (Claude); the change and the tests were reviewed and verified locally before submission.

GuardrailsOpenAI._run_stage_guardrails() and its GuardrailsAzureOpenAI
counterpart bridged into the async guardrail runtime with
asyncio.get_event_loop() + loop.run_until_complete(). Inside a running
event loop (an async request handler, a notebook cell, a pytest-asyncio
test) get_event_loop() returns the running loop and run_until_complete()
raises "RuntimeError: This event loop is already running", so the first
call that reaches a configured guardrail stage crashes. The plain
openai.OpenAI client these classes are documented as drop-in replacements
for does not have this problem.

Add a module-level _run_coroutine_blocking() helper: when a loop is
already running in the calling thread, run the coroutine with asyncio.run
on a single worker thread (with the caller's contextvars copied), which
is the pattern resources/chat and resources/responses already use;
otherwise keep the existing get_event_loop / new_event_loop path
unchanged. Both sync clients use the helper.

Adds two tests that call the sync clients from inside a running loop,
covering the return-value path and a tripwire raised across the thread
boundary.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant