From c6b2021f3875e441dc1fd4a0167e58cdcaa818b0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Sep 2026 00:05:44 +0200 Subject: [PATCH 1/3] Fix successful request retry returning previous failure '_do_rest_request' stored the error response of a failed attempt (connection error, timeout, 502, 503) and never cleared it. After the retry loop the stored failure was returned even when a later attempt succeeded. Reset it at the start of each attempt. Co-Authored-By: Claude Opus 5 --- ayon_api/server_api.py | 3 +++ tests/fake_transfer.py | 41 +++++++++++++++++++++++++++++++++++ tests/test_request_retries.py | 33 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 tests/fake_transfer.py create mode 100644 tests/test_request_retries.py diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index e42add68c..b41c44392 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -1560,6 +1560,9 @@ def _do_rest_request( response = None new_response = None for retry_idx in reversed(range(max_retries)): + # Reset failure of previous attempt, otherwise a successful + # retry would return the previous failure + new_response = None try: response = function(url, **kwargs) diff --git a/tests/fake_transfer.py b/tests/fake_transfer.py new file mode 100644 index 000000000..536c62307 --- /dev/null +++ b/tests/fake_transfer.py @@ -0,0 +1,41 @@ +"""Helpers to test requests and file transfers without AYON server.""" +import requests +from requests.structures import CaseInsensitiveDict + + +class FakeResponse: + """Minimal stand-in for 'requests.Response'.""" + def __init__( + self, status_code=200, content=b"", headers=None, json_data=None + ): + self.status_code = status_code + self.content = content + self.headers = CaseInsensitiveDict(headers or {}) + self._json_data = json_data + self.reason = "Reason" + self.text = content.decode(errors="ignore") + + @property + def ok(self): + return self.status_code < 400 + + def raise_for_status(self): + if not self.ok: + raise requests.exceptions.HTTPError( + f"{self.status_code} Error", response=self + ) + + def json(self): + if self._json_data is None: + raise ValueError("No json") + return self._json_data + + def iter_content(self, chunk_size=1): + for idx in range(0, len(self.content), chunk_size): + yield self.content[idx:idx + chunk_size] + + def __enter__(self): + return self + + def __exit__(self, *args): + return False diff --git a/tests/test_request_retries.py b/tests/test_request_retries.py new file mode 100644 index 000000000..d351e06b0 --- /dev/null +++ b/tests/test_request_retries.py @@ -0,0 +1,33 @@ +"""Retries of REST requests. Does not require running AYON server.""" +import pytest +import requests + +from ayon_api.server_api import ServerAPI + +from .fake_transfer import FakeResponse + + +@pytest.fixture +def con(monkeypatch): + monkeypatch.setattr("time.sleep", lambda *args, **kwargs: None) + return ServerAPI("http://localhost:0", create_session=False, max_retries=3) + + +@pytest.mark.parametrize( + "failure", + [requests.exceptions.ConnectionError("down"), FakeResponse(503)], +) +def test_successful_retry_returns_successful_response(con, failure): + responses = [failure, FakeResponse(200, json_data={"ok": True})] + + def request_func(url, **kwargs): + item = responses.pop(0) + if isinstance(item, Exception): + raise item + return item + + response = con._do_rest_request( + request_func, "http://localhost:0/api/x", handle_invalid_token=False + ) + assert response.status_code == 200 + assert response.data == {"ok": True} From 0ab0881a3acdbc2c02bf4a7698003a67c040701c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 14 Sep 2026 16:21:31 +0200 Subject: [PATCH 2/3] Remove redundant comment --- ayon_api/server_api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index 93d4d3df7..029ff61ad 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -1560,8 +1560,6 @@ def _do_rest_request( response = None new_response = None for retry_idx in reversed(range(max_retries)): - # Reset failure of previous attempt, otherwise a successful - # retry would return the previous failure new_response = None try: response = function(url, **kwargs) From 83cf41c5716a1aa6b00657880fbda755275568b2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 14 Sep 2026 16:22:15 +0200 Subject: [PATCH 3/3] Explain how the test works with a small comment --- tests/test_request_retries.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_request_retries.py b/tests/test_request_retries.py index d351e06b0..22708c78c 100644 --- a/tests/test_request_retries.py +++ b/tests/test_request_retries.py @@ -20,6 +20,8 @@ def con(monkeypatch): def test_successful_retry_returns_successful_response(con, failure): responses = [failure, FakeResponse(200, json_data={"ok": True})] + # `_do_rest_request` expects a callable that takes a URL and returns a + # response. So we just return fake responses to simulate it. def request_func(url, **kwargs): item = responses.pop(0) if isinstance(item, Exception):