This commit is contained in:
Mateo Wang 2026-09-16 16:03:23 -04:00 committed by GitHub
commit 58735d8008
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 152 additions and 3 deletions

View file

@ -14,12 +14,11 @@ import time
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TypeVar
from typing import Protocol, TypeVar
from urllib.parse import urlencode
from pydantic import BaseModel, ConfigDict
from websockets.sync.client import connect
from websockets.sync.connection import Connection
from e2e_config import unique_marker, ws_base_url
from proxy_client import ProxyClient
@ -219,6 +218,47 @@ class ResponseDone(BaseModel):
response: ResponsePayload
class ErrorDetail(BaseModel):
model_config = ConfigDict(extra="allow")
type: str | None = None
code: str | None = None
message: str | None = None
class ErrorEvent(BaseModel):
model_config = ConfigDict(extra="allow")
type: str = "error"
error: ErrorDetail | None = None
def error_summary(payload: str) -> str:
"""Human-readable one-liner for an `error` event payload. OpenAI's realtime
quota rejection arrives as {"type":"error","error":{"code":"insufficient_quota",
"message":"..."}}, so surfacing code + message turns an opaque socket failure
into the actual reason. Falls back to the raw payload when it is not shaped
that way (a provider whose error envelope the proxy has not normalized)."""
detail = ErrorEvent.model_validate_json(payload).error
if detail is None:
return payload
label = detail.code or detail.type
if label and detail.message:
return f"{label}: {detail.message}"
return detail.message or label or payload
class RealtimeError(RuntimeError):
"""Raised when the provider emits an `error` event while a test is waiting for
a lifecycle or response event, instead of letting the wait time out with the
cause discarded. `payload` is the raw error event for further inspection."""
def __init__(self, stop_type: str, payload: str, preceding: tuple[str, ...]) -> None:
self.payload = payload
super().__init__(
f"realtime provider returned an error before {stop_type!r}: "
f"{error_summary(payload)} (preceding events: {list(preceding)})"
)
@dataclass(frozen=True, slots=True)
class ReceivedEvent:
type: str
@ -290,9 +330,19 @@ def _as_text(message: str | bytes) -> str:
return message.decode("utf-8") if isinstance(message, bytes) else message
class WebSocketConnection(Protocol):
"""The slice of websockets.sync.connection.Connection the session uses, kept a
Protocol so the collect loop can be driven with a scripted connection in harness
coverage without a live socket (dependency injection over monkeypatching)."""
def send(self, message: str) -> None: ...
def recv(self, timeout: float | None = None) -> str | bytes: ...
@dataclass(frozen=True, slots=True)
class RealtimeSession:
connection: Connection
connection: WebSocketConnection
def send(self, event: BaseModel) -> None:
self.connection.send(event.model_dump_json(by_alias=True, exclude_none=True))
@ -315,6 +365,10 @@ class RealtimeSession:
collected.append(event)
if event.type == stop_type:
return tuple(collected)
if event.type == "error":
raise RealtimeError(
stop_type, text, tuple(e.type for e in collected[:-1])
)
raise TimeoutError(
f"no {stop_type!r} within {timeout}s; got {[e.type for e in collected]}"
)

View file

@ -0,0 +1,95 @@
"""Harness coverage for realtime_client.RealtimeSession.collect_until.
Not an e2e test (no live proxy): it drives the collect loop with a scripted
connection injected in place of the websocket, so it runs regardless of whether a
proxy is up. The behavior under test is the diagnostic contract: when the provider
emits an `error` event (OpenAI realtime quota rejection is the motivating case)
while a test waits for a lifecycle or response event, the wait must fail fast and
surface the provider's reason, not time out with the cause discarded.
"""
from collections.abc import Iterator
from dataclasses import dataclass
import pytest
from realtime_client import RealtimeError, RealtimeSession, error_summary
QUOTA_ERROR = (
'{"type":"error","event_id":"event_1","error":{"type":"insufficient_quota",'
'"code":"insufficient_quota","message":"You exceeded your current quota, '
'please check your plan and billing details."}}'
)
SESSION_CREATED = '{"type":"session.created","session":{"id":"sess_1"}}'
@dataclass(frozen=True, slots=True)
class _ScriptedConnection:
"""A WebSocketConnection that replays a fixed list of frames, then behaves like
a silent socket (recv times out). send is a no-op; the collect loop only recvs."""
frames: Iterator[str]
def send(self, message: str) -> None:
return None
def recv(self, timeout: float | None = None) -> str:
try:
return next(self.frames)
except StopIteration:
raise TimeoutError("no more frames") from None
def _session(*frames: str) -> RealtimeSession:
return RealtimeSession(connection=_ScriptedConnection(iter(frames)))
def test_collect_until_raises_realtime_error_on_error_event() -> None:
session = _session(QUOTA_ERROR)
with pytest.raises(RealtimeError) as excinfo:
session.collect_until("session.created", timeout=20)
message = str(excinfo.value)
assert "insufficient_quota" in message
assert "exceeded your current quota" in message
assert "session.created" in message
assert excinfo.value.payload == QUOTA_ERROR
def test_collect_until_reports_events_seen_before_the_error() -> None:
session = _session(SESSION_CREATED, QUOTA_ERROR)
with pytest.raises(RealtimeError) as excinfo:
session.collect_until("response.done", timeout=20)
assert "session.created" in str(excinfo.value)
def test_collect_until_returns_on_stop_event_without_raising() -> None:
session = _session(SESSION_CREATED)
events = session.collect_until("session.created", timeout=20)
assert [e.type for e in events] == ["session.created"]
def test_collect_until_times_out_when_no_stop_or_error_arrives() -> None:
session = _session(SESSION_CREATED)
with pytest.raises(TimeoutError) as excinfo:
session.collect_until("session.updated", timeout=5)
assert "session.updated" in str(excinfo.value)
def test_error_summary_extracts_openai_quota_reason() -> None:
assert error_summary(QUOTA_ERROR) == (
"insufficient_quota: You exceeded your current quota, "
"please check your plan and billing details."
)
def test_error_summary_falls_back_to_raw_payload_when_not_normalized() -> None:
payload = '{"type":"error","message":"boom"}'
assert error_summary(payload) == payload