Skip to content

Commit 8a0b767

Browse files
committed
Handle non-JSON Cloud Query errors
1 parent aa0222d commit 8a0b767

6 files changed

Lines changed: 144 additions & 26 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 a response-carrying ``CloudRecoError`` when Cloud Query returns a documented empty or non-JSON 4xx response instead of leaking ``JSONDecodeError``.

pyproject.toml

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,11 @@ optional-dependencies.dev = [
8080
"sphinxcontrib-towncrier==0.5.0a0",
8181
"strict-kwargs==2026.7.24",
8282
"sybil==10.1.0",
83-
# Listed explicitly (despite being transitive via vws-python-mock) so that
84-
# [tool.uv.sources] can redirect to the CPU-only PyTorch index.
85-
# See: https://vws-python.github.io/vws-python-mock/installation.html#faster-installation
86-
"torch>=2.5.1",
87-
"torchvision>=0.20.1",
8883
"towncrier==25.8.0",
8984
"ty==0.0.65",
9085
"types-requests==2.33.0.20260712",
9186
"vulture==2.16",
92-
"vws-python-mock==2026.8.4",
87+
"vws-python-mock==2026.8.4.1",
9388
"vws-test-fixtures==2023.3.5",
9489
"yamlfix==1.19.1",
9590
"zizmor==1.28.0",
@@ -118,11 +113,6 @@ zip-safe = false
118113
# Code to match this is in ``conf.py``.
119114
version_scheme = "post-release"
120115

121-
[tool.uv]
122-
sources.torch = { index = "pytorch-cpu" }
123-
sources.torchvision = { index = "pytorch-cpu" }
124-
index = [ { name = "pytorch-cpu", url = "https://download.pytorch.org/whl/cpu", explicit = true } ]
125-
126116
[tool.ruff]
127117
line-length = 79
128118
lint.select = [
@@ -317,13 +307,6 @@ ignore = [
317307
]
318308

319309
[tool.deptry]
320-
# torch and torchvision are listed explicitly in dev deps to allow
321-
# [tool.uv.sources] to redirect them to the CPU-only PyTorch index,
322-
# but they are not directly imported in the vws-python source code.
323-
per_rule_ignores.DEP002 = [
324-
"torch",
325-
"torchvision",
326-
]
327310
optional_dependencies_dev_groups = [
328311
"dev",
329312
"release",

src/vws/async_query.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from vws._image_utils import ImageType as _ImageType
1414
from vws._image_utils import get_image_data as _get_image_data
15+
from vws.exceptions.base_exceptions import CloudRecoError
1516
from vws.exceptions.cloud_reco_exceptions import (
1617
AuthenticationFailureError,
1718
BadImageError,
@@ -119,6 +120,8 @@ async def query(
119120
given image is too large.
120121
~vws.exceptions.custom_exceptions.ServerError: There is an
121122
error with Vuforia's servers.
123+
~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned
124+
a client error without a recognized JSON body.
122125
123126
Returns:
124127
An ordered list of target details of matching
@@ -186,7 +189,23 @@ async def query(
186189
): # pragma: no cover
187190
raise ServerError(response=response)
188191

189-
result_code = json.loads(s=response.text)["result_code"]
192+
content_type = {
193+
key.lower(): value for key, value in response.headers.items()
194+
}.get("content-type", "")
195+
if (
196+
response.status_code >= HTTPStatus.BAD_REQUEST
197+
and not content_type.lower().startswith("application/json")
198+
):
199+
raise CloudRecoError(response=response)
200+
201+
try:
202+
response_body = json.loads(s=response.text)
203+
except json.JSONDecodeError as exc:
204+
if response.status_code >= HTTPStatus.BAD_REQUEST:
205+
raise CloudRecoError(response=response) from exc
206+
raise
207+
208+
result_code = response_body["result_code"]
190209
if result_code != "Success":
191210
exception = {
192211
"AuthenticationFailure": (AuthenticationFailureError),
@@ -196,9 +215,7 @@ async def query(
196215
}[result_code]
197216
raise exception(response=response)
198217

199-
result_list = list(
200-
json.loads(s=response.text)["results"],
201-
)
218+
result_list = list(response_body["results"])
202219
return [
203220
QueryResult.from_response_dict(response_dict=item)
204221
for item in result_list

src/vws/query.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from vws._image_utils import ImageType as _ImageType
1212
from vws._image_utils import get_image_data as _get_image_data
13+
from vws.exceptions.base_exceptions import CloudRecoError
1314
from vws.exceptions.cloud_reco_exceptions import (
1415
AuthenticationFailureError,
1516
BadImageError,
@@ -101,6 +102,8 @@ def query(
101102
given image is too large.
102103
~vws.exceptions.custom_exceptions.ServerError: There is an
103104
error with Vuforia's servers.
105+
~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned
106+
a client error without a recognized JSON body.
104107
105108
Returns:
106109
An ordered list of target details of matching targets.
@@ -156,7 +159,23 @@ def query(
156159
): # pragma: no cover
157160
raise ServerError(response=response)
158161

159-
result_code = json.loads(s=response.text)["result_code"]
162+
content_type = {
163+
key.lower(): value for key, value in response.headers.items()
164+
}.get("content-type", "")
165+
if (
166+
response.status_code >= HTTPStatus.BAD_REQUEST
167+
and not content_type.lower().startswith("application/json")
168+
):
169+
raise CloudRecoError(response=response)
170+
171+
try:
172+
response_body = json.loads(s=response.text)
173+
except json.JSONDecodeError as exc:
174+
if response.status_code >= HTTPStatus.BAD_REQUEST:
175+
raise CloudRecoError(response=response) from exc
176+
raise
177+
178+
result_code = response_body["result_code"]
160179
if result_code != "Success":
161180
exception = {
162181
"AuthenticationFailure": AuthenticationFailureError,
@@ -166,7 +185,7 @@ def query(
166185
}[result_code]
167186
raise exception(response=response)
168187

169-
result_list = list(json.loads(s=response.text)["results"])
188+
result_list = list(response_body["results"])
170189
return [
171190
QueryResult.from_response_dict(response_dict=item)
172191
for item in result_list

tests/test_async_cloud_reco_exceptions.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
from http import HTTPStatus
88

99
import pytest
10-
from mock_vws import MockVWS
10+
from mock_vws import CloudQueryFailureResponse, MockVWS
1111
from mock_vws.database import CloudDatabase
1212
from mock_vws.states import States
1313

1414
from vws import AsyncCloudRecoService
15+
from vws.exceptions.base_exceptions import CloudRecoError
1516
from vws.exceptions.cloud_reco_exceptions import (
1617
AuthenticationFailureError,
1718
InactiveProjectError,
@@ -118,3 +119,52 @@ async def test_inactive_project(
118119
response = exc.value.response
119120
assert response.status_code == HTTPStatus.FORBIDDEN
120121
assert response.tell_position != 0
122+
123+
124+
@pytest.mark.parametrize(
125+
argnames=("body", "headers"),
126+
argvalues=[
127+
("", {"X-Query-Failure": "empty"}),
128+
(
129+
"Arbitrary upstream failure",
130+
{
131+
"Content-Type": "application/json",
132+
"X-Query-Failure": "text",
133+
},
134+
),
135+
],
136+
ids=["empty", "arbitrary-text"],
137+
)
138+
@pytest.mark.asyncio
139+
async def test_non_json_client_error(
140+
*,
141+
high_quality_image: io.BytesIO,
142+
body: str,
143+
headers: dict[str, str],
144+
) -> None:
145+
"""Non-JSON 4xx responses raise a response-carrying error."""
146+
database = CloudDatabase()
147+
failure_response = CloudQueryFailureResponse(
148+
status_code=HTTPStatus.BAD_REQUEST,
149+
headers=headers,
150+
body=body,
151+
)
152+
cloud_reco_client = AsyncCloudRecoService(
153+
client_access_key=database.client_access_key,
154+
client_secret_key=database.client_secret_key,
155+
)
156+
157+
with MockVWS(cloud_query_failure_response=failure_response) as mock:
158+
mock.add_cloud_database(cloud_database=database)
159+
with pytest.raises(expected_exception=CloudRecoError) as exc:
160+
await cloud_reco_client.query(image=high_quality_image)
161+
162+
response = exc.value.response
163+
assert response.status_code == HTTPStatus.BAD_REQUEST
164+
assert response.text == body
165+
assert response.content == body.encode()
166+
response_headers = {
167+
key.lower(): value for key, value in response.headers.items()
168+
}
169+
assert response_headers["x-query-failure"] == headers["X-Query-Failure"]
170+
assert response.request_body

tests/test_cloud_reco_exceptions.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from http import HTTPStatus
66

77
import pytest
8-
from mock_vws import MockVWS
8+
from mock_vws import CloudQueryFailureResponse, MockVWS
99
from mock_vws.database import CloudDatabase
1010
from mock_vws.states import States
1111

@@ -126,3 +126,51 @@ def test_inactive_project(
126126
# We need one test which checks tell position
127127
# and so we choose this one almost at random.
128128
assert response.tell_position != 0
129+
130+
131+
@pytest.mark.parametrize(
132+
argnames=("body", "headers"),
133+
argvalues=[
134+
("", {"X-Query-Failure": "empty"}),
135+
(
136+
"Arbitrary upstream failure",
137+
{
138+
"Content-Type": "application/json",
139+
"X-Query-Failure": "text",
140+
},
141+
),
142+
],
143+
ids=["empty", "arbitrary-text"],
144+
)
145+
def test_non_json_client_error(
146+
*,
147+
high_quality_image: io.BytesIO,
148+
body: str,
149+
headers: dict[str, str],
150+
) -> None:
151+
"""Non-JSON 4xx responses raise a response-carrying error."""
152+
database = CloudDatabase()
153+
failure_response = CloudQueryFailureResponse(
154+
status_code=HTTPStatus.BAD_REQUEST,
155+
headers=headers,
156+
body=body,
157+
)
158+
cloud_reco_client = CloudRecoService(
159+
client_access_key=database.client_access_key,
160+
client_secret_key=database.client_secret_key,
161+
)
162+
163+
with MockVWS(cloud_query_failure_response=failure_response) as mock:
164+
mock.add_cloud_database(cloud_database=database)
165+
with pytest.raises(expected_exception=CloudRecoError) as exc:
166+
cloud_reco_client.query(image=high_quality_image)
167+
168+
response = exc.value.response
169+
assert response.status_code == HTTPStatus.BAD_REQUEST
170+
assert response.text == body
171+
assert response.content == body.encode()
172+
response_headers = {
173+
key.lower(): value for key, value in response.headers.items()
174+
}
175+
assert response_headers["x-query-failure"] == headers["X-Query-Failure"]
176+
assert response.request_body

0 commit comments

Comments
 (0)