From 28d7e7258a1cf28367245473ce98ac5daf712541 Mon Sep 17 00:00:00 2001 From: Pascal-Nicolas Becker Date: Tue, 3 Feb 2026 18:28:08 +0100 Subject: [PATCH 1/4] Use decorator to update csrf token instead of parameter retry --- dspace_rest_client/client.py | 131 +++++++++-------------------------- 1 file changed, 34 insertions(+), 97 deletions(-) diff --git a/dspace_rest_client/client.py b/dspace_rest_client/client.py index 4bc57e7..893293a 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -156,9 +156,31 @@ def do_paginate(url, params): return fun(do_paginate, self, *args, **kwargs) return decorated - return decorator + @staticmethod + def refresh_csrf(func): + @functools.wraps(func) + def decorated(self, *args, refresh_csrf=True, **kwargs): + r = func(self, *args, **kwargs) + if r is None: + return r + if r.status_code == 403 and refresh_csrf: + logging.debug("Retrying request with updated CSRF token") + r = func(self, *args, **kwargs) + r_json = None + try: + r_json = r.json() + except ValueError: + logging.warning("Tried to refresh CSRF token after getting a 403, got a non json-response.") + return r + if r is not None and "message" in r_json and "CSRF token" in r_json["message"]: + logging.warning( + "Too many retries updating token: %s: %s", r.status_code, r.text + ) + return r + return decorated + def __init__( self, api_endpoint=API_ENDPOINT, @@ -292,14 +314,14 @@ def api_get(self, url, params=None, data=None, headers=None): self.update_token(r) return r - def api_post(self, url, params, json, retry=False): + @refresh_csrf + def api_post(self, url, params, json): """ Perform a POST request. Refresh XSRF token if necessary. POSTs are typically used to create objects. @param url: DSpace REST API URL @param params: Any parameters to include (eg ?parent=abbc-....) @param json: Data in json-ready form (dict) to send as POST body (eg. item.as_dict()) - @param retry: Has this method already been retried? Used if we need to refresh XSRF. @return: Response from API """ r = self.session.post( @@ -307,32 +329,16 @@ def api_post(self, url, params, json, retry=False): proxies=self.proxies ) self.update_token(r) - - if r.status_code == 403: - # 403 Forbidden - # If we had a CSRF failure, retry the request with the updated token - # After speaking in #dev it seems that these do need occasional refreshes but I suspect - # it's happening too often for me, so check for accidentally triggering it - r_json = parse_json(r) - if "message" in r_json and "CSRF token" in r_json["message"]: - if retry: - logging.warning( - "Too many retries updating token: %s: %s", r.status_code, r.text - ) - else: - logging.debug("Retrying request with updated CSRF token") - return self.api_post(url, params=params, json=json, retry=True) - return r - def api_post_uri(self, url, params, uri_list, retry=False): + @refresh_csrf + def api_post_uri(self, url, params, uri_list): """ Perform a POST request. Refresh XSRF token if necessary. POSTs are typically used to create objects. @param url: DSpace REST API URL @param params: Any parameters to include (eg ?parent=abbc-....) @param uri_list: One or more URIs referencing objects - @param retry: Has this method already been retried? Used if we need to refresh XSRF. @return: Response from API """ r = self.session.post( @@ -340,34 +346,16 @@ def api_post_uri(self, url, params, uri_list, retry=False): proxies=self.proxies ) self.update_token(r) - - if r.status_code == 403: - # 403 Forbidden - # If we had a CSRF failure, retry the request with the updated token - # After speaking in #dev it seems that these do need occasional refreshes but I suspect - # it's happening too often for me, so check for accidentally triggering it - r_json = r.json() - if "message" in r_json and "CSRF token" in r_json["message"]: - if retry: - logging.warning( - "Too many retries updating token: %s: %s", r.status_code, r.text - ) - else: - logging.debug("Retrying request with updated CSRF token") - return self.api_post_uri( - url, params=params, uri_list=uri_list, retry=True - ) - return r - def api_put(self, url, params, json, retry=False): + @refresh_csrf + def api_put(self, url, params, json): """ Perform a PUT request. Refresh XSRF token if necessary. PUTs are typically used to update objects. @param url: DSpace REST API URL @param params: Any parameters to include (eg ?parent=abbc-....) @param json: Data in json-ready form (dict) to send as PUT body (eg. item.as_dict()) - @param retry: Has this method already been retried? Used if we need to refresh XSRF. @return: Response from API """ r = self.session.put( @@ -375,64 +363,28 @@ def api_put(self, url, params, json, retry=False): proxies=self.proxies ) self.update_token(r) - - if r.status_code == 403: - # 403 Forbidden - # If we had a CSRF failure, retry the request with the updated token - # After speaking in #dev it seems that these do need occasional refreshes but I suspect - # it's happening too often for me, so check for accidentally triggering it - logging.debug(r.text) - # Parse response - r_json = parse_json(r) - if "message" in r_json and "CSRF token" in r_json["message"]: - if retry: - logging.warning( - "Too many retries updating token: %s: %s", r.status_code, r.text - ) - else: - logging.debug("Retrying request with updated CSRF token") - return self.api_put(url, params=params, json=json, retry=True) - return r - def api_delete(self, url, params, retry=False): + @refresh_csrf + def api_delete(self, url, params): """ Perform a DELETE request. Refresh XSRF token if necessary. DELETES are typically used to update objects. @param url: DSpace REST API URL @param params: Any parameters to include (eg ?parent=abbc-....) - @param retry: Has this method already been retried? Used if we need to refresh XSRF. @return: Response from API """ r = self.session.delete(url, params=params, headers=self.request_headers) self.update_token(r) - - if r.status_code == 403: - # 403 Forbidden - # If we had a CSRF failure, retry the request with the updated token - # After speaking in #dev it seems that these do need occasional refreshes but I suspect - # it's happening too often for me, so check for accidentally triggering it - logging.debug(r.text) - # Parse response - r_json = parse_json(r) - if "message" in r_json and "CSRF token" in r_json["message"]: - if retry: - logging.warning( - "Too many retries updating token: %s: %s", r.status_code, r.text - ) - else: - logging.debug("Retrying request with updated CSRF token") - return self.api_delete(url, params=params, retry=True) - return r - def api_patch(self, url, operation, path, value, params=None, retry=False): + @refresh_csrf + def api_patch(self, url, operation, path, value, params=None): """ @param url: DSpace REST API URL @param operation: 'add', 'remove', 'replace', or 'move' (see PatchOperation enumeration) @param path: path to perform operation - eg, metadata, withdrawn, etc. @param value: new value for add or replace operations, or 'original' path for move operations - @param retry: Has this method already been retried? Used if we need to refresh XSRF. @return: @see https://github.com/DSpace/RestContract/blob/main/metadata-patch.md """ @@ -470,22 +422,7 @@ def api_patch(self, url, operation, path, value, params=None, retry=False): ) self.update_token(r) - if r.status_code == 403: - # 403 Forbidden - # If we had a CSRF failure, retry the request with the updated token - # After speaking in #dev it seems that these do need occasional refreshes but I suspect - # it's happening too often for me, so check for accidentally triggering it - logging.debug(r.text) - r_json = parse_json(r) - if "message" in r_json and "CSRF token" in r_json["message"]: - if retry: - logging.warning( - "Too many retries updating token: %s: %s", r.status_code, r.text - ) - else: - logging.debug("Retrying request with updated CSRF token") - return self.api_patch(url, operation, path, value, params, True) - elif r.status_code == 200: + if r.status_code == 200: # 200 Success logging.info( "successful patch update to %s %s", r.json()["type"], r.json()["id"] From 427dd648d00855186ab7cc8112a74e372920ed4c Mon Sep 17 00:00:00 2001 From: Pascal-Nicolas Becker Date: Tue, 3 Feb 2026 18:29:45 +0100 Subject: [PATCH 2/4] Try to reauthenticate on a http status code 403 If we get a http status code 403, we should try to reauthenticate in case the session timed out. If we then still get the error, our user might miss the permissions for the requested action. Using a decorator to achieve this. --- dspace_rest_client/client.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dspace_rest_client/client.py b/dspace_rest_client/client.py index 893293a..98f99dd 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -158,6 +158,19 @@ def do_paginate(url, params): return decorated return decorator + @staticmethod + def reauthenticate(func): + @functools.wraps(func) + def decorated(self, *args, reauthenticate=True, **kwargs): + r = func(self, *args, **kwargs) + if r is None: + return r + if r.status_code == 401 and reauthenticate: + if self.authenticate(): + r = func(self, *args, **kwargs) + return r + return decorated + @staticmethod def refresh_csrf(func): @functools.wraps(func) @@ -314,6 +327,7 @@ def api_get(self, url, params=None, data=None, headers=None): self.update_token(r) return r + @reauthenticate @refresh_csrf def api_post(self, url, params, json): """ @@ -331,6 +345,7 @@ def api_post(self, url, params, json): self.update_token(r) return r + @reauthenticate @refresh_csrf def api_post_uri(self, url, params, uri_list): """ @@ -348,6 +363,7 @@ def api_post_uri(self, url, params, uri_list): self.update_token(r) return r + @reauthenticate @refresh_csrf def api_put(self, url, params, json): """ @@ -365,6 +381,7 @@ def api_put(self, url, params, json): self.update_token(r) return r + @reauthenticate @refresh_csrf def api_delete(self, url, params): """ @@ -378,6 +395,7 @@ def api_delete(self, url, params): self.update_token(r) return r + @reauthenticate @refresh_csrf def api_patch(self, url, operation, path, value, params=None): """ From 3b9176aff1176ac789b2d9cc9223ba2f823a5200 Mon Sep 17 00:00:00 2001 From: Pascal-Nicolas Becker Date: Tue, 3 Feb 2026 18:44:12 +0100 Subject: [PATCH 3/4] handle create_bitsream as special case and try reauthentication manually --- dspace_rest_client/client.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/dspace_rest_client/client.py b/dspace_rest_client/client.py index 98f99dd..f8f447d 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -858,6 +858,7 @@ def create_bitstream( metadata=None, embeds=None, retry=False, + reauthenticated=False, ): """ Upload a file and create a bitstream for a specified parent bundle, from the uploaded file and @@ -876,6 +877,7 @@ def create_bitstream( @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 reauthenticated An indicator, if we tried to reauthenticate in case of a http 403 status. @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 @@ -911,6 +913,8 @@ def create_bitstream( logging.debug("Updating token to %s", t) self.session.headers.update({"X-XSRF-Token": t}) self.session.cookies.update({"X-XSRF-Token": t}) + # as this method doesn't return the request, we cannot use our @refresh_csft decorator + # we should enhance self.api_post to be able to send files and use our decorators if r.status_code == 403: r_json = parse_json(r) if "message" in r_json and "CSRF token" in r_json["message"]: @@ -921,7 +925,12 @@ def create_bitstream( return self.create_bitstream( bundle, name, path, mime, metadata, embeds, True ) - + # as this method doesn't return the request, we cannot use our @reauthenticate decorator + # we should enhance self.api_post to be able to send files and use our decorators + if r.status_code == 401 and not reauthenticated: + self.reauthenticate + prepared_req = self.session.prepare_request(req) + r = self.session.send(prepared_req) if r.status_code == 201 or r.status_code == 200: # Success return Bitstream(api_resource=parse_json(r)) From 344bccfa4853eae42babbff1513158a8ad53f14c Mon Sep 17 00:00:00 2001 From: Pascal-Nicolas Becker Date: Wed, 4 Feb 2026 15:30:05 +0100 Subject: [PATCH 4/4] Fix reauthenticate in create_bitstream() --- 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 f8f447d..b1e1df8 100644 --- a/dspace_rest_client/client.py +++ b/dspace_rest_client/client.py @@ -928,7 +928,7 @@ def create_bitstream( # as this method doesn't return the request, we cannot use our @reauthenticate decorator # we should enhance self.api_post to be able to send files and use our decorators if r.status_code == 401 and not reauthenticated: - self.reauthenticate + self.authenticate() prepared_req = self.session.prepare_request(req) r = self.session.send(prepared_req) if r.status_code == 201 or r.status_code == 200: