33import glob
44import math
55import re
6+ import traceback
67from datetime import datetime
78from os import path
89from time import sleep
10+ from typing import Any
911
1012import opentaskpy .otflogging
1113import requests
1214from dateutil .tz import tzlocal
1315from opentaskpy .config .variablecaching import cache_utils
1416from opentaskpy .exceptions import RemoteTransferError
1517from opentaskpy .remotehandlers .remotehandler import RemoteTransferHandler
18+ from tenacity import (
19+ RetryCallState ,
20+ retry ,
21+ retry_if_exception_type ,
22+ stop_after_attempt ,
23+ wait_exponential ,
24+ )
1625
1726from .creds import get_access_token
1827
@@ -24,6 +33,63 @@ class SharepointTransfer(RemoteTransferHandler):
2433
2534 TASK_TYPE = "T"
2635
36+ @staticmethod
37+ def _log_retry_attempt (retry_state : RetryCallState ) -> None :
38+ """Log details before tenacity sleeps and retries a request."""
39+ if not retry_state .args :
40+ return
41+
42+ self = retry_state .args [0 ]
43+ method = str (retry_state .args [1 ]) if len (retry_state .args ) > 1 else "UNKNOWN"
44+ url = str (retry_state .args [2 ]) if len (retry_state .args ) > 2 else "UNKNOWN"
45+ sleep_for = (
46+ retry_state .next_action .sleep
47+ if retry_state .next_action is not None
48+ else "unknown"
49+ )
50+ next_attempt = retry_state .attempt_number + 1
51+ exception = retry_state .outcome .exception () if retry_state .outcome else None
52+ exception_traceback = ""
53+
54+ if exception is not None :
55+ exception_traceback = "" .join (
56+ traceback .format_exception (
57+ type (exception ), exception , exception .__traceback__
58+ )
59+ )
60+
61+ self .logger .warning (
62+ "Retrying SharePoint request %s %s after exception: %s "
63+ "(failed attempt %s, retrying attempt %s, next sleep %ss)\n %s" ,
64+ method ,
65+ url ,
66+ exception if exception is not None else "unknown" ,
67+ retry_state .attempt_number ,
68+ next_attempt ,
69+ sleep_for ,
70+ exception_traceback ,
71+ )
72+ self .logger .info (f"Sleeping for { sleep_for } seconds before retry" )
73+
74+ @retry (
75+ reraise = True ,
76+ stop = stop_after_attempt (6 ),
77+ wait = wait_exponential (multiplier = 2 , min = 5 , max = 60 ),
78+ retry = retry_if_exception_type (requests .exceptions .ReadTimeout ),
79+ before_sleep = _log_retry_attempt ,
80+ )
81+ def _request (self , method : str , url : str , ** kwargs : Any ) -> requests .Response :
82+ """Perform a request with retry for transient timeout failures."""
83+ method_upper = method .upper ()
84+ self .logger .debug (f"Making request to { url } with method { method } " )
85+ if method_upper == "GET" :
86+ return requests .get (url , ** kwargs ) # pylint: disable=missing-timeout
87+ if method_upper == "POST" :
88+ return requests .post (url , ** kwargs ) # pylint: disable=missing-timeout
89+ if method_upper == "PUT" :
90+ return requests .put (url , ** kwargs ) # pylint: disable=missing-timeout
91+ raise ValueError (f"Unsupported HTTP method for retry wrapper: { method } " )
92+
2793 def __init__ (self , spec : dict ):
2894 """Initialise the SharepointTransfer handler.
2995
@@ -52,10 +118,14 @@ def __init__(self, spec: dict):
52118 "Authorization" : "Bearer " + self .credentials ["access_token" ],
53119 "Content-Type" : "application/json" ,
54120 }
55- response = requests .get (
121+
122+ self .timeout = self .spec ["protocol" ].get ("timeout" , 30 )
123+
124+ response = self ._request (
125+ "GET" ,
56126 f"https://graph.microsoft.com/v1.0/sites/{ self .spec ['siteHostname' ]} :/sites/{ self .spec ['siteName' ]} " ,
57127 headers = self .headers ,
58- timeout = 5 ,
128+ timeout = self . timeout ,
59129 ).json ()
60130
61131 # Check the response is OK
@@ -117,13 +187,14 @@ def create_folder(self, parent_id: str | None, folder: str) -> str:
117187 create_folder_url = f"https://graph.microsoft.com/v1.0/sites/{ self .site_id } /drive/root/children"
118188 else :
119189 create_folder_url = f"https://graph.microsoft.com/v1.0/sites/{ self .site_id } /drive/items/{ parent_id } /children"
120- response = requests .post (
190+ response = self ._request (
191+ "POST" ,
121192 create_folder_url ,
122193 headers = {
123194 "Authorization" : "Bearer " + self .credentials ["access_token" ],
124195 "Content-Type" : "application/json" ,
125196 },
126- timeout = 60 ,
197+ timeout = self . timeout ,
127198 json = {"name" : folder , "folder" : {}},
128199 )
129200 if response .status_code != 201 :
@@ -164,7 +235,7 @@ def handle_post_copy_action(self, files: dict) -> int:
164235 headers = {
165236 "Authorization" : "Bearer " + self .credentials ["access_token" ],
166237 },
167- timeout = 60 ,
238+ timeout = self . timeout ,
168239 )
169240 if response .status_code != 204 :
170241 self .logger .error (f"Failed to delete file: { file_name } " )
@@ -216,7 +287,7 @@ def handle_post_copy_action(self, files: dict) -> int:
216287 response = requests .patch (
217288 file_url ,
218289 headers = patch_headers ,
219- timeout = 60 ,
290+ timeout = self . timeout ,
220291 json = patch_body ,
221292 )
222293 if response .status_code == 409 :
@@ -239,7 +310,7 @@ def handle_post_copy_action(self, files: dict) -> int:
239310 "Bearer " + self .credentials ["access_token" ]
240311 ),
241312 },
242- timeout = 60 ,
313+ timeout = self . timeout ,
243314 )
244315 # Check the response was a success
245316 if response .status_code != 204 :
@@ -253,7 +324,7 @@ def handle_post_copy_action(self, files: dict) -> int:
253324 response = requests .patch (
254325 file_url ,
255326 headers = patch_headers ,
256- timeout = 60 ,
327+ timeout = self . timeout ,
257328 json = patch_body ,
258329 )
259330 if response .status_code != 200 :
@@ -287,12 +358,13 @@ def create_or_get_folder(self, destination_path: str) -> str | None:
287358 else :
288359 # get_file_url_from_path returns a path-based URL, not a drive item ID;
289360 # resolve it to the actual item ID so it can be used in parentReference.id
290- response = requests .get (
361+ response = self ._request (
362+ "GET" ,
291363 folder_url ,
292364 headers = {
293365 "Authorization" : "Bearer " + self .credentials ["access_token" ],
294366 },
295- timeout = 60 ,
367+ timeout = self . timeout ,
296368 )
297369 if response .status_code == 404 :
298370 self .logger .info (f"Folder { folder } does not exist, creating" )
@@ -351,10 +423,11 @@ def list_files(
351423 "Content-Type" : "application/json" ,
352424 }
353425
354- response = requests .get (
426+ response = self ._request (
427+ "GET" ,
355428 url ,
356429 headers = headers ,
357- timeout = 30 ,
430+ timeout = self . timeout ,
358431 ).json ()
359432
360433 if "value" in response and response ["value" ]:
@@ -476,7 +549,7 @@ def push_files_from_worker(
476549 "Content-Type" : "application/json" ,
477550 },
478551 data = f ,
479- timeout = 60 ,
552+ timeout = self . timeout ,
480553 )
481554 if response .status_code != 409 :
482555 break
@@ -535,13 +608,14 @@ def _do_upload_session(self, file: str, file_name: str) -> int:
535608 self .logger .info (f"File { file_name } already exists. Replacing file." )
536609 upload_session_url = f"{ file_url } :/createUploadSession"
537610
538- response = requests .post (
611+ response = self ._request (
612+ "POST" ,
539613 upload_session_url ,
540614 headers = {
541615 "Authorization" : "Bearer " + self .credentials ["access_token" ],
542616 "Content-Type" : "application/json" ,
543617 },
544- timeout = 60 ,
618+ timeout = self . timeout ,
545619 )
546620 if response .status_code != 200 :
547621 self .logger .error (f"Failed to create upload session: { file_name } " )
@@ -587,7 +661,8 @@ def _do_upload_session(self, file: str, file_name: str) -> int:
587661 chunk = f .read (chunk_end - chunk_start + 1 )
588662
589663 # PUT the chunk to the upload session url
590- response = requests .put (
664+ response = self ._request (
665+ "PUT" ,
591666 upload_session_url ,
592667 data = chunk ,
593668 headers = headers ,
@@ -642,12 +717,13 @@ def pull_files_to_worker(self, files: dict, local_staging_directory: str) -> int
642717 # Download file using item url
643718 self .logger .info (f"Downloading file: { file_name } " )
644719 download_url = f"{ file_url } :/content"
645- response = requests .get (
720+ response = self ._request (
721+ "GET" ,
646722 download_url ,
647723 headers = {
648724 "Authorization" : "Bearer " + self .credentials ["access_token" ],
649725 },
650- timeout = 60 ,
726+ timeout = self . timeout ,
651727 )
652728
653729 # Check the response was a success
@@ -705,12 +781,13 @@ def get_file_url_from_path(self, file_path: str) -> str | None:
705781
706782 # If the path starts with a / then it's a document library, we need to get the id of the document library
707783 # Do a GET request to /sites/{siteId}/drives to get the document libraries
708- response = requests .get (
784+ response = self ._request (
785+ "GET" ,
709786 f"https://graph.microsoft.com/v1.0/sites/{ self .site_id } /drives" ,
710787 headers = {
711788 "Authorization" : "Bearer " + self .credentials ["access_token" ],
712789 },
713- timeout = 60 ,
790+ timeout = self . timeout ,
714791 )
715792 if response .status_code != 200 :
716793 self .logger .error ("Failed to get document libraries" )
@@ -737,12 +814,13 @@ def get_file_url_from_path(self, file_path: str) -> str | None:
737814
738815 return f"https://graph.microsoft.com/v1.0/sites/{ self .site_id } /drive/root:/{ re .sub (r'/+' , '/' , file_path )} "
739816
740- response = requests .get (
817+ response = self ._request (
818+ "GET" ,
741819 item_url ,
742820 headers = {
743821 "Authorization" : "Bearer " + self .credentials ["access_token" ],
744822 },
745- timeout = 60 ,
823+ timeout = self . timeout ,
746824 )
747825 # Check the response was a success
748826 if response .status_code != 200 :
0 commit comments