fix(openai): stop realtime reconnect loop on fatal server errors (insufficient_quota, invalid_api_key)#6352
Conversation
|
Hi @longcw @davidzhao — would appreciate a review when you have a moment. This adds response-level retry for realtime generate_reply #6205 , building on @longcw's SpeechHandle.exception() work in #6304 A few design decisions I'd especially like your take on: Retry placement. I put the retry in the voice turn path (_realtime_reply_task) rather than in RealtimeModel.generate_reply, because the returned future resolves at response.created (needed for streaming), so a complete retry has to live where completion is awaited. This covers all AgentSession usage but not direct RealtimeSession.generate_reply() callers — happy to add a model-level path if you'd prefer that instead. New base-RealtimeSession surface. I added a session_reconnecting event + reconnecting/wait_reconnected() and a cancel_and_wait() (default falls back to interrupt()), driven by the OpenAI plugin. Want to confirm you're OK extending the base API this way vs. keeping it OpenAI-local. Deliberately scoped out (called out in the PR): post-response.created failures are made observable (via GenerationCreatedEvent.done_fut) but not yet retried/surfaced — that needs changes to the streaming path and is a follow-up. The response.create collision for independent overlapping replies is tracked separately. |
2aa99ba to
af4d6cb
Compare
|
Thanks for the pr. But we already have a My bigger concern is the auto-retry of |
Realtime models had no response-level retry, unlike the pipeline LLM. A recoverable generate_reply failure (timeout, transient server error, or a reply discarded by an automatic session reconnection) left the turn silent with no recovery, surfacing in production as dead air. - classify errors: RealtimeError.recoverable + shared is_fatal_error() (quota/auth/billing are never retried) - reconnect coordination: session_reconnecting event, reconnecting/ wait_reconnected(); OpenAI plugin drives _set_reconnecting/_set_reconnected and logs reconnection at INFO - fix infinite reconnect: reset num_retries only after a healthy connection - cancel_and_wait(): cancel the active response and await its response.done before re-issuing, avoiding conversation_already_has_active_response - retry loop in _realtime_reply_task for pre-response.created failures; handle update_chat_ctx push failures; wire GenerationCreatedEvent.done_fut - tests for the classifier, reconnect state machine, error classification, and cancel_and_wait Fixes livekit#6205
af4d6cb to
272bf63
Compare
@longcw The incident — a single OpenAI realtime model, no fallback adapter, on an account whose quota ran out mid-service: The WebSocket connects fine — the API key is valid, so _create_ws_conn() always succeeds. Connecting was never the problem. The quota error is tagged recoverable=True (the handlers optimistically mark everything recoverable), so nothing treats it as terminal. is_fatal_error → _emit_error(recoverable=False) for insufficient_quota / invalid_api_key / billing errors, so the failure is terminal — the adapter can swap (if one is configured) or the session can close cleanly, instead of spinning. Proposed scope — happy to reduce the PR to: the is_fatal_error classification feeding _emit_error(recoverable=…), |
|
I'd split this into small PRs. For quota/"can't serve" we don't need For speech retry, the |
Per review: drop the framework reconnect state machine, the turn-loop generate_reply retry, and the num_retries health heuristic. Fatal error codes (insufficient_quota, invalid_api_key, account_deactivated, billing_hard_limit_reached) are classified where they are parsed in the openai plugin (_handle_error / failed response.done) and emitted with recoverable=False; _main_task stops reconnecting once a fatal error has been seen, instead of looping forever on an account that reconnects fine but can never generate. Pending generate_reply futures fail fast with the actual error code. Speech retry is left to RealtimeModelFallbackAdapter (regenerate_on_swap).
@longcw error=APIError('OpenAI Realtime API returned an error', Since a parseable error event arrives on every cycle, agreed on all points — I've rescoped this PR to exactly what you described, and dropped everything else: Kept: fatal codes (insufficient_quota, invalid_api_key, account_deactivated, billing_hard_limit_reached) are classified as recoverable=False where they're parsed (_handle_error and failed response.done), and _main_task stops reconnecting once one has been seen. Pending generate_reply futures fail immediately with the actual code instead of waiting out the 10s timeout. Diff is now ~170 lines, OpenAI plugin + tests only. |
Per review: _run_ws can also exit normally (max_session_duration refresh), which previously bypassed the fatal-error check in the APIError handler and allowed one extra reconnect of a terminally failed session.
|
@longcw can we please check this if possible , |
| self._closing = False | ||
| # set when the server reports an error that cannot succeed on retry (e.g. | ||
| # insufficient_quota); _main_task stops reconnecting once this is set | ||
| self._fatal_error: APIError | None = None |
There was a problem hiding this comment.
we can raise the APIError with retryable=False from the handler and re-raise from recv_task with
except Exception as e:
# terminal server errors (e.g. insufficient_quota) must break the recv loop so
# _main_task stops reconnecting; every other handler failure is logged and skipped
if isinstance(e, APIError) and not e.retryable:
raise
if event["type"] == "response.output_audio.delta":
event["delta"] = event["delta"][:10] + "..."
logger.exception("failed to handle event", extra={"event": event})self._fatal_error is not needed then, and actually setting this won't break the run_ws.
There was a problem hiding this comment.
Done — the handlers now raise APIError(retryable=False) and the recv loop re-raises non-retryable APIErrors (used your snippet); _main_task's existing non-retryable branch handles the emit + stop. _fatal_error removed.
|
|
||
| reconnecting = False | ||
| while not self._msg_ch.closed: | ||
| if self._fatal_error is not None: |
There was a problem hiding this comment.
if raising APIError from the run_ws, this is not needed, the retryable=False case is already handled.
There was a problem hiding this comment.
Removed — not needed with the raise-based flow.
| await self._run_ws(ws_conn) | ||
|
|
||
| except APIError as e: | ||
| if self._fatal_error is not None: |
There was a problem hiding this comment.
Removed — not needed with the raise-based flow.
| # a pending generate_reply can never be served now; fail its future | ||
| # immediately instead of letting it wait out the timeout | ||
| code = getattr(event.error, "code", None) or getattr(event.error, "type", None) | ||
| for fut in self._response_created_futures.values(): |
There was a problem hiding this comment.
maybe move this to the finally of _main_task
There was a problem hiding this comment.
Done — moved to _main_task's finally, so pending generate_reply futures now fail fast on every terminal exit (fatal error, retries exhausted, close), not just on fatal error events.
Per review: raise APIError(retryable=False) from the error handlers and re-raise it from the recv loop so _main_task's existing non-retryable branch emits recoverable=False and stops reconnecting. Removes the _fatal_error field and its check sites. Pending generate_reply futures are now failed in _main_task's finally, covering every terminal exit path.
Per review: the finally in _main_task failed pending generate_reply futures but left an in-flight generation's channels open, so consumers streaming audio/text would hang forever after a fatal exit. Close the current generation (idempotent; aclose already does it on the normal path) before failing the pending futures.
…le.exception()
On a transient socket drop, the OpenAI realtime session reconnects and discards
any pending generate_reply future, then never re-creates the response — so the
turn silently produced nothing. The discard was raised as a bare
llm.RealtimeError, indistinguishable from a terminal failure.
Fail the discarded futures with APIConnectionError instead (retryable=True):
the session is coming back, so re-prompting is sensible. The voice turn loop
already lands generate_reply failures on the SpeechHandle via _mark_done(error=);
widen its catch to APIError so the discard reaches SpeechHandle.exception().
Apps can now distinguish a retryable drop without string-matching:
exc = handle.exception()
if isinstance(exc, APIError) and exc.retryable:
... # transient reconnect discard — safe to re-prompt
The terminal "realtime session closed" path (fatal error / retries exhausted)
stays a bare RealtimeError — nothing to retry against.
Follow-up agreed in the livekit#6352 review.
Addresses the production failure in #6205 . Rescoped per review (https://github.com/livekit/agents/pull/6352#issuecomment-4920965533)— see "Scoped out" below.
Motivation
On an OpenAI account whose quota ran out mid-service, the realtime session reconnect-loops forever while the caller hears dead air. From the production capture, every ~300ms cycle is:
error=APIError('OpenAI Realtime API returned an error',
body=RealtimeError(message='You exceeded your current quota, ...',
type='insufficient_quota', code='insufficient_quota'), retryable=True) recoverable=True
error=APIConnectionError('OpenAI S2S connection closed unexpectedly', body=None, retryable=True) recoverable=True
Two things combine to cause this, and neither is handled today:
The quota error is emitted with recoverable=True (the handlers optimistically mark everything recoverable), so nothing — including RealtimeModelFallbackAdapter, whose swap is triggered by recoverable=False — treats it as terminal.
The socket itself always reconnects fine (the key is valid; only generation fails, then the server closes). _main_task resets num_retries on every successful connect, so the give-up branch is unreachable and the loop never ends.
Meanwhile a pending generate_reply() sits out its full 10s timeout and fails with a generic "generate_reply timed out" that says nothing about quota.
Changes (OpenAI plugin only)
_FATAL_ERROR_CODES (insufficient_quota, invalid_api_key, account_deactivated, billing_hard_limit_reached) — codes that can never succeed on retry — classified where errors are parsed: _handle_error (server error events) and _handle_response_done_but_not_complete (failed response.done). These now emit recoverable=False instead of the hardcoded True; everything else keeps the optimistic default.
When a fatal code is seen, the session records it and _main_task stops reconnecting (raises the fatal error instead of retrying), ending the infinite loop. No retry-budget heuristics needed — the fatal error event arrives on every cycle, so classification alone is sufficient.
Pending generate_reply futures fail immediately with the actual code ("openai returned a fatal error: insufficient_quota") instead of waiting out the timeout.
With recoverable=False now emitted correctly, the existing recovery path works as designed: RealtimeModelFallbackAdapter swaps to the next model (and re-issues the reply via regenerate_on_swap), or a bare session fails fast and loud instead of spinning silently.
Scoped out (per review)
Earlier revisions of this PR added a framework-level reconnect state machine and an automatic generate_reply retry in the turn loop; both were dropped — speech retry belongs to RealtimeModelFallbackAdapter / the application. Two small follow-up PRs planned:
Make the pending-reply drop in _reconnect() observable via SpeechHandle.exception() (building on #6304.
Graceful handling of the update_chat_ctx timeout inside generate_reply (today an unhandled RealtimeError).
Testing
8 new unit tests in tests/test_realtime/test_openai_realtime_model.py: fatal-code matcher, classification at both parse sites (fatal + transient), fail-fast of pending reply futures, transient errors left untouched, Cancellation failed early-return.
ruff format --check, ruff check, full mypy (agents + all plugins), and pytest tests/test_realtime --unit all green.