Skip to content

Commit 6ddcf12

Browse files
authored
Merge pull request #810 from HardMax71/fix/strict-typing-retry-handler-pipeline
fix(http): pass mypy --strict in the retry handler and pipeline and fix the bugs it shows
2 parents 11f2883 + 3b5622e commit 6ddcf12

6 files changed

Lines changed: 140 additions & 59 deletions

File tree

‎packages/http/httpx/kiota_http/middleware/middleware.py‎

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,43 +19,40 @@ class MiddlewarePipeline():
1919
it here https://buffered.dev/middleware-python-requests/
2020
"""
2121

22-
def __init__(self, transport: httpx.AsyncBaseTransport):
22+
def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
2323
super().__init__()
24-
self._current_middleware = None
25-
self._first_middleware = None
24+
self._current_middleware: Optional[BaseMiddleware] = None
25+
self._first_middleware: Optional[BaseMiddleware] = None
2626
self._transport = transport
2727

28-
def add_middleware(self, middleware):
29-
if self._middleware_present():
30-
self._current_middleware.next = middleware
31-
self._current_middleware = middleware
32-
else:
28+
def add_middleware(self, middleware: "BaseMiddleware") -> None:
29+
if self._current_middleware is None:
3330
self._first_middleware = middleware
34-
self._current_middleware = self._first_middleware
31+
else:
32+
self._current_middleware.next = middleware
33+
self._current_middleware = middleware
3534

36-
async def send(self, request):
35+
async def send(self, request: httpx.Request) -> httpx.Response:
3736

38-
if self._middleware_present():
37+
if self._first_middleware is not None:
3938
return await self._first_middleware.send(request, self._transport)
40-
# No middleware in pipeline, delete request optoions from header and
41-
# send the request
42-
del request.headers['request_options']
39+
# No middleware in pipeline, remove the request options and send the request
40+
request.extensions.pop(REQUEST_OPTIONS_KEY, None)
4341
return await self._transport.handle_async_request(request)
4442

45-
def _middleware_present(self):
46-
return self._current_middleware
47-
4843

4944
class BaseMiddleware():
5045
"""Base class for middleware. Handles moving a Request to the next middleware in the pipeline.
5146
If the current middleware is the last one in the pipeline, it makes a network request
5247
"""
5348

54-
def __init__(self):
55-
self.next = None
56-
self.parent_span = None
49+
def __init__(self) -> None:
50+
self.next: Optional[BaseMiddleware] = None
51+
self.parent_span: Optional[trace.Span] = None
5752

58-
async def send(self, request, transport):
53+
async def send(
54+
self, request: httpx.Request, transport: httpx.AsyncBaseTransport
55+
) -> httpx.Response:
5956
if self.next is None:
6057
# Remove request options if there's no other middleware in the chain.
6158
if hasattr(request, "extensions") and isinstance(request.extensions, dict):
@@ -65,19 +62,15 @@ async def send(self, request, transport):
6562
return response
6663
return await self.next.send(request, transport)
6764

68-
def _create_observability_span(self, request, span_name: str) -> trace.Span:
65+
def _create_observability_span(self, request: httpx.Request, span_name: str) -> trace.Span:
6966
"""Gets the parent_span from the request options and creates a new span.
7067
If no parent_span is found in the request, uses the parent_span in the
7168
object. If parent_span is None, current context will be used."""
72-
_span = None
7369
options = None
7470
if hasattr(request, "extensions") and isinstance(request.extensions, dict):
7571
options = request.extensions.get(REQUEST_OPTIONS_KEY)
7672
if options and (parent_span := options.get("parent_span", None)):
7773
self.parent_span = parent_span
78-
_context = trace.set_span_in_context(parent_span)
79-
_span = tracer.start_span(span_name, _context)
80-
if _span is None:
81-
_context = trace.set_span_in_context(self.parent_span)
82-
_span = tracer.start_span(span_name, _context)
83-
return _span
74+
if self.parent_span is None:
75+
return tracer.start_span(span_name)
76+
return tracer.start_span(span_name, trace.set_span_in_context(self.parent_span))

‎packages/http/httpx/kiota_http/middleware/redirect_handler.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,7 @@ async def send(
8484
max_redirect -= 1
8585
if not self.increment(response, max_redirect, history[:]):
8686
if max_redirect < 0:
87-
response.history = history
88-
exc = RedirectError(f"Too many redirects. {response.history}")
87+
exc = RedirectError(f"Too many redirects. {history}")
8988
_redirect_span.record_exception(exc)
9089
raise exc
9190
break
@@ -100,7 +99,6 @@ async def send(
10099
break
101100
finally:
102101
_redirect_span.end()
103-
response.history = history
104102

105103
return response
106104

‎packages/http/httpx/kiota_http/middleware/retry_handler.py‎

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import random
44
import re
55
from email.utils import parsedate_to_datetime
6+
from typing import Optional
67

78
from kiota_abstractions.request_option import RequestOption
89
from opentelemetry.semconv.attributes.http_attributes import HTTP_RESPONSE_STATUS_CODE
@@ -42,16 +43,18 @@ class RetryHandler(BaseMiddleware):
4243
['HEAD', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'QUERY']
4344
)
4445

45-
def __init__(self, options: RetryHandlerOption = RetryHandlerOption()) -> None:
46+
def __init__(self, options: Optional[RetryHandlerOption] = None) -> None:
4647
super().__init__()
4748
self.allowed_methods: frozenset[str] = self.DEFAULT_ALLOWED_METHODS
4849
self.backoff_factor: float = self.DEFAULT_BACKOFF_FACTOR
4950
self.backoff_max: int = self.MAXIMUM_BACKOFF
50-
self.options = options
51-
self.respect_retry_after_header: bool = self.options.DEFAULT_SHOULD_RETRY # type:ignore
51+
self.options = RetryHandlerOption() if options is None else options
52+
self.respect_retry_after_header: bool = self.options.DEFAULT_SHOULD_RETRY
5253
self.retry_on_status_codes: set[int] = self.DEFAULT_RETRY_STATUS_CODES
5354

54-
async def send(self, request: httpx.Request, transport: httpx.AsyncBaseTransport):
55+
async def send(
56+
self, request: httpx.Request, transport: httpx.AsyncBaseTransport
57+
) -> httpx.Response:
5558
"""
5659
Sends the http request object to the next middleware or retries the request if necessary.
5760
"""
@@ -88,6 +91,7 @@ async def send(self, request: httpx.Request, transport: httpx.AsyncBaseTransport
8891
request.headers.update({RETRY_ATTEMPT: f'{retry_count}'})
8992
_retry_span.set_attribute(HTTP_RESPONSE_STATUS_CODE, response.status_code)
9093
_retry_span.set_attribute('http.request.resend_count', retry_count)
94+
_retry_span.end()
9195
continue
9296
_retry_span.end()
9397
break
@@ -107,11 +111,15 @@ def _get_current_options(self, request: httpx.Request) -> RetryHandlerOption:
107111
"""
108112
request_options = request.extensions.get(REQUEST_OPTIONS_KEY)
109113
if request_options:
110-
current_options = request_options.get(RetryHandlerOption.get_key(), self.options)
114+
current_options: RetryHandlerOption = request_options.get(
115+
RetryHandlerOption.get_key(), self.options
116+
)
111117
return current_options
112118
return self.options
113119

114-
def should_retry(self, request, options, response):
120+
def should_retry(
121+
self, request: httpx.Request, options: RetryHandlerOption, response: httpx.Response
122+
) -> bool:
115123
"""
116124
Determines whether the request should be retried
117125
Checks if the request method is in allowed methods
@@ -121,10 +129,9 @@ def should_retry(self, request, options, response):
121129
return False
122130
if not self._is_request_payload_buffered(request):
123131
return False
124-
val = options.max_retry and (response.status_code in self.retry_on_status_codes)
125-
return val
132+
return options.max_retry > 0 and response.status_code in self.retry_on_status_codes
126133

127-
def _is_method_retryable(self, request):
134+
def _is_method_retryable(self, request: httpx.Request) -> bool:
128135
"""
129136
Checks if a given request should be retried upon, depending on
130137
whether the HTTP method is in the set of allowed methods
@@ -133,7 +140,7 @@ def _is_method_retryable(self, request):
133140
return False
134141
return True
135142

136-
def _is_request_payload_buffered(self, request):
143+
def _is_request_payload_buffered(self, request: httpx.Request) -> bool:
137144
"""
138145
Checks if the request payload is buffered/rewindable.
139146
Payloads with forward only streams will return false and have the responses
@@ -145,15 +152,20 @@ def _is_request_payload_buffered(self, request):
145152
return False
146153
return True
147154

148-
def check_retry_valid(self, retry_count, options):
155+
def check_retry_valid(self, retry_count: int, options: RetryHandlerOption) -> bool:
149156
"""
150157
Check that the max retries limit has not been hit
151158
"""
152159
if retry_count < options.max_retry:
153160
return True
154161
return False
155162

156-
def get_delay_time(self, retry_count, response=None, delay=RetryHandlerOption.DEFAULT_DELAY):
163+
def get_delay_time(
164+
self,
165+
retry_count: int,
166+
response: Optional[httpx.Response] = None,
167+
delay: float = RetryHandlerOption.DEFAULT_DELAY
168+
) -> float:
157169
"""Get the time in seconds to delay before the next attempt.
158170
159171
A ``Retry-After`` response header that parses to more than zero seconds is
@@ -167,37 +179,41 @@ def get_delay_time(self, retry_count, response=None, delay=RetryHandlerOption.DE
167179
return retry_after
168180
return self._get_delay_time_exp_backoff(retry_count, delay)
169181

170-
def _get_delay_time_exp_backoff(self, retry_count, delay):
182+
def _get_delay_time_exp_backoff(self, retry_count: int, delay: float) -> float:
171183
"""
172184
Get time in seconds to delay between retry attempts based on an exponential
173185
backoff value.
174186
"""
175-
exp_backoff_value = self.backoff_factor * +(2**(retry_count - 1))
187+
exp_backoff_value: float = self.backoff_factor * +(2**(retry_count - 1))
176188
backoff_value = exp_backoff_value + (random.randint(0, 1000) / 1000) + delay
177189

178190
backoff = min(self.backoff_max, backoff_value)
179191
return backoff
180192

181-
def _get_retry_after(self, response):
193+
def _get_retry_after(self, response: Optional[httpx.Response]) -> Optional[float]:
182194
"""
183195
Check if retry-after is specified in the response header and get the value
184196
"""
197+
if response is None:
198+
return None
185199
retry_after = response.headers.get("retry-after")
186200
if retry_after:
187201
return self._parse_retry_after(retry_after)
188202
return None
189203

190-
def _parse_retry_after(self, retry_after):
204+
def _parse_retry_after(self, retry_after: str) -> Optional[float]:
191205
"""
192-
Helper to parse Retry-After and get value in seconds.
206+
Helper to parse Retry-After and get value in seconds. Returns None when the
207+
value is neither a number of seconds nor an HTTP date.
193208
"""
209+
if re.match(r"\d+,\d+$", retry_after):
210+
retry_after = retry_after.split(",")[0]
194211
try:
195-
retry_after = retry_after.split(",")[0] if re.match(
196-
r"\d+,\d+$", retry_after
197-
) else retry_after
198-
delay = int(retry_after)
212+
return max(0, int(retry_after))
199213
except ValueError:
200-
# Not an integer? Try HTTP date
214+
pass
215+
try:
201216
retry_date = parsedate_to_datetime(retry_after)
202-
delay = (retry_date - datetime.datetime.now(retry_date.tzinfo)).total_seconds()
203-
return max(0, delay)
217+
except ValueError:
218+
return None
219+
return max(0.0, (retry_date - datetime.datetime.now(retry_date.tzinfo)).total_seconds())

‎packages/http/httpx/tests/middleware_tests/test_base_middleware.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Test the BaseMiddleware class."""
2+
import httpx
3+
import pytest
24
from opentelemetry import trace
35

4-
from kiota_http.middleware import BaseMiddleware
6+
from kiota_http.middleware import REQUEST_OPTIONS_KEY, BaseMiddleware, MiddlewarePipeline
7+
from kiota_http.middleware import middleware as middleware_module
58

69

710
def test_next_is_none():
@@ -27,4 +30,24 @@ def test_span_created(request_info, otel_tracer):
2730
span = middleware._create_observability_span(request_info, "test_span_created")
2831
span.end()
2932
after_state = outside_span.is_recording()
30-
assert(before_state == after_state)
33+
assert(before_state == after_state)
34+
35+
36+
def test_span_without_parent_span_uses_the_current_span(span_exporter):
37+
middleware = BaseMiddleware()
38+
with middleware_module.tracer.start_as_current_span("outer"):
39+
middleware._create_observability_span(httpx.Request("GET", "https://example.com"), "inner").end()
40+
inner, outer = span_exporter.get_finished_spans()
41+
assert inner.parent.span_id == outer.context.span_id
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_pipeline_without_middleware_sends_the_request():
46+
def request_handler(request: httpx.Request):
47+
return httpx.Response(200)
48+
49+
request = httpx.Request("GET", "https://example.com", extensions={REQUEST_OPTIONS_KEY: {}})
50+
async with httpx.MockTransport(request_handler) as transport:
51+
response = await MiddlewarePipeline(transport).send(request)
52+
assert response.status_code == 200
53+
assert REQUEST_OPTIONS_KEY not in request.extensions

‎packages/http/httpx/tests/middleware_tests/test_redirect_handler.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ def request_handler(request):
3737
attempts = redirects + 1 if should_redirect else 1
3838
assert len(requests) == attempts
3939
assert response.status_code == (200 if should_redirect else 302)
40-
assert len(response.history) == attempts - 1
4140
spans = span_exporter.get_finished_spans()
4241
assert [span.name for span in spans] == ["RedirectHandler_send"] + [
4342
f"RedirectHandler_send - redirect {index}" for index in range(attempts)
@@ -124,6 +123,27 @@ def test_increment_redirects():
124123
assert handler.increment(response, handler.options.max_redirect, history=history)
125124

126125

126+
class SingleRedirectHandler(RedirectHandler):
127+
128+
def increment(self, response, max_redirect, history):
129+
return len(history) == 0
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_increment_override_stops_redirects():
134+
requests = []
135+
136+
def request_handler(request):
137+
requests.append(request)
138+
return httpx.Response(FOUND, headers={LOCATION_HEADER: f"/redirect/{len(requests)}"})
139+
140+
async with httpx.MockTransport(request_handler) as transport:
141+
response = await SingleRedirectHandler().send(httpx.Request("GET", BASE_URL), transport)
142+
143+
assert response.status_code == FOUND
144+
assert [request.url.path for request in requests] == ["/", "/redirect/1"]
145+
146+
127147
def test_same_origin(mock_redirect_handler):
128148
origin1 = httpx.URL(BASE_URL)
129149
origin2 = httpx.URL(f"{BASE_URL}:443")

‎packages/http/httpx/tests/middleware_tests/test_retry_handler.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,34 @@ def request_handler(request: httpx.Request):
345345
assert responses[0].is_closed and responses[1].is_closed
346346
# the returned response is left open for the caller to read
347347
assert not responses[2].is_closed
348+
349+
350+
@pytest.mark.asyncio
351+
async def test_retry_spans_end(span_exporter):
352+
def request_handler(request: httpx.Request):
353+
if RETRY_ATTEMPT in request.headers:
354+
return httpx.Response(200)
355+
return httpx.Response(SERVICE_UNAVAILABLE)
356+
357+
async with httpx.MockTransport(request_handler) as transport:
358+
await RetryHandler(RetryHandlerOption(delay=0)).send(httpx.Request("GET", BASE_URL), transport)
359+
assert [span.name for span in span_exporter.get_finished_spans()] == [
360+
"RetryHandler_send", "RetryHandler_send - attempt 0", "RetryHandler_send - attempt 1"
361+
]
362+
363+
364+
def test_default_options_are_not_shared():
365+
first, second = RetryHandler(), RetryHandler()
366+
first.options.max_retry = 7
367+
assert second.options.max_retry == RetryHandlerOption.DEFAULT_MAX_RETRIES
368+
369+
370+
def test_get_delay_time_without_response():
371+
delay = RetryHandler().get_delay_time(1, delay=0)
372+
assert 0.5 <= delay <= 1.5
373+
374+
375+
def test_get_retry_after_invalid_value():
376+
response = httpx.Response(SERVICE_UNAVAILABLE, headers={RETRY_AFTER: "soon"})
377+
assert RetryHandler()._get_retry_after(response) is None
378+

0 commit comments

Comments
 (0)