Skip to content

Commit 368daff

Browse files
authored
Merge pull request #72 from microsoftgraph/feat/retry-handler
Feat/retry handler
2 parents 25d8366 + e6ea103 commit 368daff

File tree

12 files changed

+626
-5
lines changed

12 files changed

+626
-5
lines changed

.coveragerc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[run]
2+
omit =
3+
*/site-packages/*
4+
*/distutils/*

.pylintrc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,10 +510,10 @@ valid-metaclass-classmethod-first-arg=cls
510510
[DESIGN]
511511

512512
# Maximum number of arguments for function / method.
513-
max-args=5
513+
max-args=7
514514

515515
# Maximum number of attributes for a class (see R0902).
516-
max-attributes=7
516+
max-attributes=12
517517

518518
# Maximum number of boolean expressions in an if statement (see R0916).
519519
max-bool-expr=5

.pypirc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ username = msgraphsdkteam
99

1010
[testpypi]
1111
repository = https://test.pypi.org/legacy/
12-
username = msgraphsdkteam-test
12+
username = msgraphsdkteam-test

Pipfile.lock

Lines changed: 25 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

msgraph/core/_client_factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .middleware.abc_token_credential import TokenCredential
1212
from .middleware.authorization import AuthorizationHandler
1313
from .middleware.middleware import BaseMiddleware, MiddlewarePipeline
14+
from .middleware.retry import RetryHandler
1415

1516

1617
class HTTPClientFactory:
@@ -51,6 +52,7 @@ def create_with_default_middleware(self, credential: TokenCredential, **kwargs)
5152
"""
5253
middleware = [
5354
AuthorizationHandler(credential, **kwargs),
55+
RetryHandler(**kwargs),
5456
]
5557
self._register(middleware)
5658
return self.session

msgraph/core/_graph_client.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,26 @@
77
from ._client_factory import HTTPClientFactory
88
from .middleware.request_context import RequestContext
99

10-
supported_options = ['scopes', 'custom_option']
10+
# These are middleware options that can be configured per request.
11+
# Supports options for default middleware as well as custom middleware.
12+
supported_options = [
13+
# Auth Options
14+
'scopes',
15+
16+
# Retry Options
17+
'max_retries',
18+
'retry_backoff_factor',
19+
'retry_backoff_max',
20+
'retry_time_limit',
21+
'retry_on_status_codes',
22+
23+
# Custom middleware options
24+
'custom_option',
25+
]
1126

1227

1328
def attach_context(func):
29+
"""Attaches a request context object to every graph request"""
1430
def wrapper(*args, **kwargs):
1531
middleware_control = dict()
1632

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class RetryMiddlewareOptions:
2+
def __init__(self, retry_configs):
3+
self.retry_total: int = retry_configs.get('retry_total')
4+
self.retry_backoff_factor: int = retry_configs.get('retry_backoff_factor')
5+
self.retry_backoff_max: int = retry_configs.get('retry_backoff_max')
6+
self.retry_time_limit: int = retry_configs.get('retry_time_limit')
7+
self.retry_on_status_codes: [int] = retry_configs.get('retry_on_status_codes')

msgraph/core/middleware/request_context.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,22 @@
88

99

1010
class RequestContext:
11+
"""A request context contains data that is persisted throughout the request and
12+
includes a ClientRequestId property, MiddlewareControl property to control behavior
13+
of middleware as well as a FeatureUsage  property to keep track of middleware used
14+
in making the request.
15+
"""
1116
def __init__(self, middleware_control, headers):
17+
"""Constructor for request context instances
18+
19+
Args:
20+
middleware_control (dict): A dictionary of optional middleware options
21+
that can be accessed by middleware components to override the options provided
22+
during middleware initialization,
23+
24+
headers (dict): A dictionary containing the request headers. Used to check for a
25+
user provided client request id.
26+
"""
1227
self.middleware_control = middleware_control
1328
self.client_request_id = headers.get('client-request-id', str(uuid.uuid4()))
1429
self._feature_usage = FeatureUsageFlag.NONE

msgraph/core/middleware/retry.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import datetime
2+
import random
3+
import sys
4+
import time
5+
from email.utils import parsedate_to_datetime
6+
7+
from msgraph.core.middleware.middleware import BaseMiddleware
8+
9+
10+
class RetryHandler(BaseMiddleware):
11+
"""
12+
TransportAdapter that allows us to specify the retry policy for all requests
13+
14+
Retry configuration.
15+
16+
:param int max_retries:
17+
Maximum number of retries to allow. Takes precedence over other counts.
18+
Set to ``0`` to fail on the first retry.
19+
:param iterable retry_on_status_codes:
20+
A set of integer HTTP status codes that we should force a retry on.
21+
A retry is initiated if the request method is in ``allowed_methods``
22+
and the response status code is in ``RETRY STATUS CODES``.
23+
:param float retry_backoff_factor:
24+
A backoff factor to apply between attempts after the second try
25+
(most errors are resolved immediately by a second try without a
26+
delay).
27+
The request will sleep for::
28+
{backoff factor} * (2 ** ({retry number} - 1))
29+
seconds. If the backoff_factor is 0.1, then :func:`.sleep` will sleep
30+
for [0.0s, 0.2s, 0.4s, ...] between retries. It will never be longer
31+
than :attr:`RetryHandler.MAXIMUM_BACKOFF`.
32+
By default, backoff is set to 0.5.
33+
:param int retry_time_limit:
34+
The maximum cumulative time in seconds that total retries should take.
35+
The cumulative retry time and retry-after value for each request retry
36+
will be evaluated against this value; if the cumulative retry time plus
37+
the retry-after value is greater than the retry_time_limit, the failed
38+
response will be immediately returned, else the request retry continues.
39+
"""
40+
41+
DEFAULT_MAX_RETRIES = 3
42+
MAX_RETRIES = 10
43+
DEFAULT_DELAY = 3
44+
MAX_DELAY = 180
45+
DEFAULT_BACKOFF_FACTOR = 0.5
46+
MAXIMUM_BACKOFF = 120
47+
_DEFAULT_RETRY_STATUS_CODES = {429, 503, 504}
48+
49+
def __init__(self, **kwargs):
50+
super().__init__()
51+
self.max_retries: int = min(
52+
kwargs.pop('max_retries', self.DEFAULT_MAX_RETRIES), self.MAX_RETRIES
53+
)
54+
self.backoff_factor: float = kwargs.pop('retry_backoff_factor', self.DEFAULT_BACKOFF_FACTOR)
55+
self.backoff_max: int = kwargs.pop('retry_backoff_max', self.MAXIMUM_BACKOFF)
56+
self.timeout: int = kwargs.pop('retry_time_limit', self.MAX_DELAY)
57+
58+
status_codes: [int] = kwargs.pop('retry_on_status_codes', [])
59+
60+
self._retry_on_status_codes: set = set(status_codes) | self._DEFAULT_RETRY_STATUS_CODES
61+
self._allowed_methods: set = frozenset(
62+
['HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
63+
)
64+
self._respect_retry_after_header: bool = True
65+
66+
@classmethod
67+
def disable_retries(cls):
68+
"""
69+
Disable retries by setting retry_total to zero.
70+
retry_total takes precedence over all other counts.
71+
"""
72+
return cls(max_retries=0)
73+
74+
def get_retry_options(self, middleware_control):
75+
"""
76+
Check if request specific configs have been passed and override any session defaults
77+
Then configure retry settings into the form of a dict.
78+
"""
79+
if middleware_control:
80+
return {
81+
'total':
82+
min(middleware_control.get('max_retries', self.max_retries), self.MAX_RETRIES),
83+
'backoff':
84+
middleware_control.get('retry_backoff_factor', self.backoff_factor),
85+
'max_backoff':
86+
middleware_control.get('retry_backoff_max', self.backoff_max),
87+
'timeout':
88+
middleware_control.get('retry_time_limit', self.timeout),
89+
'retry_codes':
90+
set(middleware_control.get('retry_on_status_codes', self._retry_on_status_codes))
91+
| set(self._DEFAULT_RETRY_STATUS_CODES),
92+
'methods':
93+
self._allowed_methods,
94+
}
95+
return {
96+
'total': self.max_retries,
97+
'backoff': self.backoff_factor,
98+
'max_backoff': self.backoff_max,
99+
'timeout': self.timeout,
100+
'retry_codes': self._retry_on_status_codes,
101+
'methods': self._allowed_methods,
102+
}
103+
104+
def send(self, request, **kwargs):
105+
"""
106+
Sends the http request object to the next middleware or retries the request if necessary.
107+
"""
108+
retry_options = self.get_retry_options(request.context.middleware_control)
109+
absolute_time_limit = min(retry_options['timeout'], self.MAX_DELAY)
110+
response = None
111+
retry_count = 0
112+
retry_valid = True
113+
114+
while retry_valid:
115+
start_time = time.time()
116+
if retry_count > 0:
117+
request.headers.update({'retry-attempt': '{}'.format(retry_count)})
118+
response = super().send(request, **kwargs)
119+
# Check if the request needs to be retried based on the response method
120+
# and status code
121+
if self.should_retry(retry_options, response):
122+
# check that max retries has not been hit
123+
retry_valid = self.check_retry_valid(retry_options, retry_count)
124+
125+
# Get the delay time between retries
126+
delay = self.get_delay_time(retry_options, retry_count, response)
127+
128+
if retry_valid and delay < absolute_time_limit:
129+
time.sleep(delay)
130+
end_time = time.time()
131+
absolute_time_limit -= (end_time - start_time)
132+
# increment the count for retries
133+
retry_count += 1
134+
135+
continue
136+
break
137+
return response
138+
139+
def should_retry(self, retry_options, response):
140+
"""
141+
Determines whether the request should be retried
142+
Checks if the request method is in allowed methods
143+
Checks if the response status code is in retryable status codes.
144+
"""
145+
if not self._is_method_retryable(retry_options, response.request):
146+
return False
147+
if not self._is_request_payload_buffered(response):
148+
return False
149+
return retry_options['total'] and response.status_code in retry_options['retry_codes']
150+
151+
def _is_method_retryable(self, retry_options, request):
152+
"""
153+
Checks if a given request should be retried upon, depending on
154+
whether the HTTP method is in the set of allowed methods
155+
"""
156+
if request.method.upper() not in retry_options['methods']:
157+
return False
158+
return True
159+
160+
def _is_request_payload_buffered(self, response):
161+
"""
162+
Checks if the request payload is buffered/rewindable.
163+
Payloads with forward only streams will return false and have the responses
164+
returned without any retry attempt.
165+
"""
166+
if response.request.method.upper() in frozenset(['HEAD', 'GET', 'DELETE', 'OPTIONS']):
167+
return True
168+
if response.request.headers['Content-Type'] == "application/octet-stream":
169+
return False
170+
return True
171+
172+
def check_retry_valid(self, retry_options, retry_count):
173+
"""
174+
Check that the max retries limit has not been hit
175+
"""
176+
if retry_count < retry_options['total']:
177+
return True
178+
return False
179+
180+
def get_delay_time(self, retry_options, retry_count, response=None):
181+
"""
182+
Get the time in seconds to delay between retry attempts.
183+
Respects a retry-after header in the response if provided
184+
If no retry-after response header, it defaults to exponential backoff
185+
"""
186+
retry_after = self._get_retry_after(response)
187+
if retry_after:
188+
return retry_after
189+
return self._get_delay_time_exp_backoff(retry_options, retry_count)
190+
191+
def _get_delay_time_exp_backoff(self, retry_options, retry_count):
192+
"""
193+
Get time in seconds to delay between retry attempts based on an exponential
194+
backoff value.
195+
"""
196+
exp_backoff_value = retry_options['backoff'] * +(2**(retry_count - 1))
197+
backoff_value = exp_backoff_value + (random.randint(0, 1000) / 1000)
198+
199+
backoff = min(retry_options['max_backoff'], backoff_value)
200+
return backoff
201+
202+
def _get_retry_after(self, response):
203+
"""
204+
Check if retry-after is specified in the response header and get the value
205+
"""
206+
retry_after = response.headers.get("retry-after")
207+
if retry_after:
208+
return self._parse_retry_after(retry_after)
209+
return None
210+
211+
def _parse_retry_after(self, retry_after):
212+
"""
213+
Helper to parse Retry-After and get value in seconds.
214+
"""
215+
try:
216+
delay = int(retry_after)
217+
except ValueError:
218+
# Not an integer? Try HTTP date
219+
retry_date = parsedate_to_datetime(retry_after)
220+
delay = (retry_date - datetime.datetime.now(retry_date.tzinfo)).total_seconds()
221+
return max(0, delay)

0 commit comments

Comments
 (0)