-
-
Notifications
You must be signed in to change notification settings - Fork 163
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #558 from nolar/log-responses
Enrich the K8s API errors with information from K8s API itself
- Loading branch information
Showing
8 changed files
with
149 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import collections.abc | ||
import json | ||
|
||
import aiohttp | ||
|
||
|
||
class APIClientResponseError(aiohttp.ClientResponseError): | ||
""" | ||
Same as :class:`aiohttp.ClientResponseError`, but with information from K8s. | ||
""" | ||
|
||
|
||
async def check_response( | ||
response: aiohttp.ClientResponse, | ||
) -> None: | ||
""" | ||
Check for specialised K8s errors, and raise with extended information. | ||
Built-in aiohttp's errors only provide the rudimentary titles of the status | ||
codes, but not the explanation why that error happened. K8s API provides | ||
this information in the bodies of non-2xx responses. That information can | ||
replace aiohttp's error messages to be more helpful. | ||
However, the same error classes are used for now, to meet the expectations | ||
if some routines in their ``except:`` clauses analysing for specific HTTP | ||
statuses: e.g. 401 for re-login activities, 404 for patching/deletion, etc. | ||
""" | ||
if response.status >= 400: | ||
try: | ||
payload = await response.json() | ||
except (json.JSONDecodeError, aiohttp.ContentTypeError, aiohttp.ClientConnectionError): | ||
payload = None | ||
|
||
# Better be safe: who knows which sensitive information can be dumped unless kind==Status. | ||
if not isinstance(payload, collections.abc.Mapping) or payload.get('kind') != 'Status': | ||
payload = None | ||
|
||
# If no information can be retrieved, fall back to the original error. | ||
if payload is None: | ||
response.raise_for_status() | ||
else: | ||
details = payload.get('details') | ||
message = payload.get('message') or f"{details}" | ||
raise APIClientResponseError( | ||
response.request_info, | ||
response.history, | ||
status=response.status, | ||
headers=response.headers, | ||
message=message) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import aiohttp | ||
import pytest | ||
|
||
from kopf.clients.auth import APIContext, reauthenticated_request | ||
from kopf.clients.errors import APIClientResponseError, check_response | ||
|
||
|
||
@reauthenticated_request | ||
async def get_it(url: str, *, context: APIContext) -> None: | ||
response = await context.session.get(url) | ||
await check_response(response) | ||
return await response.json() | ||
|
||
|
||
@pytest.mark.parametrize('status', [200, 202, 300, 304]) | ||
async def test_no_error_on_success( | ||
resp_mocker, aresponses, hostname, resource, status): | ||
|
||
resp = aresponses.Response( | ||
status=status, | ||
reason="boo!", | ||
headers={'Content-Type': 'application/json'}, | ||
text='{"kind": "Status", "code": "xxx", "message": "msg"}', | ||
) | ||
aresponses.add(hostname, '/', 'get', resp_mocker(return_value=resp)) | ||
|
||
await get_it(f"http://{hostname}/") | ||
|
||
|
||
@pytest.mark.parametrize('status', [400, 401, 403, 404, 500, 666]) | ||
async def test_replaced_error_raised_with_payload( | ||
resp_mocker, aresponses, hostname, resource, status): | ||
|
||
resp = aresponses.Response( | ||
status=status, | ||
reason="boo!", | ||
headers={'Content-Type': 'application/json'}, | ||
text='{"kind": "Status", "code": "xxx", "message": "msg"}', | ||
) | ||
aresponses.add(hostname, '/', 'get', resp_mocker(return_value=resp)) | ||
|
||
with pytest.raises(aiohttp.ClientResponseError) as err: | ||
await get_it(f"http://{hostname}/") | ||
|
||
assert isinstance(err.value, APIClientResponseError) | ||
assert err.value.status == status | ||
assert err.value.message == 'msg' | ||
|
||
|
||
@pytest.mark.parametrize('status', [400, 500, 666]) | ||
async def test_original_error_raised_if_nonjson_payload( | ||
resp_mocker, aresponses, hostname, resource, status): | ||
|
||
resp = aresponses.Response( | ||
status=status, | ||
reason="boo!", | ||
headers={'Content-Type': 'application/json'}, | ||
text='unparsable json', | ||
) | ||
aresponses.add(hostname, '/', 'get', resp_mocker(return_value=resp)) | ||
|
||
with pytest.raises(aiohttp.ClientResponseError) as err: | ||
await get_it(f"http://{hostname}/") | ||
|
||
assert not isinstance(err.value, APIClientResponseError) | ||
assert err.value.status == status | ||
assert err.value.message == 'boo!' | ||
|
||
|
||
@pytest.mark.parametrize('status', [400, 500, 666]) | ||
async def test_original_error_raised_if_parseable_nonk8s_payload( | ||
resp_mocker, aresponses, hostname, resource, status): | ||
|
||
resp = aresponses.Response( | ||
status=status, | ||
reason="boo!", | ||
headers={'Content-Type': 'application/json'}, | ||
text='{"kind": "NonStatus", "code": "xxx", "message": "msg"}', | ||
) | ||
aresponses.add(hostname, '/', 'get', resp_mocker(return_value=resp)) | ||
|
||
with pytest.raises(aiohttp.ClientResponseError) as err: | ||
await get_it(f"http://{hostname}/") | ||
|
||
assert not isinstance(err.value, APIClientResponseError) | ||
assert err.value.status == status | ||
assert err.value.message == 'boo!' |