@@ -171,6 +171,11 @@ def __init__(
171171 config .API_RETRY_MAX_DELAY if retry_max_delay is None else retry_max_delay
172172 )
173173 self ._http = httpx .Client (timeout = timeout , verify = self .verify )
174+ # True while the in-flight request was aborted (Ctrl-C): a
175+ # connection error on an aborted request must NOT be retried —
176+ # the user asked to stop. Cleared at the start of each chat()
177+ # so a fresh turn may retry normally.
178+ self ._aborted = False
174179 self .log_path : Path | None = _llm_log_path () if config .LLM_LOG_ENABLED else None
175180
176181 def close (self ) -> None :
@@ -188,6 +193,7 @@ def abort(self) -> None:
188193 the loop treats as a cancel) and then close the pool. A fresh
189194 client is swapped in for the next request.
190195 """
196+ self ._aborted = True
191197 old = self ._http
192198 self ._http = httpx .Client (timeout = self .timeout , verify = self .verify )
193199 try :
@@ -202,9 +208,9 @@ def abort(self) -> None:
202208 def _reset_http (self ) -> None :
203209 """Replace the httpx client with a fresh instance.
204210
205- Called after connection-level retries are exhausted so a
206- poisoned pool (stale/dead connections) does not doom every
207- subsequent request in the session.
211+ Called before every connection-error retry (and on exhaustion)
212+ so a poisoned pool (stale/dead connections) never dooms the
213+ retry itself or every subsequent request in the session.
208214 """
209215 old = self ._http
210216 self ._http = httpx .Client (timeout = self .timeout , verify = self .verify )
@@ -267,6 +273,7 @@ def chat(
267273 on_tool_call : Callable [[str , str , str ], None ] | None = None ,
268274 stream : bool = True ,
269275 cancel_check : Callable [[], bool ] | None = None ,
276+ on_retry : Callable [[], None ] | None = None ,
270277 ) -> tuple [Message , Usage ]:
271278 """Send a chat request, return (assistant msg, usage).
272279
@@ -279,18 +286,27 @@ def chat(
279286
280287 Transient failures (HTTP 429 / 5xx, connection errors) are
281288 retried with exponential backoff + jitter up to ``retry_max``
282- attempts, honoring ``Retry-After`` when present. A retry only
283- happens before any delta has been delivered to the callbacks,
284- so streaming output is never duplicated for the caller. Other
285- 4xx errors are permanent and fail immediately. ``cancel_check``
286- (when given) is polled during backoff sleeps so an abort lands
287- promptly instead of after the full wait.
289+ attempts, honoring ``Retry-After`` when present. A connection
290+ error always swaps in a fresh httpx client first (``_reset_http``)
291+ so a dead connection never poisons the retry — and a stream that
292+ died mid-body IS retried even when deltas already reached the
293+ callers: the partial stream is discarded on retry (``on_retry``
294+ lets the caller drop its live text), so nothing is duplicated in
295+ the returned message. Other 4xx errors are permanent and fail
296+ immediately. ``cancel_check`` (when given) is polled during
297+ backoff sleeps so an abort lands promptly instead of after the
298+ full wait. ``on_retry`` (when given) is invoked right before a
299+ retry after a connection error, so a UI can clear the partial
300+ output and show that the request is being restarted.
288301 """
289302 payload = self ._payload (
290303 messages , tools , stream = stream , temperature = temperature ,
291304 max_tokens = max_tokens , system = system ,
292305 reasoning_effort = reasoning_effort ,
293306 )
307+ # a fresh turn may retry connection errors even if a previous
308+ # in-flight request was aborted (see abort/_aborted)
309+ self ._aborted = False
294310 usage = Usage ()
295311 emitted = False
296312
@@ -326,19 +342,22 @@ def wrap_tool_call(name: str, call_id: str, fragment: str) -> None:
326342 raise
327343 except httpx .HTTPError as e :
328344 # connection-level failures: connect errors, timeouts,
329- # dropped streams — all transient unless a delta already
330- # reached the caller (then a retry would duplicate it)
331- if emitted or attempt >= self .retry_max :
332- # Replace the client so a poisoned connection pool
333- # does not doom all subsequent requests in this
334- # session. Without this, a single network hiccup
335- # can leave the session stuck in a permanent error
336- # state (the dead connection stays in the pool and
337- # keeps getting reused).
338- self ._reset_http ()
345+ # dropped streams. Swap in a fresh client immediately —
346+ # a dead connection must not stay in the pool for the
347+ # retry — then retry the request, even when deltas
348+ # already reached the caller: the partial stream is
349+ # discarded on retry (on_retry lets the caller clear
350+ # its live text), so nothing is duplicated in the
351+ # stored message. Only give up once the per-request
352+ # attempt budget is exhausted — or immediately when
353+ # the request was aborted (Ctrl-C: the user asked to
354+ # stop, so a fresh attempt must not be started).
355+ self ._reset_http ()
356+ if self ._aborted or attempt >= self .retry_max :
339357 raise ApiError (f"network error: { e } " ) from e
358+ if on_retry is not None :
359+ on_retry ()
340360 if self ._sleep_backoff (attempt , None , cancel_check ):
341- self ._reset_http ()
342361 raise ApiError (f"network error: { e } " ) from e
343362
344363 content = "" .join (content_parts )
0 commit comments