From ae842bb54bde91a7aaca0df7deff4e458aebef4e Mon Sep 17 00:00:00 2001 From: Sergei Date: Mon, 16 Feb 2026 15:24:55 +0300 Subject: [PATCH 1/4] fix: add missing timeouts to server accessor (#58) --- source/ftrack_api/accessor/server.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/source/ftrack_api/accessor/server.py b/source/ftrack_api/accessor/server.py index da6b0262..c3f41adb 100644 --- a/source/ftrack_api/accessor/server.py +++ b/source/ftrack_api/accessor/server.py @@ -4,7 +4,6 @@ import os import hashlib import base64 -import json import requests @@ -22,6 +21,7 @@ def __init__(self, resource_identifier, session, mode="rb"): self.mode = mode self.resource_identifier = resource_identifier self._session = session + self._timeout = session.request_timeout self._has_read = False super(ServerFile, self).__init__() @@ -54,6 +54,7 @@ def _read(self): "apiKey": self._session.api_key, }, stream=True, + timeout=self._timeout, ) try: @@ -105,7 +106,10 @@ def _write(self): # Put the file based on the metadata. response = requests.put( - metadata["url"], data=self.wrapped_file, headers=metadata["headers"] + metadata["url"], + data=self.wrapped_file, + headers=metadata["headers"], + timeout=self._timeout, ) try: @@ -153,6 +157,7 @@ def __init__(self, session, **kw): super(_ServerAccessor, self).__init__(**kw) self._session = session + self._timeout = session.request_timeout def open(self, resource_identifier, mode="rb"): """Return :py:class:`~ftrack_api.Data` for *resource_identifier*.""" @@ -167,6 +172,7 @@ def remove(self, resourceIdentifier): "username": self._session.api_user, "apiKey": self._session.api_key, }, + timeout=self._timeout, ) if response.status_code != 200: raise ftrack_api.exception.AccessorOperationFailedError( From 5f3167e4b5141e05defb1aa17fda309308084ad7 Mon Sep 17 00:00:00 2001 From: Dennis Weil Date: Mon, 16 Feb 2026 12:55:44 +0100 Subject: [PATCH 2/4] fix: consistent exception handling for server accessor operations Wrap all HTTP requests in the server accessor with consistent try/except blocks that convert exceptions (including timeouts) to AccessorOperationFailedError. This ensures callers get a uniform error type regardless of the failure mode. Changes to server.py: - Wrap requests.get() in _read() with exception handling - Wrap requests.get() in remove() with exception handling - Replace manual status code check in remove() with raise_for_status() for consistency with _read() and _write() Changes to test_server.py: - Add separate tests for read, write, and remove timeout scenarios - Use 0.0001s timeout to trigger real timeouts against the server - All tests verify AccessorOperationFailedError is raised with timeout info --- source/ftrack_api/accessor/server.py | 55 +++++++++++++++++---------- test/unit/accessor/test_server.py | 57 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/source/ftrack_api/accessor/server.py b/source/ftrack_api/accessor/server.py index c3f41adb..0c9e5f78 100644 --- a/source/ftrack_api/accessor/server.py +++ b/source/ftrack_api/accessor/server.py @@ -46,16 +46,21 @@ def _read(self): position = self.tell() self.seek(0) - response = requests.get( - "{0}/component/get".format(self._session.server_url), - params={ - "id": self.resource_identifier, - "username": self._session.api_user, - "apiKey": self._session.api_key, - }, - stream=True, - timeout=self._timeout, - ) + try: + response = requests.get( + "{0}/component/get".format(self._session.server_url), + params={ + "id": self.resource_identifier, + "username": self._session.api_user, + "apiKey": self._session.api_key, + }, + stream=True, + timeout=self._timeout, + ) + except Exception as error: + raise ftrack_api.exception.AccessorOperationFailedError( + "Failed to read data: {0}.".format(error) + ) try: response.raise_for_status() @@ -165,18 +170,26 @@ def open(self, resource_identifier, mode="rb"): def remove(self, resourceIdentifier): """Remove *resourceIdentifier*.""" - response = requests.get( - "{0}/component/remove".format(self._session.server_url), - params={ - "id": resourceIdentifier, - "username": self._session.api_user, - "apiKey": self._session.api_key, - }, - timeout=self._timeout, - ) - if response.status_code != 200: + try: + response = requests.get( + "{0}/component/remove".format(self._session.server_url), + params={ + "id": resourceIdentifier, + "username": self._session.api_user, + "apiKey": self._session.api_key, + }, + timeout=self._timeout, + ) + except Exception as error: + raise ftrack_api.exception.AccessorOperationFailedError( + "Failed to remove file: {0}.".format(error) + ) + + try: + response.raise_for_status() + except requests.exceptions.HTTPError as error: raise ftrack_api.exception.AccessorOperationFailedError( - "Failed to remove file." + "Failed to remove file: {0}.".format(error) ) def get_container(self, resource_identifier): diff --git a/test/unit/accessor/test_server.py b/test/unit/accessor/test_server.py index bf366c29..935773cc 100644 --- a/test/unit/accessor/test_server.py +++ b/test/unit/accessor/test_server.py @@ -39,3 +39,60 @@ def test_remove_data(new_component, session): data = accessor.open(new_component["id"], "r") with pytest.raises(ftrack_api.exception.AccessorOperationFailedError): data.read() + + +def test_read_timeout(new_component, session, monkeypatch): + """Test that read operations respect timeout settings.""" + random_data = uuid.uuid1().hex.encode() + + # First, write some data so there's something to read + accessor = ftrack_api.accessor.server._ServerAccessor(session) + http_file = accessor.open(new_component["id"], mode="wb") + http_file.write(random_data) + http_file.close() + + # Set an impossibly short timeout - no server can respond this fast + monkeypatch.setattr(session, "request_timeout", 0.0001) + + # Open a new file handle (this captures the patched timeout) + data = accessor.open(new_component["id"], "r") + with pytest.raises( + ftrack_api.exception.AccessorOperationFailedError, match="timed out" + ): + data.read() + + +def test_write_timeout(new_component, session, monkeypatch): + """Test that write operations respect timeout settings.""" + random_data = uuid.uuid1().hex.encode() + + # Set an impossibly short timeout + monkeypatch.setattr(session, "request_timeout", 0.0001) + + accessor = ftrack_api.accessor.server._ServerAccessor(session) + http_file = accessor.open(new_component["id"], mode="wb") + http_file.write(random_data) + + # Timeout is caught and wrapped in AccessorOperationFailedError + with pytest.raises( + ftrack_api.exception.AccessorOperationFailedError, match="timed out" + ): + http_file.close() # close() triggers flush() which triggers _write() + + +def test_remove_timeout(new_component, session, monkeypatch): + """Test that remove operations respect timeout settings.""" + # Write something first + accessor = ftrack_api.accessor.server._ServerAccessor(session) + http_file = accessor.open(new_component["id"], mode="wb") + http_file.write(b"test data") + http_file.close() + + # Set timeout and create new accessor to pick up the timeout for remove() + monkeypatch.setattr(session, "request_timeout", 0.0001) + accessor = ftrack_api.accessor.server._ServerAccessor(session) + + with pytest.raises( + ftrack_api.exception.AccessorOperationFailedError, match="timed out" + ): + accessor.remove(new_component["id"]) From 417dd98c50c16cd5a27957ee33c4b3ec88552bd8 Mon Sep 17 00:00:00 2001 From: Dennis Weil Date: Mon, 16 Feb 2026 16:08:42 +0100 Subject: [PATCH 3/4] Cosmetic changes for consistency --- test/unit/accessor/test_server.py | 35 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/test/unit/accessor/test_server.py b/test/unit/accessor/test_server.py index 935773cc..65c10f06 100644 --- a/test/unit/accessor/test_server.py +++ b/test/unit/accessor/test_server.py @@ -11,27 +11,28 @@ import ftrack_api.data -def test_read_and_write(new_component, session): - """Read and write data from server accessor.""" - random_data = uuid.uuid1().hex.encode() +@pytest.fixture(scope="session") +def random_binary_data(): + return uuid.uuid1().hex.encode() + +def test_read_and_write(new_component, random_binary_data, session): + """Read and write data from server accessor.""" accessor = ftrack_api.accessor.server._ServerAccessor(session) http_file = accessor.open(new_component["id"], mode="wb") - http_file.write(random_data) + http_file.write(random_binary_data) http_file.close() data = accessor.open(new_component["id"], "r") - assert data.read() == random_data, "Read data is the same as written." + assert data.read() == random_binary_data, "Read data is the same as written." data.close() -def test_remove_data(new_component, session): +def test_remove_data(new_component, random_binary_data, session): """Remove data using server accessor.""" - random_data = uuid.uuid1().hex - accessor = ftrack_api.accessor.server._ServerAccessor(session) http_file = accessor.open(new_component["id"], mode="wb") - http_file.write(random_data) + http_file.write(random_binary_data) http_file.close() accessor.remove(new_component["id"]) @@ -41,14 +42,12 @@ def test_remove_data(new_component, session): data.read() -def test_read_timeout(new_component, session, monkeypatch): +def test_read_timeout(new_component, random_binary_data, session, monkeypatch): """Test that read operations respect timeout settings.""" - random_data = uuid.uuid1().hex.encode() - # First, write some data so there's something to read accessor = ftrack_api.accessor.server._ServerAccessor(session) http_file = accessor.open(new_component["id"], mode="wb") - http_file.write(random_data) + http_file.write(random_binary_data) http_file.close() # Set an impossibly short timeout - no server can respond this fast @@ -62,16 +61,14 @@ def test_read_timeout(new_component, session, monkeypatch): data.read() -def test_write_timeout(new_component, session, monkeypatch): +def test_write_timeout(new_component, random_binary_data, session, monkeypatch): """Test that write operations respect timeout settings.""" - random_data = uuid.uuid1().hex.encode() - # Set an impossibly short timeout monkeypatch.setattr(session, "request_timeout", 0.0001) accessor = ftrack_api.accessor.server._ServerAccessor(session) http_file = accessor.open(new_component["id"], mode="wb") - http_file.write(random_data) + http_file.write(random_binary_data) # Timeout is caught and wrapped in AccessorOperationFailedError with pytest.raises( @@ -80,12 +77,12 @@ def test_write_timeout(new_component, session, monkeypatch): http_file.close() # close() triggers flush() which triggers _write() -def test_remove_timeout(new_component, session, monkeypatch): +def test_remove_timeout(new_component, random_binary_data, session, monkeypatch): """Test that remove operations respect timeout settings.""" # Write something first accessor = ftrack_api.accessor.server._ServerAccessor(session) http_file = accessor.open(new_component["id"], mode="wb") - http_file.write(b"test data") + http_file.write(random_binary_data) http_file.close() # Set timeout and create new accessor to pick up the timeout for remove() From 39eb451a2dd86e5366f3c851d8d2fa1a3324f189 Mon Sep 17 00:00:00 2001 From: Dennis Weil Date: Mon, 16 Feb 2026 16:17:21 +0100 Subject: [PATCH 4/4] Localise fixture to module --- test/unit/accessor/test_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/accessor/test_server.py b/test/unit/accessor/test_server.py index 65c10f06..b1789428 100644 --- a/test/unit/accessor/test_server.py +++ b/test/unit/accessor/test_server.py @@ -11,7 +11,7 @@ import ftrack_api.data -@pytest.fixture(scope="session") +@pytest.fixture(scope="module") def random_binary_data(): return uuid.uuid1().hex.encode()