From adc0bf40d24e9b7623e71cb7880b2ef4e85184ae Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:24:53 +0200 Subject: [PATCH 1/5] fix(client): add timeout override to create_bitstream Large bitstream uploads (100s of MB) were hitting the client's flat 60s default timeout mid-write and aborting with a ConnectionError. create_bitstream now accepts a per-call timeout, consistent with the timeout param already on api_post, and propagates it through the CSRF-retry recursion so an override isn't lost on retry. Co-Authored-By: Claude Sonnet 5 --- dspace_rest_client/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dspace_rest_client/client.py b/dspace_rest_client/client.py index 9cacb08..c56d9ef 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -830,7 +830,7 @@ def get_bitstreams(self, uuid=None, bundle=None, page=0, size=20, sort=None): bitstreams.append(Bitstream(bitstream_resource)) return bitstreams - def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadata=None, retry=False): + def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadata=None, retry=False, timeout=None): """ Upload a file and create a bitstream for a specified parent bundle, from the uploaded file and the supplied metadata. @@ -865,7 +865,8 @@ def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadat h.update({'Content-Encoding': 'gzip', 'User-Agent': self.USER_AGENT}) req = Request('POST', url, data=payload, headers=h, files=files) prepared_req = self.session.prepare_request(req) - r = self.session.send(prepared_req, proxies=self.proxies, timeout=self.timeout) + r = self.session.send(prepared_req, proxies=self.proxies, + timeout=timeout if timeout is not None else self.timeout) if 'DSPACE-XSRF-TOKEN' in r.headers: t = r.headers['DSPACE-XSRF-TOKEN'] _logger.debug('Updating token to ' + t) @@ -877,7 +878,7 @@ def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadat _logger.debug("Retrying request with updated CSRF token") else: self.authenticate() - return self.create_bitstream(bundle, name, path, mime, metadata, True) + return self.create_bitstream(bundle, name, path, mime, metadata, True, timeout) if r.status_code == 201 or r.status_code == 200: # Success From 31e4fd6012132cc349d454bbf6c18ad637e75e9d Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:31:57 +0200 Subject: [PATCH 2/5] test(client): pin CSRF-retry timeout preservation on create_bitstream The retry recursion in create_bitstream used to drop a caller-supplied timeout override on the retried request, silently falling back to the client default. Assert both the initial and retried request carry the same caller-supplied timeout. Co-Authored-By: Claude Sonnet 5 --- tests/test_client_write.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_client_write.py b/tests/test_client_write.py index 9e4815c..a8bbec3 100644 --- a/tests/test_client_write.py +++ b/tests/test_client_write.py @@ -192,6 +192,26 @@ def test_server_error_returns_none(self): bundle=bundle, name="a.pdf", path=self.path, mime="application/pdf")) + def test_csrf_retry_preserves_custom_timeout(self): + # the retry recursion used to drop a caller-supplied timeout override, + # silently falling back to the client default on the retried request + c = make_client() + bundle = Bundle(bundle_json("bnd")) + with requests_mock.Mocker() as m: + m.post(f"{API}/core/bundles/bnd/bitstreams", [ + {"status_code": 403, "json": {"message": "CSRF token invalid"}}, + {"status_code": 201, "json": bitstream_json("bsnew", "a.pdf", size=20)}, + ]) + bs = c.create_bitstream( + bundle=bundle, name="a.pdf", path=self.path, + mime="application/pdf", timeout=900) + self.assertIsInstance(bs, Bitstream) + # initial attempt + exactly one CSRF retry, both bounded by the + # caller's override rather than the client's flat default + self.assertEqual(len(m.request_history), 2) + self.assertEqual(m.request_history[0].timeout, 900) + self.assertEqual(m.request_history[1].timeout, 900) + class TestCreateClarinAllowances(unittest.TestCase): From 3d8fa5d877f4f47f94335bb1d36340ee60a1c797 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:46:39 +0200 Subject: [PATCH 3/5] docs(client): document the timeout param on __init__ and create_bitstream Both gained a timeout override but neither docstring mentioned it or what None falls back to. Co-Authored-By: Claude Sonnet 5 --- dspace_rest_client/client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dspace_rest_client/client.py b/dspace_rest_client/client.py index c56d9ef..d7ec4a0 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -103,6 +103,9 @@ def __init__(self, api_endpoint=API_ENDPOINT, username=USERNAME, password=PASSWO :param api_endpoint: base path to DSpace REST API, eg. http://localhost:8080/server/api :param username: username with appropriate privileges to perform operations on REST API :param password: password for the above username + :param timeout: default per-request timeout in seconds, used by every request unless a + method call overrides it (eg. create_bitstream's own timeout argument). + None (default) falls back to DEFAULT_TIMEOUT (60s). """ self.session = requests.Session() self.API_ENDPOINT = api_endpoint @@ -845,6 +848,10 @@ def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadat @param metadata: Full metadata JSON @param retry: A 'retried' indicator. If the first attempt fails due to an expired or missing auth token, the request will retry once, after the token is refreshed. (default: False) + @param timeout: Per-call timeout in seconds for this upload, overriding self.timeout - useful for + large files that need longer than the client's default. None (default) falls back + to self.timeout. Preserved across the CSRF-retry recursion, so it still applies + to the retried request. @return: constructed Bitstream object from the API response, or None if the operation failed. """ # TODO: It is probably wise to allow the bundle UUID to be simply passed as an alternative to having the full From 9f053e0c7fbdb6d84069fac72f196040f06a614e Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:49:08 +0200 Subject: [PATCH 4/5] fix(client): guard None r_json in create_bitstream's CSRF-retry check parse_json() returns None for a non-JSON 401/403 body (plain text, empty, HTML from a proxy). 'message' in r_json then raises TypeError instead of falling through to authenticate()+retry. Mirrors the (r_json or {}) guard api_post already uses for the same check. Co-Authored-By: Claude Sonnet 5 --- dspace_rest_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dspace_rest_client/client.py b/dspace_rest_client/client.py index d7ec4a0..a7e53fb 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -881,7 +881,7 @@ def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadat self.session.cookies.update({'X-XSRF-Token': t}) if not retry and r.status_code in (401, 403): r_json = parse_json(r) - if 'message' in r_json and 'CSRF token' in r_json['message']: + if 'message' in (r_json or {}) and 'CSRF token' in r_json['message']: _logger.debug("Retrying request with updated CSRF token") else: self.authenticate() From 952a6b1bc5c91595f0b8812ba0610df8a0950921 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:56:42 +0200 Subject: [PATCH 5/5] refactor(client): use keyword args in create_bitstream's retry recursion Was fully positional, unlike every other retry call in this file (api_post, api_put, api_put_uri, api_delete all pass retry=True / timeout=timeout by keyword). A 7-positional-arg call is one accidental reorder away from silently mis-wiring parameters on the retried request. Co-Authored-By: Claude Sonnet 5 --- dspace_rest_client/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dspace_rest_client/client.py b/dspace_rest_client/client.py index a7e53fb..061680f 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -885,7 +885,8 @@ def create_bitstream(self, bundle=None, name=None, path=None, mime=None, metadat _logger.debug("Retrying request with updated CSRF token") else: self.authenticate() - return self.create_bitstream(bundle, name, path, mime, metadata, True, timeout) + return self.create_bitstream(bundle=bundle, name=name, path=path, mime=mime, + metadata=metadata, retry=True, timeout=timeout) if r.status_code == 201 or r.status_code == 200: # Success