Skip to content
This repository was archived by the owner on Oct 14, 2024. It is now read-only.

Feature/xxx error mapping #296

Merged
merged 6 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.3.0] - 2024-02-08

### Added

- Added support for `XXX` status code error mapping in RequestAdapter.[#280](https://github.com/microsoft/kiota-http-python/issues/280)

### Changed

## [1.2.1] - 2024-01-22

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Microsoft Kiota HTTP library
[![PyPI version](https://badge.fury.io/py/microsoft-kiota-http.svg)](https://badge.fury.io/py/microsoft-kiota-http)
[![CI Actions Status](https://github.com/microsoft/kiota-http-python/actions/workflows/build_publish.yml/badge.svg?branch=main)](https://github.com/microsoft/kiota-http-python/actions)
[![CI Actions Status](https://github.com/microsoft/kiota-http-python/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/microsoft/kiota-http-python/actions)
[![Downloads](https://pepy.tech/badge/microsoft-kiota-http)](https://pepy.tech/project/microsoft-kiota-http)

The Microsoft Kiota HTTP Library is a python HTTP implementation with HTTPX library.
Expand Down
2 changes: 1 addition & 1 deletion kiota_http/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION: str = '1.2.1'
VERSION: str = '1.3.0'
33 changes: 27 additions & 6 deletions kiota_http/httpx_request_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,9 +451,9 @@ async def throw_failed_responses(
attribute_span.record_exception(exc)
raise exc

if (response_status_code_str not in error_map) and (
(400 <= response_status_code < 500 and "4XX" not in error_map) or
(500 <= response_status_code < 600 and "5XX" not in error_map)
if (
response_status_code_str not in error_map
and self._error_class_not_in_error_mapping(error_map, response_status_code)
):
exc = APIError(
"The server returned an unexpected status code and no error class is registered"
Expand All @@ -466,12 +466,14 @@ async def throw_failed_responses(
_throw_failed_resp_span.set_attribute("status_message", "received_error_response")

error_class = None
if response_status_code_str in error_map:
if response_status_code_str in error_map: # Error Code 400 - <= 599
error_class = error_map[response_status_code_str]
elif 400 <= response_status_code < 500:
elif 400 <= response_status_code < 500 and "4XX" in error_map: # Error code 4XX
error_class = error_map["4XX"]
elif 500 <= response_status_code < 600:
elif 500 <= response_status_code < 600 and "5XX" in error_map: # Error code 5XX
error_class = error_map["5XX"]
elif "XXX" in error_map: # Blanket case
error_class = error_map["XXX"]

root_node = await self.get_root_parse_node(
response, _throw_failed_resp_span, _throw_failed_resp_span
Expand Down Expand Up @@ -635,3 +637,22 @@ async def convert_to_native_async(self, request_info: RequestInformation) -> htt
return request
finally:
parent_span.end()

def _error_class_not_in_error_mapping(
self, error_map: Dict[str, ParsableFactory], status_code: int
) -> bool:
"""Helper function to check if the error class corresponding to a response status code
is not in the error mapping.

Args:
error_map (Dict[str, ParsableFactory]): The error mapping.
status_code (int): The response status code.

Returns:
bool: True if the error class is not in the error mapping, False otherwise.
"""

return (
(400 <= status_code < 500 and "4XX" not in error_map) or
(500 <= status_code < 600 and "5XX" not in error_map)
) and ("XXX" not in error_map)
69 changes: 30 additions & 39 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@

from .helpers import MockTransport, MockErrorObject, MockResponseObject, OfficeLocation

@pytest.fixture
def sample_headers():
return {"Content-Type": "application/json"}

@pytest.fixture
def auth_provider():
Expand Down Expand Up @@ -49,54 +52,42 @@ def mock_error_object():


@pytest.fixture
def mock_error_map():
def mock_error_500_map():
return {
"500": Exception("Internal Server Error"),
}


@pytest.fixture
def mock_apierror_map():
def mock_apierror_map(sample_headers):
return {
"500":
APIError(
"Custom Internal Server Error", {
'cache-control': 'private',
'transfer-encoding': 'chunked',
'content-type': 'application/json'
}, 500
)
"400": APIError("Resource not found", 400, sample_headers),
"500": APIError("Custom Internal Server Error", 500, sample_headers)
}


@pytest.fixture
def mock_request_adapter():
resp = httpx.Response(
json={'error': 'not found'}, status_code=404, headers={"Content-Type": "application/json"}
)
def mock_apierror_XXX_map(sample_headers):
return {"XXX": APIError("OdataError",400, sample_headers)}

@pytest.fixture
def mock_request_adapter(sample_headers):
resp = httpx.Response(json={'error': 'not found'}, status_code=404, headers=sample_headers)
mock_request_adapter = AsyncMock
mock_request_adapter.get_http_response_message = AsyncMock(return_value=resp)


@pytest.fixture
def simple_error_response():
return httpx.Response(
json={'error': 'not found'}, status_code=404, headers={"Content-Type": "application/json"}
)

def simple_error_response(sample_headers):
return httpx.Response(json={'error': 'not found'}, status_code=404, headers=sample_headers)

@pytest.fixture
def simple_success_response():
return httpx.Response(
json={'message': 'Success!'}, status_code=200, headers={"Content-Type": "application/json"}
)
def simple_success_response(sample_headers):
return httpx.Response(json={'message': 'Success!'}, status_code=200, headers=sample_headers)


@pytest.fixture
def mock_user_response(mocker):
def mock_user_response(mocker, sample_headers):
return httpx.Response(
200,
headers={"Content-Type": "application/json"},
headers=sample_headers,
json={
"@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users/$entity",
"businessPhones": ["+1 205 555 0108"],
Expand Down Expand Up @@ -161,25 +152,25 @@ def mock_users_response(mocker):


@pytest.fixture
def mock_primitive_collection_response(mocker):
def mock_primitive_collection_response(sample_headers):
return httpx.Response(
200, json=[12.1, 12.2, 12.3, 12.4, 12.5], headers={"Content-Type": "application/json"}
200, json=[12.1, 12.2, 12.3, 12.4, 12.5], headers=sample_headers
)


@pytest.fixture
def mock_primitive(mocker):
def mock_primitive():
resp = MockResponseObject()
return resp


@pytest.fixture
def mock_primitive_response(mocker):
return httpx.Response(200, json=22.3, headers={"Content-Type": "application/json"})
def mock_primitive_response(sample_headers):
return httpx.Response(200, json=22.3, headers=sample_headers)


@pytest.fixture
def mock_primitive_response_bytes(mocker):
def mock_primitive_response_bytes():
return httpx.Response(
200,
content=b'Hello World',
Expand All @@ -191,7 +182,7 @@ def mock_primitive_response_bytes(mocker):


@pytest.fixture
def mock_primitive_response_with_no_content(mocker):
def mock_primitive_response_with_no_content():
return httpx.Response(
200,
headers={
Expand All @@ -202,13 +193,13 @@ def mock_primitive_response_with_no_content(mocker):


@pytest.fixture
def mock_primitive_response_with_no_content_type_header(mocker):
def mock_primitive_response_with_no_content_type_header():
return httpx.Response(200, content=b'Hello World')


@pytest.fixture
def mock_no_content_response(mocker):
return httpx.Response(204, json="Radom JSON", headers={"Content-Type": "application/json"})
def mock_no_content_response(sample_headers):
return httpx.Response(204, json="Radom JSON", headers=sample_headers)


tracer = trace.get_tracer(__name__)
Expand All @@ -220,7 +211,7 @@ def mock_otel_span():


@pytest.fixture
def mock_cae_failure_response(mocker):
def mock_cae_failure_response():
auth_header = """Bearer authorization_uri="https://login.windows.net/common/oauth2/authorize",
client_id="00000003-0000-0000-c000-000000000000",
error="insufficient_claims",
Expand Down
45 changes: 39 additions & 6 deletions tests/test_httpx_request_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@

from .helpers import MockResponseObject

APPLICATION_JSON = "application/json"
BASE_URL = "https://graph.microsoft.com"



def test_create_request_adapter(auth_provider):
request_adapter = HttpxRequestAdapter(auth_provider)
assert request_adapter._authentication_provider is auth_provider
Expand Down Expand Up @@ -53,7 +55,7 @@ def test_get_serialization_writer_factory(request_adapter):

def test_get_response_content_type(request_adapter, simple_success_response):
content_type = request_adapter.get_response_content_type(simple_success_response)
assert content_type == "application/json"
assert content_type == APPLICATION_JSON


def test_set_base_url_for_request_information(request_adapter, request_info):
Expand Down Expand Up @@ -146,7 +148,7 @@ async def test_throw_failed_responses_null_error_map(

@pytest.mark.asyncio
async def test_throw_failed_responses_no_error_class(
request_adapter, simple_error_response, mock_error_map, mock_otel_span
request_adapter, simple_error_response, mock_error_500_map, mock_otel_span
):
assert simple_error_response.text == '{"error": "not found"}'
assert simple_error_response.status_code == 404
Expand All @@ -156,7 +158,7 @@ async def test_throw_failed_responses_no_error_class(
with pytest.raises(APIError) as e:
span = mock_otel_span
await request_adapter.throw_failed_responses(
simple_error_response, mock_error_map, span, span
simple_error_response, mock_error_500_map, span, span
)
assert (
str(e.value.message) == "The server returned an unexpected status code and"
Expand All @@ -167,7 +169,7 @@ async def test_throw_failed_responses_no_error_class(

@pytest.mark.asyncio
async def test_throw_failed_responses_not_apierror(
request_adapter, mock_error_map, mock_error_object, mock_otel_span
request_adapter, mock_error_500_map, mock_error_object, mock_otel_span
):
request_adapter.get_root_parse_node = AsyncMock(return_value=mock_error_object)
resp = httpx.Response(status_code=500, headers={"Content-Type": "application/json"})
Expand All @@ -177,15 +179,30 @@ async def test_throw_failed_responses_not_apierror(

with pytest.raises(Exception) as e:
span = mock_otel_span
await request_adapter.throw_failed_responses(resp, mock_error_map, span, span)
await request_adapter.throw_failed_responses(resp, mock_error_500_map, span, span)
assert ("The server returned an unexpected status code and the error registered"
" for this code failed to deserialize") in str(
e.value.message
)


@pytest.mark.asyncio
async def test_throw_failed_responses(
async def test_throw_failed_responses_4XX(
request_adapter, mock_apierror_map, mock_error_object, mock_otel_span
):
request_adapter.get_root_parse_node = AsyncMock(return_value=mock_error_object)
resp = httpx.Response(status_code=400, headers={"Content-Type": "application/json"})
assert resp.status_code == 400
content_type = request_adapter.get_response_content_type(resp)
assert content_type == "application/json"

with pytest.raises(APIError) as e:
span = mock_otel_span
await request_adapter.throw_failed_responses(resp, mock_apierror_map, span, span)
assert str(e.value.message) == "Resource not found"

@pytest.mark.asyncio
async def test_throw_failed_responses_5XX(
request_adapter, mock_apierror_map, mock_error_object, mock_otel_span
):
request_adapter.get_root_parse_node = AsyncMock(return_value=mock_error_object)
Expand All @@ -198,6 +215,22 @@ async def test_throw_failed_responses(
span = mock_otel_span
await request_adapter.throw_failed_responses(resp, mock_apierror_map, span, span)
assert str(e.value.message) == "Custom Internal Server Error"

@pytest.mark.asyncio
async def test_throw_failed_responses_XXX(
request_adapter, mock_apierror_XXX_map, mock_error_object, mock_otel_span
):
request_adapter.get_root_parse_node = AsyncMock(return_value=mock_error_object)
resp = httpx.Response(status_code=500, headers={"Content-Type": "application/json"})
assert resp.status_code == 500
content_type = request_adapter.get_response_content_type(resp)
assert content_type == "application/json"

with pytest.raises(APIError) as e:
span = mock_otel_span
await request_adapter.throw_failed_responses(resp, mock_apierror_XXX_map, span, span)
assert str(e.value.message) == "OdataError"



@pytest.mark.asyncio
Expand Down