Retry transient API timeouts during PR reviews (#903)
Actions CI / Setup-uv-Windows (push) Waiting to run
Actions CI / Test (macos-latest, 3.14) (push) Waiting to run
Actions CI / Test (macos-latest, 3.8) (push) Waiting to run
Actions CI / Test (ubuntu-latest, 3.14) (push) Failing after 4s
Publish to PyPI / check (push) Skipped
Publish to PyPI / build (push) Skipped
Publish to PyPI / publish (push) Skipped
Publish to PyPI / sbom (push) Skipped
Publish to PyPI / notify (push) Skipped
Actions CI / Test (ubuntu-latest, 3.8) (push) Failing after 4s
Publish to PyPI / deploy-actions (push) Failing after 1s
Actions CI / Setup-uv-Windows (push) Waiting to run
Actions CI / Test (macos-latest, 3.14) (push) Waiting to run
Actions CI / Test (macos-latest, 3.8) (push) Waiting to run
Actions CI / Test (ubuntu-latest, 3.14) (push) Failing after 4s
Publish to PyPI / check (push) Skipped
Publish to PyPI / build (push) Skipped
Publish to PyPI / publish (push) Skipped
Publish to PyPI / sbom (push) Skipped
Publish to PyPI / notify (push) Skipped
Actions CI / Test (ubuntu-latest, 3.8) (push) Failing after 4s
Publish to PyPI / deploy-actions (push) Failing after 1s
This commit is contained in:
+1
-1
@@ -28,4 +28,4 @@
|
|||||||
# ├── test_summarize_pr.py
|
# ├── test_summarize_pr.py
|
||||||
# └── ...
|
# └── ...
|
||||||
|
|
||||||
__version__ = "0.3.23"
|
__version__ = "0.3.24"
|
||||||
|
|||||||
@@ -327,9 +327,8 @@ def _post_openai_response(
|
|||||||
return (
|
return (
|
||||||
_poll_openai_response(response_json, headers) if response_json.get("status") else response_json
|
_poll_openai_response(response_json, headers) if response_json.get("status") else response_json
|
||||||
), elapsed
|
), elapsed
|
||||||
except (requests.exceptions.ConnectionError, json.JSONDecodeError):
|
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, json.JSONDecodeError):
|
||||||
# ConnectTimeout subclasses ConnectionError so it stays retryable; a ReadTimeout propagates instead,
|
# Retry TLS/read timeouts too, accepting that a response timeout may repeat an already-billed request.
|
||||||
# because the request may have completed server-side and re-POSTing it would double-bill.
|
|
||||||
if attempt < retries:
|
if attempt < retries:
|
||||||
print(f"Retrying API request in {2**attempt}s (attempt {attempt + 1}/{retries + 1})...")
|
print(f"Retrying API request in {2**attempt}s (attempt {attempt + 1}/{retries + 1})...")
|
||||||
time.sleep(2**attempt)
|
time.sleep(2**attempt)
|
||||||
@@ -666,9 +665,8 @@ def get_response(
|
|||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
except (requests.exceptions.ConnectionError, json.JSONDecodeError) as e:
|
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, json.JSONDecodeError) as e:
|
||||||
# ConnectTimeout subclasses ConnectionError so it stays retryable; a ReadTimeout propagates instead,
|
# Retry TLS/read timeouts too, accepting that a response timeout may repeat an already-billed request.
|
||||||
# because the request may have completed server-side and re-POSTing it would double-bill.
|
|
||||||
if attempt < retries:
|
if attempt < retries:
|
||||||
print(f"Retrying {e.__class__.__name__} in {2**attempt}s (attempt {attempt + 1}/{retries + 1})...")
|
print(f"Retrying {e.__class__.__name__} in {2**attempt}s (attempt {attempt + 1}/{retries + 1})...")
|
||||||
time.sleep(2**attempt)
|
time.sleep(2**attempt)
|
||||||
|
|||||||
+23
-11
@@ -1,7 +1,7 @@
|
|||||||
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
@@ -149,19 +149,31 @@ def test_get_response(mock_post):
|
|||||||
assert mock_post.call_args.kwargs["json"]["prompt_cache_options"] == {"mode": "explicit"}
|
assert mock_post.call_args.kwargs["json"]["prompt_cache_options"] == {"mode": "explicit"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("agent", [False, True])
|
||||||
|
@pytest.mark.parametrize("failures", [1, 3])
|
||||||
@patch("time.sleep")
|
@patch("time.sleep")
|
||||||
@patch("requests.post")
|
@patch("requests.post")
|
||||||
def test_get_response_read_timeout_propagates(mock_post, mock_sleep):
|
def test_response_read_timeout_retries(mock_post, mock_sleep, agent, failures):
|
||||||
"""Test a read timeout is NOT retried: the request may have completed server-side and re-POSTing double-bills."""
|
"""Read timeouts recover with bounded backoff in both completion and agent requests."""
|
||||||
mock_post.side_effect = requests.exceptions.ReadTimeout()
|
response = MagicMock(status_code=200)
|
||||||
|
response.elapsed.total_seconds.return_value = 1.0
|
||||||
|
response.json.return_value = {
|
||||||
|
"output": [{"type": "message", "content": [{"type": "output_text", "text": "recovered"}]}]
|
||||||
|
}
|
||||||
|
mock_post.side_effect = [requests.exceptions.ReadTimeout()] * failures + [response]
|
||||||
|
kwargs = {"tools": [], "tool_handlers": {}} if agent else {"check_links": False}
|
||||||
|
generate = get_agent_response if agent else get_response
|
||||||
with patch("actions.utils.openai_utils.OPENAI_API_KEY", "test-key"):
|
with patch("actions.utils.openai_utils.OPENAI_API_KEY", "test-key"):
|
||||||
try:
|
if failures > 2:
|
||||||
get_response([{"role": "user", "content": "Hello"}], check_links=False, retries=2)
|
with pytest.raises(requests.exceptions.ReadTimeout):
|
||||||
raise AssertionError("ReadTimeout should propagate")
|
generate([{"role": "user", "content": "Hello"}], model=OPENAI_MODEL_DEFAULT, retries=2, **kwargs)
|
||||||
except requests.exceptions.ReadTimeout:
|
else:
|
||||||
pass
|
assert (
|
||||||
assert mock_post.call_count == 1 # no re-POST of a possibly-billed request
|
generate([{"role": "user", "content": "Hello"}], model=OPENAI_MODEL_DEFAULT, retries=2, **kwargs)
|
||||||
|
== "recovered"
|
||||||
|
)
|
||||||
|
assert mock_post.call_count == min(failures + 1, 3)
|
||||||
|
assert mock_sleep.call_args_list == [call(2**attempt) for attempt in range(min(failures, 2))]
|
||||||
|
|
||||||
|
|
||||||
@patch("time.sleep")
|
@patch("time.sleep")
|
||||||
|
|||||||
Reference in New Issue
Block a user