From e297473377dac60ea8c31ef972ea6e36511908b3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 13 Sep 2026 00:06:14 +0200 Subject: [PATCH] Fix 'api/' endpoint auto-fix of file transfers - Web frontend answers unknown urls (without 'api/') with 'index.html' and status 200, so the 404 based auto-fix never happened and download saved the html page as the file. - The auto-fix consumed a retry attempt. With 'max_retries' 1 the transfer ended without sending the fixed request. - Upload progress was not reset when content was sent again to fixed url, so transferred size was doubled. - Download stored fixed url as destination url instead of source url. Co-Authored-By: Claude Opus 5 --- ayon_api/server_api.py | 62 ++++++++++++++++++++----------- tests/test_transfer_api_prefix.py | 53 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 22 deletions(-) create mode 100644 tests/test_transfer_api_prefix.py diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index 5c469e30d..0160f3c4e 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -1662,7 +1662,9 @@ def _download_file_to_stream( retries = max(self.max_retries, 1) api_prepended = False - for attempt in range(retries): + attempt = 0 + while True: + attempt += 1 # Continue in download offset = progress.get_transferred_size() if offset > 0: @@ -1671,20 +1673,29 @@ def _download_file_to_stream( try: with get_func(url, **kwargs) as response: # Auto-fix missing 'api/' + # NOTE Web frontend returns 'index.html' with status 200 + # for unknown urls, which is not a file to download. + is_frontend_page = ( + response.ok + and "text/html" in response.headers.get( + "Content-Type", "" + ) + ) if ( - response.status_code in (404, 405) + ( + response.status_code in (404, 405) + or is_frontend_page + ) and not api_prepended + and not endpoint.startswith(self._base_url) + and not endpoint.startswith("api/") ): api_prepended = True - if ( - not endpoint.startswith(self._base_url) - and not endpoint.startswith("api/") - ): - url = self._endpoint_to_url( - endpoint, use_rest=True - ) - progress.set_destination_url(url) - continue + url = self._endpoint_to_url(endpoint, use_rest=True) + progress.set_source_url(url) + # Endpoint fix is not a failed attempt + attempt -= 1 + continue response.raise_for_status() if offset > 0 and response.status_code != 206: # Server ignored 'Range' and sends whole file again, @@ -1722,7 +1733,7 @@ def _download_file_to_stream( requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError, ): - if attempt == retries - 1: + if attempt >= retries: raise progress.next_attempt() @@ -2127,7 +2138,9 @@ def _upload_file( progress.set_content_size(size) api_prepended = False - for attempt in range(retries): + attempt = 0 + while True: + attempt += 1 try: response = post_func( url, @@ -2137,22 +2150,27 @@ def _upload_file( **kwargs ) # Auto-fix missing 'api/' - if response.status_code in (404, 405) and not api_prepended: + if ( + response.status_code in (404, 405) + and not api_prepended + and not endpoint.startswith(self._base_url) + and not endpoint.startswith("api/") + ): api_prepended = True - if ( - not endpoint.startswith(self._base_url) - and not endpoint.startswith("api/") - ): - url = self._endpoint_to_url(endpoint, use_rest=True) - progress.set_destination_url(url) - continue + url = self._endpoint_to_url(endpoint, use_rest=True) + progress.set_destination_url(url) + # Content is sent again and endpoint fix is not a failed + # attempt + progress.reset_transferred() + attempt -= 1 + continue break except ( requests.exceptions.Timeout, requests.exceptions.ConnectionError, ): - if attempt == retries - 1: + if attempt >= retries: raise progress.next_attempt() progress.reset_transferred() diff --git a/tests/test_transfer_api_prefix.py b/tests/test_transfer_api_prefix.py new file mode 100644 index 000000000..d947eac57 --- /dev/null +++ b/tests/test_transfer_api_prefix.py @@ -0,0 +1,53 @@ +"""Auto-fix of missing 'api/' in transfer endpoints. + +Does not require running AYON server. +""" +import io + +import pytest + +from ayon_api.server_api import ServerAPI +from ayon_api.utils import RequestTypes, TransferProgress + +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=1) + + +def test_download_frontend_page_uses_api_url(con): + urls = [] + + def get_func(url, **kwargs): + urls.append(url) + if "/api/" not in url: + # Web frontend serves 'index.html' for unknown urls + return FakeResponse(200, b"", {"Content-Type": "text/html"}) + return FakeResponse(200, b"PNG", {"Content-Length": "3"}) + + con._base_functions_mapping[RequestTypes.get] = get_func + stream = io.BytesIO() + con.download_file_to_stream("projects/p/thumbnails/1", stream) + + assert stream.getvalue() == b"PNG" + assert urls[-1] == "http://localhost:0/api/projects/p/thumbnails/1" + + +def test_upload_autofix_with_single_attempt_and_progress(con): + def put_func(url, data=None, **kwargs): + list(data) + if "/api/" not in url: + return FakeResponse(404) + return FakeResponse(200) + + con._base_functions_mapping[RequestTypes.put] = put_func + progress = TransferProgress() + response = con.upload_file_from_stream( + "upload", io.BytesIO(b"0123456789"), progress + ) + + assert response.status_code == 200 + assert progress.transferred_size == 10