Skip to content

Commit 34a196a

Browse files
Raise UnexpectedQueryResponseError for non-JSON Cloud Query bodies.
Empty or arbitrary-text 4xx responses no longer surface as JSONDecodeError. Closes #3093. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b0f97a4 commit 34a196a

7 files changed

Lines changed: 160 additions & 6 deletions

File tree

newsfragments/3093.change.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Raise ``UnexpectedQueryResponseError`` for empty or non-JSON Cloud Query error responses.

spelling_private_dict.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ TargetStatusNotSuccess
2222
TargetStatusProcessing
2323
TooManyRequests
2424
Ubuntu
25+
UnexpectedQueryResponse
2526
UnknownTarget
2627
admin
2728
api

src/vws/async_query.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from vws.exceptions.custom_exceptions import (
2323
RequestEntityTooLargeError,
2424
ServerError,
25+
UnexpectedQueryResponseError,
2526
)
2627
from vws.include_target_data import CloudRecoIncludeTargetData
2728
from vws.reports import QueryResult
@@ -117,6 +118,8 @@ async def query(
117118
given image is too large.
118119
~vws.exceptions.custom_exceptions.ServerError: There is an
119120
error with Vuforia's servers.
121+
~vws.exceptions.custom_exceptions.UnexpectedQueryResponseError:
122+
The response body is empty or is not valid JSON.
120123
121124
Returns:
122125
An ordered list of target details of matching
@@ -184,7 +187,12 @@ async def query(
184187
): # pragma: no cover
185188
raise ServerError(response=response)
186189

187-
result_code = json.loads(s=response.text)["result_code"]
190+
try:
191+
response_dict = json.loads(s=response.text)
192+
except json.JSONDecodeError as exc:
193+
raise UnexpectedQueryResponseError(response=response) from exc
194+
195+
result_code = response_dict["result_code"]
188196
if result_code != "Success":
189197
exception = {
190198
"AuthenticationFailure": (AuthenticationFailureError),
@@ -194,9 +202,7 @@ async def query(
194202
}[result_code]
195203
raise exception(response=response)
196204

197-
result_list = list(
198-
json.loads(s=response.text)["results"],
199-
)
205+
result_list = list(response_dict["results"])
200206
return [
201207
QueryResult.from_response_dict(response_dict=item)
202208
for item in result_list

src/vws/exceptions/custom_exceptions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,25 @@ def __init__(self, response: Response) -> None:
5050
def response(self) -> Response:
5151
"""The response returned by Vuforia which included this error."""
5252
return self._response
53+
54+
55+
@beartype
56+
class UnexpectedQueryResponseError(Exception):
57+
"""Exception raised when a Cloud Query response body is not valid JSON.
58+
59+
Vuforia documents that failed Cloud Query requests may return an
60+
arbitrary body or no body at all.
61+
"""
62+
63+
def __init__(self, response: Response) -> None:
64+
"""
65+
Args:
66+
response: The response returned by Vuforia.
67+
"""
68+
super().__init__(response.text)
69+
self._response = response
70+
71+
@property
72+
def response(self) -> Response:
73+
"""The response returned by Vuforia which included this error."""
74+
return self._response

src/vws/query.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from vws.exceptions.custom_exceptions import (
2121
RequestEntityTooLargeError,
2222
ServerError,
23+
UnexpectedQueryResponseError,
2324
)
2425
from vws.include_target_data import CloudRecoIncludeTargetData
2526
from vws.reports import QueryResult
@@ -99,6 +100,8 @@ def query(
99100
given image is too large.
100101
~vws.exceptions.custom_exceptions.ServerError: There is an
101102
error with Vuforia's servers.
103+
~vws.exceptions.custom_exceptions.UnexpectedQueryResponseError:
104+
The response body is empty or is not valid JSON.
102105
103106
Returns:
104107
An ordered list of target details of matching targets.
@@ -154,7 +157,12 @@ def query(
154157
): # pragma: no cover
155158
raise ServerError(response=response)
156159

157-
result_code = json.loads(s=response.text)["result_code"]
160+
try:
161+
response_dict = json.loads(s=response.text)
162+
except json.JSONDecodeError as exc:
163+
raise UnexpectedQueryResponseError(response=response) from exc
164+
165+
result_code = response_dict["result_code"]
158166
if result_code != "Success":
159167
exception = {
160168
"AuthenticationFailure": AuthenticationFailureError,
@@ -164,7 +172,7 @@ def query(
164172
}[result_code]
165173
raise exception(response=response)
166174

167-
result_list = list(json.loads(s=response.text)["results"])
175+
result_list = list(response_dict["results"])
168176
return [
169177
QueryResult.from_response_dict(response_dict=item)
170178
for item in result_list

tests/test_async_cloud_reco_exceptions.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
)
2020
from vws.exceptions.custom_exceptions import (
2121
RequestEntityTooLargeError,
22+
UnexpectedQueryResponseError,
2223
)
24+
from vws.response import Response
2325

2426

2527
@pytest.mark.asyncio
@@ -118,3 +120,59 @@ async def test_inactive_project(
118120
response = exc.value.response
119121
assert response.status_code == HTTPStatus.FORBIDDEN
120122
assert response.tell_position != 0
123+
124+
125+
@pytest.mark.asyncio
126+
@pytest.mark.parametrize(
127+
argnames="response_text",
128+
argvalues=["", "not-json"],
129+
)
130+
async def test_unexpected_query_response(
131+
*,
132+
high_quality_image: io.BytesIO,
133+
response_text: str,
134+
) -> None:
135+
"""
136+
An ``UnexpectedQueryResponseError`` is raised for empty or non-JSON
137+
4xx Cloud Query responses.
138+
"""
139+
140+
class _NonJSONAsyncTransport:
141+
"""Async transport that returns a non-JSON 4xx response."""
142+
143+
async def aclose(self) -> None:
144+
"""Close the transport."""
145+
146+
async def __call__(
147+
self,
148+
*,
149+
method: str,
150+
url: str,
151+
headers: dict[str, str],
152+
data: bytes,
153+
request_timeout: float | tuple[float, float],
154+
) -> Response:
155+
"""Return a non-JSON error response."""
156+
del method, headers, request_timeout
157+
return Response(
158+
text=response_text,
159+
url=url,
160+
status_code=HTTPStatus.BAD_REQUEST,
161+
headers={},
162+
request_body=data,
163+
tell_position=0,
164+
content=response_text.encode(),
165+
)
166+
167+
async with AsyncCloudRecoService(
168+
client_access_key=uuid.uuid4().hex,
169+
client_secret_key=uuid.uuid4().hex,
170+
transport=_NonJSONAsyncTransport(),
171+
) as async_cloud_reco_client:
172+
with pytest.raises(
173+
expected_exception=UnexpectedQueryResponseError,
174+
) as exc:
175+
await async_cloud_reco_client.query(image=high_quality_image)
176+
177+
assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
178+
assert exc.value.response.text == response_text

tests/test_cloud_reco_exceptions.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
)
2121
from vws.exceptions.custom_exceptions import (
2222
RequestEntityTooLargeError,
23+
UnexpectedQueryResponseError,
2324
)
25+
from vws.response import Response
2426

2527

2628
def test_too_many_max_results(
@@ -126,3 +128,59 @@ def test_inactive_project(
126128
# We need one test which checks tell position
127129
# and so we choose this one almost at random.
128130
assert response.tell_position != 0
131+
132+
133+
@pytest.mark.parametrize(
134+
argnames="response_text",
135+
argvalues=["", "not-json"],
136+
)
137+
def test_unexpected_query_response(
138+
*,
139+
high_quality_image: io.BytesIO,
140+
response_text: str,
141+
) -> None:
142+
"""
143+
An ``UnexpectedQueryResponseError`` is raised for empty or non-JSON
144+
4xx Cloud Query responses.
145+
"""
146+
147+
class _NonJSONTransport:
148+
"""Transport that returns a non-JSON 4xx response."""
149+
150+
def close(self) -> None:
151+
"""Close the transport."""
152+
153+
def __call__(
154+
self,
155+
*,
156+
method: str,
157+
url: str,
158+
headers: dict[str, str],
159+
data: bytes,
160+
request_timeout: float | tuple[float, float],
161+
) -> Response:
162+
"""Return a non-JSON error response."""
163+
del method, headers, request_timeout
164+
return Response(
165+
text=response_text,
166+
url=url,
167+
status_code=HTTPStatus.BAD_REQUEST,
168+
headers={},
169+
request_body=data,
170+
tell_position=0,
171+
content=response_text.encode(),
172+
)
173+
174+
cloud_reco_client = CloudRecoService(
175+
client_access_key=uuid.uuid4().hex,
176+
client_secret_key=uuid.uuid4().hex,
177+
transport=_NonJSONTransport(),
178+
)
179+
180+
with pytest.raises(
181+
expected_exception=UnexpectedQueryResponseError,
182+
) as exc:
183+
cloud_reco_client.query(image=high_quality_image)
184+
185+
assert exc.value.response.status_code == HTTPStatus.BAD_REQUEST
186+
assert exc.value.response.text == response_text

0 commit comments

Comments
 (0)