9494 APIResponseValidationError ,
9595)
9696from ._utils ._json import openapi_dumps
97+ from .lib .error_contract import is_safe_transport_retry
9798
9899log : logging .Logger = logging .getLogger (__name__ )
99100
@@ -665,7 +666,10 @@ def _enforce_trailing_slash(self, url: URL) -> URL:
665666 def _make_status_error_from_response (
666667 self ,
667668 response : httpx .Response ,
669+ * ,
670+ attempts : int = 1 ,
668671 ) -> APIStatusError :
672+ body : object | None
669673 if response .is_closed and not response .is_stream_consumed :
670674 # We can't read the response body as it has been closed
671675 # before it was read. This can happen if an event hook
@@ -677,12 +681,19 @@ def _make_status_error_from_response(
677681 body = err_text
678682
679683 try :
680- body = json .loads (err_text )
681- err_msg = f"Error code: { response .status_code } - { body } "
684+ body = cast (object , json .loads (err_text ))
685+ body_mapping = cast (Mapping [str , object ], body ) if isinstance (body , dict ) else None
686+ body_message = body_mapping .get ("message" ) if body_mapping is not None else None
687+ if isinstance (body_message , str ):
688+ err_msg = body_message
689+ else :
690+ err_msg = f"Error code: { response .status_code } - { body } "
682691 except Exception :
683692 err_msg = err_text or f"Error code: { response .status_code } "
684693
685- return self ._make_status_error (err_msg , body = body , response = response )
694+ error = self ._make_status_error (err_msg , body = cast (object , body ), response = response )
695+ error .attempts = attempts
696+ return error
686697
687698 def _make_status_error (
688699 self ,
@@ -1045,6 +1056,26 @@ def _should_retry(self, response: httpx.Response) -> bool:
10451056 log .debug ("Not retrying as header `x-should-retry` is set to `false`" )
10461057 return False
10471058
1059+ # These failures happen after a request or response may have been
1060+ # partially transferred. Retrying them implicitly can duplicate an
1061+ # execute or replay a multipart stream. Servers may explicitly opt in
1062+ # with X-Should-Retry when an idempotency record makes that safe.
1063+ try :
1064+ raw_payload = response .json ()
1065+ except Exception :
1066+ raw_payload = None
1067+ payload = cast (Mapping [str , object ], raw_payload ) if isinstance (raw_payload , dict ) else None
1068+ code = response .headers .get ("x-runloop-error-code" )
1069+ if code is None and isinstance (payload , dict ) and isinstance (payload .get ("error" ), str ):
1070+ code = payload ["error" ]
1071+ if code in {
1072+ "upload_request_body_idle_timeout" ,
1073+ "tunnel_backend_idle_timeout" ,
1074+ "tunnel_backend_connection_reset" ,
1075+ }:
1076+ log .debug ("Not retrying ambiguous transfer failure %s" , code )
1077+ return False
1078+
10481079 # Retry on request timeouts.
10491080 if response .status_code == 408 :
10501081 log .debug ("Retrying due to status code %i" , response .status_code )
@@ -1366,7 +1397,7 @@ def request(
13661397 except httpx .TimeoutException as err :
13671398 log .debug ("Encountered httpx.TimeoutException" , exc_info = True )
13681399
1369- if remaining_retries > 0 :
1400+ if remaining_retries > 0 and is_safe_transport_retry ( err ) :
13701401 self ._sleep_for_retry (
13711402 retries_taken = retries_taken ,
13721403 max_retries = max_retries ,
@@ -1377,11 +1408,11 @@ def request(
13771408 continue
13781409
13791410 log .debug ("Raising timeout error" )
1380- raise APITimeoutError (request = request ) from err
1411+ raise APITimeoutError (request = request , cause = err , attempts = retries_taken + 1 ) from err
13811412 except Exception as err :
13821413 log .debug ("Encountered Exception" , exc_info = True )
13831414
1384- if remaining_retries > 0 :
1415+ if remaining_retries > 0 and is_safe_transport_retry ( err ) :
13851416 self ._sleep_for_retry (
13861417 retries_taken = retries_taken ,
13871418 max_retries = max_retries ,
@@ -1392,7 +1423,7 @@ def request(
13921423 continue
13931424
13941425 log .debug ("Raising connection error" )
1395- raise APIConnectionError (request = request ) from err
1426+ raise APIConnectionError (request = request , cause = err , attempts = retries_taken + 1 ) from err
13961427
13971428 log .debug (
13981429 'HTTP Response: %s %s "%i %s" %s' ,
@@ -1424,7 +1455,7 @@ def request(
14241455 err .response .read ()
14251456
14261457 log .debug ("Re-raising status error" )
1427- raise self ._make_status_error_from_response (err .response ) from None
1458+ raise self ._make_status_error_from_response (err .response , attempts = retries_taken + 1 ) from None
14281459
14291460 break
14301461
@@ -2076,7 +2107,7 @@ async def request(
20762107 except httpx .TimeoutException as err :
20772108 log .debug ("Encountered httpx.TimeoutException" , exc_info = True )
20782109
2079- if remaining_retries > 0 :
2110+ if remaining_retries > 0 and is_safe_transport_retry ( err ) :
20802111 await self ._sleep_for_retry (
20812112 retries_taken = retries_taken ,
20822113 max_retries = max_retries ,
@@ -2087,11 +2118,11 @@ async def request(
20872118 continue
20882119
20892120 log .debug ("Raising timeout error" )
2090- raise APITimeoutError (request = request ) from err
2121+ raise APITimeoutError (request = request , cause = err , attempts = retries_taken + 1 ) from err
20912122 except Exception as err :
20922123 log .debug ("Encountered Exception" , exc_info = True )
20932124
2094- if remaining_retries > 0 :
2125+ if remaining_retries > 0 and is_safe_transport_retry ( err ) :
20952126 await self ._sleep_for_retry (
20962127 retries_taken = retries_taken ,
20972128 max_retries = max_retries ,
@@ -2102,7 +2133,7 @@ async def request(
21022133 continue
21032134
21042135 log .debug ("Raising connection error" )
2105- raise APIConnectionError (request = request ) from err
2136+ raise APIConnectionError (request = request , cause = err , attempts = retries_taken + 1 ) from err
21062137
21072138 log .debug (
21082139 'HTTP Response: %s %s "%i %s" %s' ,
@@ -2134,7 +2165,7 @@ async def request(
21342165 await err .response .aread ()
21352166
21362167 log .debug ("Re-raising status error" )
2137- raise self ._make_status_error_from_response (err .response ) from None
2168+ raise self ._make_status_error_from_response (err .response , attempts = retries_taken + 1 ) from None
21382169
21392170 break
21402171
0 commit comments