Skip to content

Commit 10db22b

Browse files
authored
Add exponential backoff for flaky MS Graph API (#17)
* Add exponential backoff for flaky MS Graph API * bump version v26.10.3 -> v26.16.0 * Update changelog * bump version v26.16.0 -> v26.16.1
1 parent e2947e3 commit 10db22b

8 files changed

Lines changed: 358 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v26.16.1
4+
5+
- Add exponential backoff for MS Graph API calls
6+
- Add optional timeout to protocol definition
7+
38
## v26.10.3
49

510
- Fixed an issue where renaming when uploading a file did not actually rename the file

mypy.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ check_untyped_defs = true
1919
disallow_incomplete_defs = true
2020
disallow_subclassing_any = false
2121
disallow_untyped_calls = true
22-
disallow_untyped_decorators = true
22+
disallow_untyped_decorators = false
2323
disallow_untyped_defs = true
2424
warn_return_any = true
2525
warn_unreachable = true

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "otf-addons-o365"
7-
version = "v26.10.3"
7+
version = "v26.16.1"
88
authors = [{ name = "Adam McDonagh", email = "adam@elitemonkey.net" }]
99
license = { text = "GPLv3" }
1010
classifiers = [
@@ -46,7 +46,7 @@ dev = [
4646
profile = 'black'
4747

4848
[tool.bumpver]
49-
current_version = "v26.10.3"
49+
current_version = "v26.16.1"
5050
version_pattern = "vYY.WW.PATCH[-TAG]"
5151
commit_message = "bump version {old_version} -> {new_version}"
5252
commit = true

src/opentaskpy/addons/o365/remotehandlers/schemas/transfer/sharepoint_destination/protocol.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"opentaskpy.addons.o365.remotehandlers.sharepoint.SharepointTransfer"
1010
]
1111
},
12+
"timeout": {
13+
"type": "integer",
14+
"default": 30
15+
},
1216
"refreshToken": {
1317
"type": "string"
1418
},

src/opentaskpy/addons/o365/remotehandlers/schemas/transfer/sharepoint_source/protocol.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"opentaskpy.addons.o365.remotehandlers.sharepoint.SharepointTransfer"
1010
]
1111
},
12+
"timeout": {
13+
"type": "integer",
14+
"default": 30
15+
},
1216
"refreshToken": {
1317
"type": "string"
1418
},

src/opentaskpy/addons/o365/remotehandlers/sharepoint.py

Lines changed: 100 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,25 @@
33
import glob
44
import math
55
import re
6+
import traceback
67
from datetime import datetime
78
from os import path
89
from time import sleep
10+
from typing import Any
911

1012
import opentaskpy.otflogging
1113
import requests
1214
from dateutil.tz import tzlocal
1315
from opentaskpy.config.variablecaching import cache_utils
1416
from opentaskpy.exceptions import RemoteTransferError
1517
from 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

1726
from .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

Comments
 (0)