Skip to content

Commit b88f449

Browse files
committed
✨ feat: implement RFC 9457 error handling
1 parent e5659f6 commit b88f449

File tree

6 files changed

+156
-115
lines changed

6 files changed

+156
-115
lines changed

osc_sdk/problem.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import json
2+
3+
import defusedxml.ElementTree as ET
4+
5+
6+
class ProblemDecoder(json.JSONDecoder):
7+
def decode(self, s):
8+
data = super().decode(s)
9+
if isinstance(data, dict):
10+
return self._make_problem(data)
11+
return data
12+
13+
def _make_problem(self, data):
14+
type_ = data.pop("type", None)
15+
status = data.pop("status", None)
16+
title = data.pop("title", None)
17+
detail = data.pop("detail", None)
18+
instance = data.pop("instance", None)
19+
return Problem(type_, status, title, detail, instance, **data)
20+
21+
22+
class Problem(Exception):
23+
def __init__(self, type_, status, title, detail, instance, **kwargs):
24+
self._type = type_ or "about:blank"
25+
self.status = status
26+
self.title = title
27+
self.detail = detail
28+
self.instance = instance
29+
self.extras = kwargs
30+
31+
for k in self.extras:
32+
if k in ["type", "status", "title", "detail", "instance"]:
33+
raise ValueError(f"Reserved key '{k}' used in Problem extra arguments.")
34+
35+
def __str__(self):
36+
return self.title
37+
38+
def __repr__(self):
39+
return f"{self.__class__.__name__}<type={self._type}; status={self.status}; title={self.title}>"
40+
41+
def msg(self):
42+
msg = (
43+
f"type = {self._type}, "
44+
f"status = {self.status}, "
45+
f"title = {self.title}, "
46+
f"detail = {self.detail}, "
47+
f"instance = {self.instance}, "
48+
f"extras = {self.extras}"
49+
)
50+
return msg
51+
52+
@property
53+
def type(self):
54+
return self._type
55+
56+
57+
class LegacyProblemDecoder(json.JSONDecoder):
58+
def decode(self, s):
59+
data = super().decode(s)
60+
if isinstance(data, dict):
61+
return self._make_legacy_problem(data)
62+
return data
63+
64+
def _make_legacy_problem(self, data):
65+
request_id = None
66+
error_code = None
67+
code_type = None
68+
69+
if "__type" in data:
70+
error_code = data.get("__type")
71+
else:
72+
request_id = (data.get("ResponseContext") or {}).get("RequestId")
73+
errors = data.get("Errors")
74+
if errors:
75+
error = errors[0]
76+
error_code = error.get("Code")
77+
reason = error.get("Type")
78+
if error.get("Details"):
79+
code_type = reason
80+
else:
81+
code_type = None
82+
return LegacyProblem(None, error_code, code_type, request_id, None)
83+
84+
85+
class LegacyProblem(Exception):
86+
def __init__(self, status, error_code, code_type, request_id, url):
87+
self.status = status
88+
self.error_code = error_code
89+
self.code_type = code_type
90+
self.request_id = request_id
91+
self.url = url
92+
93+
def msg(self):
94+
msg = (
95+
f"status = {self.status}, "
96+
f"code = {self.error_code}, "
97+
f"{'code_type = ' if self.code_type is not None else ''}"
98+
f"{self.code_type + ', ' if self.code_type is not None else ''}"
99+
f"request_id = {self.request_id}, "
100+
f"url = {self.url}"
101+
)
102+
return msg
103+
104+
105+
def api_error(response):
106+
try:
107+
problem = None
108+
ct = response.headers.get("content-type") or ""
109+
if "application/json" in ct:
110+
problem = response.json(cls=LegacyProblemDecoder)
111+
problem.status = problem.status or str(response.status_code)
112+
problem.url = response.url
113+
elif "application/problem+json" in ct:
114+
problem = response.json(cls=ProblemDecoder)
115+
problem.status = problem.status or str(response.status_code)
116+
117+
if problem:
118+
return problem
119+
except json.JSONDecodeError:
120+
pass
121+
122+
try:
123+
error = ET.fromstring(response.text)
124+
125+
err = dict()
126+
for key, attr in [
127+
("Code", "error_code"),
128+
("Message", "status"),
129+
("RequestId", "request_id"),
130+
("RequestID", "request_id"),
131+
]:
132+
value = next((x.text for x in error.iter() if x.tag.endswith(key)), None)
133+
if value:
134+
err[attr] = value
135+
136+
return LegacyProblem(**err)
137+
except:
138+
raise Exception(
139+
f"Could not decode error response from {response.url} with status code {response.status_code}"
140+
)

osc_sdk/sdk.py

Lines changed: 5 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from requests.models import Response
2020
from typing_extensions import TypedDict
2121

22+
from .problem import api_error
23+
2224
CANONICAL_URI = "/"
2325
CONFIGURATION_FILE = "config.json"
2426
CONFIGURATION_FOLDER = ".osc"
@@ -121,77 +123,6 @@ class Tag(TypedDict):
121123
Values: List[str]
122124

123125

124-
@dataclass
125-
class OscApiException(Exception):
126-
http_response: InitVar[Response]
127-
128-
status_code: int = field(init=False)
129-
error_code: Optional[str] = field(default=None, init=False)
130-
message: Optional[str] = field(default=None, init=False)
131-
code_type: Optional[str] = field(default=None, init=False)
132-
request_id: Optional[str] = field(default=None, init=False)
133-
134-
def __post_init__(self, http_response: Response):
135-
super().__init__()
136-
self.status_code = http_response.status_code
137-
# Set error details
138-
self._set(http_response)
139-
140-
def __str__(self) -> str:
141-
return (
142-
f"Error --> status = {self.status_code}, "
143-
f"code = {self.error_code}, "
144-
f"{'code_type = ' if self.code_type is not None else ''}"
145-
f"{self.code_type + ', ' if self.code_type is not None else ''}"
146-
f"Reason = {self.message}, "
147-
f"request_id = {self.request_id}"
148-
)
149-
150-
def _set(self, http_response: Response):
151-
content = http_response.content.decode()
152-
# In case it is JSON error format
153-
try:
154-
error = json.loads(content)
155-
except json.JSONDecodeError:
156-
pass
157-
else:
158-
if "__type" in error:
159-
self.error_code = error.get("__type")
160-
self.message = error.get("message")
161-
self.request_id = http_response.headers.get("x-amz-requestid")
162-
else:
163-
self.request_id = (error.get("ResponseContext") or {}).get("RequestId")
164-
errors = error.get("Errors")
165-
if errors:
166-
error = errors[0]
167-
self.error_code = error.get("Code")
168-
self.message = error.get("Type")
169-
if error.get("Details"):
170-
self.code_type = self.message
171-
self.message = error.get("Details")
172-
else:
173-
self.code_type = None
174-
return
175-
176-
# In case it is XML format
177-
try:
178-
error = ET.fromstring(content)
179-
except ET.ParseError:
180-
return
181-
else:
182-
for key, attr in [
183-
("Code", "error_code"),
184-
("Message", "message"),
185-
("RequestId", "request_id"),
186-
("RequestID", "request_id"),
187-
]:
188-
value = next(
189-
(x.text for x in error.iter() if x.tag.endswith(key)), None
190-
)
191-
if value:
192-
setattr(self, attr, value)
193-
194-
195126
@dataclass
196127
class ApiCall:
197128
profile: str = DEFAULT_PROFILE
@@ -437,7 +368,7 @@ def make_request(self, call: str, **kwargs: CallParameters):
437368
class XmlApiCall(ApiCall):
438369
def get_response(self, http_response: Response) -> Union[str, ResponseContent]:
439370
if http_response.status_code not in SUCCESS_CODES:
440-
raise OscApiException(http_response)
371+
raise api_error(http_response)
441372
try:
442373
return cast(ResponseContent, xmltodict.parse(http_response.content))
443374
except Exception:
@@ -502,7 +433,7 @@ def get_parameters(
502433

503434
def get_response(self, http_response: Response) -> ResponseContent:
504435
if http_response.status_code not in SUCCESS_CODES:
505-
raise OscApiException(http_response)
436+
raise api_error(http_response)
506437

507438
return json.loads(http_response.text)
508439

@@ -641,7 +572,7 @@ class DirectLinkCall(JsonApiCall):
641572

642573
def get_response(self, http_response: Response) -> ResponseContent:
643574
if http_response.status_code not in SUCCESS_CODES:
644-
raise OscApiException(http_response)
575+
raise api_error(http_response)
645576

646577
res = json.loads(http_response.text)
647578
res["requestid"] = http_response.headers["x-amz-requestid"]

osc_sdk/test_auth.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class Env(object):
1111
access_key: str
1212
secret_key: str
13-
endpoint_icu: str
13+
endpoint_api: str
1414
region: str
1515

1616

@@ -19,17 +19,17 @@ def env() -> Env:
1919
return Env(
2020
access_key=os.getenv("OSC_TEST_ACCESS_KEY", ""),
2121
secret_key=os.getenv("OSC_TEST_SECRET_KEY", ""),
22-
endpoint_icu=os.getenv("OSC_TEST_ENDPOINT_ICU", ""),
22+
endpoint_api=os.getenv("OSC_TEST_ENDPOINT_API", ""),
2323
region=os.getenv("OSC_TEST_REGION", ""),
2424
)
2525

2626

27-
def test_icu_auth_ak_sk(env):
28-
icu = sdk.IcuCall(
27+
def test_api_auth_ak_sk(env):
28+
api = sdk.OSCCall(
2929
access_key=env.access_key,
3030
secret_key=env.secret_key,
31-
endpoint=env.endpoint_icu,
31+
endpoint=env.endpoint_api,
3232
region_name=env.region,
3333
)
34-
icu.make_request("GetAccount")
35-
assert len(icu.response) > 0
34+
api.make_request("ReadAccounts")
35+
assert len(api.response) > 0

osc_sdk/test_errors.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
from . import sdk
7+
from .problem import LegacyProblem
78

89

910
@dataclass
@@ -32,6 +33,6 @@ def test_bad_filter(env):
3233
endpoint=env.endpoint_api,
3334
region_name=env.region,
3435
)
35-
with pytest.raises(sdk.OscApiException) as e:
36+
with pytest.raises(LegacyProblem) as e:
3637
oapi.make_request("ReadImages", Filters='"bad_filter"')
37-
assert e.value.status_code == 400
38+
assert e.value.status == "400"

osc_sdk/test_noauth.py

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,37 +28,6 @@ def env() -> Env:
2828
)
2929

3030

31-
def test_icu_noauth_call_with_auth_env(env):
32-
icu = sdk.IcuCall(
33-
access_key=env.access_key,
34-
secret_key=env.secret_key,
35-
endpoint=env.endpoint_icu,
36-
region_name=env.region,
37-
)
38-
icu.make_request("ReadPublicCatalog")
39-
assert len(icu.response)
40-
41-
42-
def test_icu_noauth_call_with_empty_auth_env(env):
43-
icu = sdk.IcuCall( # nosec
44-
access_key="",
45-
secret_key="",
46-
endpoint=env.endpoint_icu,
47-
region_name=env.region,
48-
)
49-
icu.make_request("ReadPublicCatalog")
50-
assert len(icu.response)
51-
52-
53-
def test_icu_noauth_basic(env):
54-
icu = sdk.IcuCall(
55-
endpoint=env.endpoint_icu,
56-
region_name=env.region,
57-
)
58-
icu.make_request("ReadPublicCatalog")
59-
assert len(icu.response)
60-
61-
6231
def test_api_noauth_call_with_auth_env(env):
6332
api = sdk.OSCCall(
6433
access_key=env.access_key,

tests/test_pytest.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@ fi
3030
PROJECT_ROOT=$(cd "$(dirname $0)/.." && pwd)
3131
cd $PROJECT_ROOT
3232
. .venv/bin/activate
33-
pytest osc_sdk &> /dev/null
33+
pytest osc_sdk
3434
echo "OK"

0 commit comments

Comments
 (0)