Skip to content

fix(openai): stop realtime reconnect loop on fatal server errors (insufficient_quota, invalid_api_key)#6352

Open
ByteMaster-1 wants to merge 3 commits into
livekit:mainfrom
ByteMaster-1:fix/realtime-generate-reply-issue
Open

fix(openai): stop realtime reconnect loop on fatal server errors (insufficient_quota, invalid_api_key)#6352
ByteMaster-1 wants to merge 3 commits into
livekit:mainfrom
ByteMaster-1:fix/realtime-generate-reply-issue

Conversation

@ByteMaster-1

@ByteMaster-1 ByteMaster-1 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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.

@ByteMaster-1 ByteMaster-1 requested a review from a team as a code owner July 8, 2026 07:36
@ByteMaster-1

Copy link
Copy Markdown
Contributor Author

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.
Also fixes an infinite-reconnect bug (num_retries reset on every socket open). Tests are --unit and green. Thanks!

devin-ai-integration[bot]

This comment was marked as resolved.

@ByteMaster-1 ByteMaster-1 force-pushed the fix/realtime-generate-reply-issue branch from 2aa99ba to af4d6cb Compare July 8, 2026 08:38
devin-ai-integration[bot]

This comment was marked as resolved.

@longcw

longcw commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for the pr. But we already have a RealtimeModelFallbackAdapter that handles the recoverable/non-recoverable split and even re-issues the reply on swap, so the reconnect state machine doesn't need to live at the framework level — is_fatal_error can just feed _emit_error(recoverable=...) and let the existing path handle it.

My bigger concern is the auto-retry of generate_reply: in a live conversation the user may have already moved on, so silently replaying the same (stale) reply seems like a decision that should be left to the developer rather than baked into the turn loop.

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
@ByteMaster-1 ByteMaster-1 force-pushed the fix/realtime-generate-reply-issue branch from af4d6cb to 272bf63 Compare July 8, 2026 20:26
@ByteMaster-1

ByteMaster-1 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the pr. But we already have a RealtimeModelFallbackAdapter that handles the recoverable/non-recoverable split and even re-issues the reply on swap, so the reconnect state machine doesn't need to live at the framework level — is_fatal_error can just feed _emit_error(recoverable=...) and let the existing path handle it.

My bigger concern is the auto-retry of generate_reply: in a live conversation the user may have already moved on, so silently replaying the same (stale) reply seems like a decision that should be left to the developer rather than baked into the turn loop.

@longcw
Thanks for the detailed review — the "adapter owns recovery" framing makes sense, and I agree the automatic re-speak of a reply is a developer decision, not something to bake into the turn loop. Let me be concrete about the production incident that motivated this, because I don't think the existing paths cover it and I want to make sure the actual bug doesn't get lost in the retry discussion.

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.
Quota is exhausted, so generation fails and the server tears the S2S socket down shortly after each connect.
_recv_task raises APIConnectionError("… connection closed unexpectedly"), which _main_task treats as retryable and reconnects.
The reconnect succeeds (again — connecting is fine, only generating fails), and _main_task resets num_retries = 0 after every successful reconnect.
Because the connection keeps succeeding, num_retries never reaches max_retries, so the give-up branch is unreachable — the session reconnect-loops forever. The caller hears nothing but dead air, and we keep hammering OpenAI indefinitely on an account that fundamentally can't generate a reply.
Two things combine to cause this, and neither is covered today:

The quota error is tagged recoverable=True (the handlers optimistically mark everything recoverable), so nothing treats it as terminal.
num_retries is reset on every successful socket open, not after the session has proven healthy — so a "connects, then immediately drops" endpoint loops indefinitely.
That's the heart of the PR:

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.
_main_task resets num_retries only after the connection has stayed up long enough to be considered healthy, so connect-then-drop can no longer loop forever.
On recovery and the adapter: I fully agree it should own model-swap recovery. But I want to flag a gap: the adapter re-issues the reply only on the non-recoverable swap path — for a recoverable error it forwards and defers to "the plugin's own reconnect." The plugin's _reconnect() discards the pending reply (RealtimeError("pending response discarded due to session reconnection")) and never re-creates it, so on a transient reconnect the turn silently produces nothing even with the adapter configured. I'm not proposing to auto-replay it — I'd just like these recoverable failures to be observable (surfaced on the SpeechHandle, building on #6304 instead of silently swallowed, so the developer can decide whether to re-prompt.

Proposed scope — happy to reduce the PR to:

the is_fatal_error classification feeding _emit_error(recoverable=…),
the num_retries reconnect-loop fix (the infinite-loop bug above), and
graceful update_chat_ctx timeout handling (today a push timeout raises an unhandled RealtimeError that breaks the turn),
and drop the framework-level reconnect state machine and the turn-loop auto-retry. Would that scope work for you? And for the "recoverable error silently drops the in-flight reply" case — how would you prefer we surface it?

devin-ai-integration[bot]

This comment was marked as resolved.

@longcw

longcw commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

I'd split this into small PRs. For quota/"can't serve" we don't need is_fatal_error or the num_retries heuristic — just classify those as non-retryable where we parse them so the existing reconnect loop stops (can you confirm the incident arrived as an error/failed response.done with a code, or just a bare socket close?).

For speech retry, the RealtimeModelFallbackAdapter already does it (regenerate_on_swap), so let's rely on that. and yes we can make the drop on reconnect inside the plugin observable via SpeechHandle.exception().

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).
@ByteMaster-1 ByteMaster-1 changed the title feat(realtime): add response-level retry and recovery for generate_reply fix(openai): stop realtime reconnect loop on fatal server errors (insufficient_quota, invalid_api_key) Jul 10, 2026
@ByteMaster-1

Copy link
Copy Markdown
Contributor Author

I'd split this into small PRs. For quota/"can't serve" we don't need is_fatal_error or the num_retries heuristic — just classify those as non-retryable where we parse them so the existing reconnect loop stops (can you confirm the incident arrived as an error/failed response.done with a code, or just a bare socket close?).

For speech retry, the RealtimeModelFallbackAdapter already does it (regenerate_on_swap), so let's rely on that. and yes we can make the drop on reconnect inside the plugin observable via SpeechHandle.exception().

@longcw
Confirmed — it arrived as an error event with a code, followed ~150ms later by the socket close, repeating every ~300ms. From the production capture:

error=APIError('OpenAI Realtime API returned an error',
body=RealtimeError(message='You exceeded your current quota, please check your plan and billing details. ...',
type='insufficient_quota', code='insufficient_quota'), retryable=True) recoverable=True
error=APIConnectionError('OpenAI S2S connection closed unexpectedly', body=None, retryable=True) recoverable=True
Three full error→close→reconnect cycles between t=…131.74 and t=…132.34 — the loop was hammering the API ~3x/sec, every error labeled recoverable=True.

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.
Dropped: the framework reconnect state machine, the num_retries heuristic, and the turn-loop auto-retry — speech retry stays with RealtimeModelFallbackAdapter (regenerate_on_swap) and the app.
I'll open two small follow-up PRs: (1) making the pending-reply drop in _reconnect() observable via SpeechHandle.exception() as you suggested, and (2) graceful handling of the update_chat_ctx timeout inside generate_reply.

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.
@ByteMaster-1

Copy link
Copy Markdown
Contributor Author

@longcw can we please check this if possible ,
thanks

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.

2 participants