Skip to content

Commit 1b0d8ac

Browse files
svrooijbaywet
andauthored
fix: ensure 304 status code does not result in an error
* Ensure 304 status code does not throw, but returns None instead Fixes #363 Add handling for 304 responses without a location header in request adapter. * Modify `packages/http/httpx/kiota_http/httpx_request_adapter.py` to include a check for 304 status code in `_should_return_none` method. * Return True if the status code is 304 and there is no location header. * Add a unit test in `packages/http/httpx/tests/test_httpx_request_adapter.py` to verify that the request adapter returns null and does not throw for a 304 response without a location header. * Create a mock 304 response without a location header. * Ensure the unit test checks that the request adapter returns null for the 304 response. --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/microsoft/kiota-python/issues/363?shareId=XXXX-XXXX-XXXX-XXXX). * fix(httpx): Return None for certain 3xx responses when location header is empty * fix: Throw error when invalid redirect is present, otherwise return None * chore: Sonarqube recommendation * chore: linting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: skip body if location header is present Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: formatting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: additional formatting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * chore: linting Signed-off-by: Vincent Biret <vibiret@microsoft.com> * fix: error map type Signed-off-by: Vincent Biret <vibiret@microsoft.com> --------- Signed-off-by: Vincent Biret <vibiret@microsoft.com> Co-authored-by: Vincent Biret <vibiret@microsoft.com>
1 parent 01643df commit 1b0d8ac

2 files changed

Lines changed: 132 additions & 25 deletions

File tree

packages/http/httpx/kiota_http/httpx_request_adapter.py

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ async def send_primitive_async(
332332
await self.throw_failed_responses(response, error_map, parent_span, parent_span)
333333
if self._should_return_none(response):
334334
return None
335+
335336
if response_type == "bytes":
336337
return response.content # type: ignore
337338
_deserialized_span = self._start_local_tracing_span("get_root_parse_node", parent_span)
@@ -425,7 +426,79 @@ async def get_root_parse_node(
425426
span.end()
426427

427428
def _should_return_none(self, response: httpx.Response) -> bool:
428-
return response.status_code == 204 or not bool(response.content)
429+
"""Helper function to check if the response should return None.
430+
431+
Conditions:
432+
- The response status code is 204 or 304
433+
- the response content is empty.
434+
- The response status code is 301 or 302 and the location header is not present.
435+
436+
Returns:
437+
bool: True if the response should return None, False otherwise.
438+
"""
439+
return response.status_code == 204 or response.status_code == 304 or not bool(
440+
response.content
441+
) or (not response.headers.get("location") and response.status_code in [301, 302])
442+
443+
def _is_redirect_missing_location(
444+
self, response: httpx.Response, parent_span: trace.Span, attribute_span: trace.Span
445+
) -> bool:
446+
if response.is_redirect:
447+
if response.has_redirect_location:
448+
return False
449+
# Raise a more specific error if the server returned a redirect status code
450+
# without a location header
451+
attribute_span.set_status(trace.StatusCode.ERROR)
452+
_throw_failed_resp_span = self._start_local_tracing_span(
453+
"throw_failed_responses", parent_span
454+
)
455+
_throw_failed_resp_span.set_attribute("status", response.status_code)
456+
exc = APIError(
457+
f"The server returned a redirect status code {response.status_code}"
458+
" without a location header",
459+
response.status_code,
460+
response.headers, # type: ignore
461+
)
462+
_throw_failed_resp_span.set_status(trace.StatusCode.ERROR, str(exc))
463+
attribute_span.record_exception(exc)
464+
_throw_failed_resp_span.end()
465+
raise exc
466+
return True
467+
468+
async def _get_error_from_response(
469+
self,
470+
response: httpx.Response,
471+
error_map: dict[str, type[ParsableFactory]],
472+
response_status_code_str: str,
473+
response_status_code: int,
474+
attribute_span: trace.Span,
475+
_throw_failed_resp_span: trace.Span,
476+
) -> object:
477+
error_class = None
478+
if response_status_code_str in error_map: # Error Code 400 - <= 599
479+
error_class = error_map[response_status_code_str]
480+
elif 400 <= response_status_code < 500 and "4XX" in error_map: # Error code 4XX
481+
error_class = error_map["4XX"]
482+
elif 500 <= response_status_code < 600 and "5XX" in error_map: # Error code 5XX
483+
error_class = error_map["5XX"]
484+
elif "XXX" in error_map: # Blanket case
485+
error_class = error_map["XXX"]
486+
487+
root_node = await self.get_root_parse_node(
488+
response, _throw_failed_resp_span, _throw_failed_resp_span
489+
)
490+
attribute_span.set_attribute(ERROR_BODY_FOUND_KEY, bool(root_node))
491+
492+
_get_obj_ctx = trace.set_span_in_context(_throw_failed_resp_span)
493+
_get_obj_span = tracer.start_span("get_object_value", context=_get_obj_ctx)
494+
495+
if not root_node:
496+
return None
497+
error = None
498+
if error_class:
499+
error = root_node.get_object_value(error_class)
500+
_get_obj_span.end()
501+
return error
429502

430503
async def throw_failed_responses(
431504
self,
@@ -434,7 +507,9 @@ async def throw_failed_responses(
434507
parent_span: trace.Span,
435508
attribute_span: trace.Span,
436509
) -> None:
437-
if response.is_success:
510+
if response.is_success or response.status_code == 304:
511+
return
512+
if self._is_redirect_missing_location(response, parent_span, attribute_span) is False:
438513
return
439514
try:
440515
attribute_span.set_status(trace.StatusCode.ERROR)
@@ -476,29 +551,14 @@ async def throw_failed_responses(
476551
raise exc
477552
_throw_failed_resp_span.set_attribute("status_message", "received_error_response")
478553

479-
error_class = None
480-
if response_status_code_str in error_map: # Error Code 400 - <= 599
481-
error_class = error_map[response_status_code_str]
482-
elif 400 <= response_status_code < 500 and "4XX" in error_map: # Error code 4XX
483-
error_class = error_map["4XX"]
484-
elif 500 <= response_status_code < 600 and "5XX" in error_map: # Error code 5XX
485-
error_class = error_map["5XX"]
486-
elif "XXX" in error_map: # Blanket case
487-
error_class = error_map["XXX"]
488-
489-
root_node = await self.get_root_parse_node(
490-
response, _throw_failed_resp_span, _throw_failed_resp_span
554+
error = await self._get_error_from_response(
555+
response,
556+
error_map,
557+
response_status_code_str,
558+
response_status_code,
559+
attribute_span,
560+
_throw_failed_resp_span,
491561
)
492-
attribute_span.set_attribute(ERROR_BODY_FOUND_KEY, bool(root_node))
493-
494-
_get_obj_ctx = trace.set_span_in_context(_throw_failed_resp_span)
495-
_get_obj_span = tracer.start_span("get_object_value", context=_get_obj_ctx)
496-
497-
if not root_node:
498-
return None
499-
error = None
500-
if error_class:
501-
error = root_node.get_object_value(error_class)
502562
if isinstance(error, APIError):
503563
error.response_headers = response_headers # type: ignore
504564
error.response_status_code = response_status_code
@@ -512,7 +572,6 @@ async def throw_failed_responses(
512572
response_status_code,
513573
response_headers, # type: ignore
514574
)
515-
_get_obj_span.end()
516575
raise exc
517576
finally:
518577
_throw_failed_resp_span.end()

packages/http/httpx/tests/test_httpx_request_adapter.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,54 @@ async def test_retries_on_cae_failure(
402402
request_adapter._authentication_provider.authenticate_request.assert_has_awaits(calls)
403403

404404

405+
@pytest.mark.asyncio
406+
async def test_send_primitive_async_304_no_location_header_returns_null(
407+
request_adapter, request_info
408+
):
409+
mock_304_response = httpx.Response(
410+
status_code=304, headers={"Content-Type": "application/json"}
411+
)
412+
request_adapter.get_http_response_message = AsyncMock(return_value=mock_304_response)
413+
resp = await request_adapter.get_http_response_message(request_info)
414+
assert resp.status_code == 304
415+
assert "location" not in resp.headers
416+
final_result = await request_adapter.send_primitive_async(request_info, "float", {})
417+
assert final_result is None
418+
419+
420+
@pytest.mark.asyncio
421+
async def test_send_primitive_async_301_no_location_header_throws(request_adapter, request_info):
422+
mock_301_response = httpx.Response(
423+
status_code=301, headers={"Content-Type": "application/json"}
424+
)
425+
request_adapter.get_http_response_message = AsyncMock(return_value=mock_301_response)
426+
resp = await request_adapter.get_http_response_message(request_info)
427+
assert resp.status_code == 301
428+
assert "location" not in resp.headers
429+
with pytest.raises(APIError) as e:
430+
await request_adapter.send_primitive_async(request_info, "float", {})
431+
assert e is not None
432+
assert e.value.response_status_code == 301
433+
434+
435+
@pytest.mark.asyncio
436+
async def test_send_primitive_async_302_with_location_header_does_not_throw(
437+
request_adapter, request_info
438+
):
439+
mock_302_response = httpx.Response(
440+
status_code=302,
441+
headers={
442+
"Content-Type": "application/json",
443+
"location": "https://example.com"
444+
}
445+
)
446+
request_adapter.get_http_response_message = AsyncMock(return_value=mock_302_response)
447+
resp = await request_adapter.get_http_response_message(request_info)
448+
assert resp.status_code == 302
449+
assert "location" in resp.headers
450+
await request_adapter.send_primitive_async(request_info, "float", {})
451+
452+
405453
def test_httpx_request_adapter_uses_http_client_base_url(auth_provider):
406454
http_client = httpx.AsyncClient(base_url=BASE_URL)
407455
request_adapter = HttpxRequestAdapter(auth_provider, http_client=http_client)

0 commit comments

Comments
 (0)