Skip to content

Commit a52805c

Browse files
fix(realtime): retain configured queues without replaying attempted sends (openai#3978)
An empty Realtime manager queue was replaced on connect, losing its configured byte limit and later messages. A typed or queued send could also be replayed on reconnect even after reaching the server. Preserve the manager's queue and release only attempted writes on failure or cancellation; never-attempted messages remain in order. Other endpoints retain the shared queue's existing defaults. Six sync/async real-socket regressions fail on the base and pass with this change, covering empty queues and interrupted pre-open/direct sends. A focused cancellation regression verifies in-flight bounds and retained messages. Validation: 456 affected tests per Pydantic 1/2, 45 final queue/Realtime tests per version, Ruff, mypy (1,865 files), Pyright, and the trusted custom-code check (7,766/10,000).
1 parent a444e42 commit a52805c

4 files changed

Lines changed: 222 additions & 39 deletions

File tree

‎src/openai/_send_queue.py‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,34 @@ def enqueue(self, data: str) -> None:
3737
self._queue.append((data, byte_length))
3838
self._bytes += byte_length
3939

40-
def flush_sync(self, send: typing.Callable[[str], object]) -> None:
40+
def flush_sync(self, send: typing.Callable[[str], object], *, requeue_failed: bool = True) -> None:
4141
"""Send every queued message via *send*.
4242
4343
If *send* raises, the failing message and all subsequent messages
44-
are re-queued and the error is re-raised.
44+
are re-queued and the error is re-raised. When `requeue_failed` is
45+
false, release the attempted message even on failure or interruption.
4546
"""
4647
while isinstance(pending := self._begin_flush(), threading.Event):
4748
pending.wait()
4849

4950
try:
5051
while pending:
5152
data, byte_length = pending[0]
52-
send(data)
53-
with self._lock:
54-
pending.popleft()
55-
self._bytes -= byte_length
53+
sent = False
54+
try:
55+
send(data)
56+
sent = True
57+
finally:
58+
if sent or not requeue_failed:
59+
with self._lock:
60+
pending.popleft()
61+
self._bytes -= byte_length
5662
finally:
5763
self._end_flush(pending)
5864

59-
async def flush_async(self, send: typing.Callable[[str], typing.Awaitable[object]]) -> None:
65+
async def flush_async(
66+
self, send: typing.Callable[[str], typing.Awaitable[object]], *, requeue_failed: bool = True
67+
) -> None:
6068
"""Async variant of :meth:`flush_sync`."""
6169
while isinstance(pending := self._begin_flush(), threading.Event):
6270
# Waiting in a worker keeps the event loop responsive. Cancellation
@@ -66,10 +74,15 @@ async def flush_async(self, send: typing.Callable[[str], typing.Awaitable[object
6674
try:
6775
while pending:
6876
data, byte_length = pending[0]
69-
await send(data)
70-
with self._lock:
71-
pending.popleft()
72-
self._bytes -= byte_length
77+
sent = False
78+
try:
79+
await send(data)
80+
sent = True
81+
finally:
82+
if sent or not requeue_failed:
83+
with self._lock:
84+
pending.popleft()
85+
self._bytes -= byte_length
7386
finally:
7487
self._end_flush(pending)
7588

‎src/openai/resources/realtime/realtime.py‎

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ def __init__(
294294
self._extra_headers = extra_headers
295295
self._intentionally_closed = False
296296
self._is_reconnecting = False
297-
self._send_queue = send_queue or SendQueue()
297+
self._send_queue = send_queue if send_queue is not None else SendQueue()
298298
self._event_handler_registry = EventHandlerRegistry(use_lock=False)
299299

300300
self.session = AsyncRealtimeSessionResource(self)
@@ -373,11 +373,7 @@ async def send(self, event: RealtimeClientEvent | RealtimeClientEventParam) -> N
373373
if self._is_reconnecting:
374374
self._send_queue.enqueue(data)
375375
return
376-
try:
377-
await self._connection.send(data)
378-
except Exception:
379-
self._send_queue.enqueue(data)
380-
raise
376+
await self._connection.send(data)
381377

382378
async def send_raw(self, data: bytes | str) -> None:
383379
if self._is_reconnecting:
@@ -484,7 +480,7 @@ async def _send(data: str) -> None:
484480
await self._connection.send(data)
485481

486482
try:
487-
await self._send_queue.flush_async(_send)
483+
await self._send_queue.flush_async(_send, requeue_failed=False)
488484
except Exception:
489485
log.warning("Failed to flush send queue after reconnect")
490486

@@ -818,7 +814,7 @@ def __init__(
818814
self._extra_headers = extra_headers
819815
self._intentionally_closed = False
820816
self._is_reconnecting = False
821-
self._send_queue = send_queue or SendQueue()
817+
self._send_queue = send_queue if send_queue is not None else SendQueue()
822818
self._event_handler_registry = EventHandlerRegistry(use_lock=True)
823819

824820
self.session = RealtimeSessionResource(self)
@@ -897,11 +893,7 @@ def send(self, event: RealtimeClientEvent | RealtimeClientEventParam) -> None:
897893
if self._is_reconnecting:
898894
self._send_queue.enqueue(data)
899895
return
900-
try:
901-
self._connection.send(data)
902-
except Exception:
903-
self._send_queue.enqueue(data)
904-
raise
896+
self._connection.send(data)
905897

906898
def send_raw(self, data: bytes | str) -> None:
907899
if self._is_reconnecting:
@@ -1002,7 +994,7 @@ def _reconnect(self, exc: Exception) -> bool:
1002994
def _flush_send_queue(self) -> None:
1003995
"""Send all queued messages over the current connection."""
1004996
try:
1005-
self._send_queue.flush_sync(lambda data: self._connection.send(data))
997+
self._send_queue.flush_sync(lambda data: self._connection.send(data), requeue_failed=False)
1006998
except Exception:
1007999
log.warning("Failed to flush send queue after reconnect")
10081000

‎tests/lib/test_realtime_websocket_contract.py‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66

77
import httpx2
88
import pytest
9+
from websockets.sync.client import ClientConnection
910
from websockets.sync.server import ServerConnection
11+
from websockets.asyncio.client import ClientConnection as AsyncClientConnection
1012

1113
from openai import OpenAI, AsyncOpenAI
14+
from openai._exceptions import WebSocketQueueFullError
15+
from openai.types.websocket_reconnection import ReconnectingEvent
1216
from openai.types.realtime.realtime_error_event import RealtimeErrorEvent
1317
from openai.types.realtime.input_audio_buffer_cleared_event import InputAudioBufferClearedEvent
1418

@@ -133,3 +137,136 @@ def script(socket: ServerConnection) -> None:
133137
"event_id": "future-1",
134138
"text": "東京🙂",
135139
}
140+
141+
142+
@pytest.mark.parametrize("mode", ["sync", "async"])
143+
async def test_realtime_empty_manager_queue_keeps_budget_after_open(mode: str) -> None:
144+
opened = 0
145+
146+
def script(socket: ServerConnection) -> None:
147+
nonlocal opened
148+
opened += 1
149+
if opened == 1:
150+
socket.close(code=1011, reason="synthetic restart")
151+
return
152+
# Let the iterator resume even if its connection lost the manager's
153+
# queue. Server-side inspection still proves exactly what was sent.
154+
socket.send('{"type": "input_audio_buffer.cleared", "event_id": "ready"}')
155+
assert json.loads(socket.recv(timeout=5)) == {"type": "input_audio_buffer.clear", "event_id": "queued"}
156+
157+
with script_server(script, expected_connections=2) as url:
158+
if mode == "sync":
159+
with OpenAI(
160+
api_key="fake-realtime-key", base_url=url, http_client=httpx2.Client(trust_env=False)
161+
) as client:
162+
163+
def on_retry(_event: ReconnectingEvent) -> None:
164+
manager.send({"type": "input_audio_buffer.clear", "event_id": "queued"})
165+
with pytest.raises(WebSocketQueueFullError):
166+
manager.send({"type": "input_audio_buffer.append", "audio": "AAAA" * 100})
167+
168+
manager = client.realtime.connect(
169+
model="gpt-realtime", max_queue_size=96, on_reconnecting=on_retry, initial_delay=0
170+
)
171+
with manager as connection:
172+
event = next(iter(connection))
173+
assert event.type == "input_audio_buffer.cleared"
174+
else:
175+
async with AsyncOpenAI(
176+
api_key="fake-realtime-key", base_url=url, http_client=httpx2.AsyncClient(trust_env=False)
177+
) as client:
178+
179+
def on_async_retry(_event: ReconnectingEvent) -> None:
180+
async_manager.send({"type": "input_audio_buffer.clear", "event_id": "queued"})
181+
with pytest.raises(WebSocketQueueFullError):
182+
async_manager.send({"type": "input_audio_buffer.append", "audio": "AAAA" * 100})
183+
184+
async_manager = client.realtime.connect(
185+
model="gpt-realtime", max_queue_size=96, on_reconnecting=on_async_retry, initial_delay=0
186+
)
187+
async with async_manager as connection:
188+
event = await asyncio.wait_for(anext(aiter(connection)), 5)
189+
assert event.type == "input_audio_buffer.cleared"
190+
191+
192+
@pytest.mark.parametrize("mode", ["sync", "async"])
193+
@pytest.mark.parametrize("preopen", [True, False], ids=["preopen-flush", "direct"])
194+
async def test_realtime_recovery_never_replays_an_attempted_command(
195+
mode: str, preopen: bool, monkeypatch: pytest.MonkeyPatch
196+
) -> None:
197+
opened = 0
198+
recorded: list[tuple[int, str]] = []
199+
200+
def script(socket: ServerConnection) -> None:
201+
nonlocal opened
202+
opened += 1
203+
current = opened
204+
if current == 1:
205+
for _ in range(2 if preopen else 1):
206+
recorded.append((current, json.loads(socket.recv(timeout=5))["event_id"]))
207+
socket.close(code=1011, reason="synthetic restart")
208+
return
209+
socket.send('{"type": "input_audio_buffer.cleared", "event_id": "ready"}')
210+
for _ in range(2 if preopen else 1):
211+
recorded.append((current, json.loads(socket.recv(timeout=5))["event_id"]))
212+
213+
# Fail after the real wire send: an exception cannot establish non-delivery.
214+
original = ClientConnection.send
215+
original_async = AsyncClientConnection.send
216+
217+
def send_then_interrupt(self: ClientConnection, message: object, *args: object, **kwargs: object) -> None:
218+
original(self, message, *args, **kwargs) # type: ignore[arg-type]
219+
if json.loads(message)["event_id"] == "attempted": # type: ignore[arg-type]
220+
raise OSError("synthetic interruption after send")
221+
222+
async def async_send_then_interrupt(
223+
self: AsyncClientConnection, message: object, *args: object, **kwargs: object
224+
) -> None:
225+
await original_async(self, message, *args, **kwargs) # type: ignore[arg-type]
226+
if json.loads(message)["event_id"] == "attempted": # type: ignore[arg-type]
227+
raise OSError("synthetic interruption after send")
228+
229+
if mode == "sync":
230+
monkeypatch.setattr(ClientConnection, "send", send_then_interrupt)
231+
else:
232+
monkeypatch.setattr(AsyncClientConnection, "send", async_send_then_interrupt)
233+
with script_server(script, expected_connections=2) as url:
234+
if mode == "sync":
235+
with OpenAI(
236+
api_key="fake-realtime-key", base_url=url, http_client=httpx2.Client(trust_env=False)
237+
) as client:
238+
239+
def on_retry(_event: ReconnectingEvent) -> None:
240+
manager.send({"type": "input_audio_buffer.clear", "event_id": "during-recovery"})
241+
242+
manager = client.realtime.connect(model="gpt-realtime", on_reconnecting=on_retry, initial_delay=0)
243+
if preopen:
244+
for identity in ("first", "attempted", "unattempted"):
245+
manager.send({"type": "input_audio_buffer.clear", "event_id": identity})
246+
with manager as connection:
247+
if not preopen:
248+
with pytest.raises(OSError, match="synthetic interruption after send"):
249+
connection.send({"type": "input_audio_buffer.clear", "event_id": "attempted"})
250+
assert next(iter(connection)).type == "input_audio_buffer.cleared"
251+
else:
252+
async with AsyncOpenAI(
253+
api_key="fake-realtime-key", base_url=url, http_client=httpx2.AsyncClient(trust_env=False)
254+
) as client:
255+
256+
def on_async_retry(_event: ReconnectingEvent) -> None:
257+
async_manager.send({"type": "input_audio_buffer.clear", "event_id": "during-recovery"})
258+
259+
async_manager = client.realtime.connect(
260+
model="gpt-realtime", on_reconnecting=on_async_retry, initial_delay=0
261+
)
262+
if preopen:
263+
for identity in ("first", "attempted", "unattempted"):
264+
async_manager.send({"type": "input_audio_buffer.clear", "event_id": identity})
265+
async with async_manager as connection:
266+
if not preopen:
267+
with pytest.raises(OSError, match="synthetic interruption after send"):
268+
await connection.send({"type": "input_audio_buffer.clear", "event_id": "attempted"})
269+
event = await asyncio.wait_for(anext(aiter(connection)), 5)
270+
assert event.type == "input_audio_buffer.cleared"
271+
expected = [(1, "first"), (1, "attempted"), (2, "unattempted"), (2, "during-recovery")]
272+
assert recorded == (expected if preopen else [(1, "attempted"), (2, "during-recovery")])

0 commit comments

Comments
 (0)