From 8a0b7675b5b07f96c81f2ad77a067ed5483403f5 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 4 Aug 2026 14:35:19 +0100 Subject: [PATCH 1/2] Handle non-JSON Cloud Query errors --- newsfragments/3093.change.rst | 1 + pyproject.toml | 19 +-------- src/vws/async_query.py | 25 +++++++++-- src/vws/query.py | 23 +++++++++- tests/test_async_cloud_reco_exceptions.py | 52 ++++++++++++++++++++++- tests/test_cloud_reco_exceptions.py | 50 +++++++++++++++++++++- 6 files changed, 144 insertions(+), 26 deletions(-) create mode 100644 newsfragments/3093.change.rst diff --git a/newsfragments/3093.change.rst b/newsfragments/3093.change.rst new file mode 100644 index 000000000..7a603b777 --- /dev/null +++ b/newsfragments/3093.change.rst @@ -0,0 +1 @@ +Raise a response-carrying ``CloudRecoError`` when Cloud Query returns a documented empty or non-JSON 4xx response instead of leaking ``JSONDecodeError``. diff --git a/pyproject.toml b/pyproject.toml index 8bdb08085..eabcc8401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,16 +80,11 @@ optional-dependencies.dev = [ "sphinxcontrib-towncrier==0.5.0a0", "strict-kwargs==2026.7.24", "sybil==10.1.0", - # Listed explicitly (despite being transitive via vws-python-mock) so that - # [tool.uv.sources] can redirect to the CPU-only PyTorch index. - # See: https://vws-python.github.io/vws-python-mock/installation.html#faster-installation - "torch>=2.5.1", - "torchvision>=0.20.1", "towncrier==25.8.0", "ty==0.0.65", "types-requests==2.33.0.20260712", "vulture==2.16", - "vws-python-mock==2026.8.4", + "vws-python-mock==2026.8.4.1", "vws-test-fixtures==2023.3.5", "yamlfix==1.19.1", "zizmor==1.28.0", @@ -118,11 +113,6 @@ zip-safe = false # Code to match this is in ``conf.py``. version_scheme = "post-release" -[tool.uv] -sources.torch = { index = "pytorch-cpu" } -sources.torchvision = { index = "pytorch-cpu" } -index = [ { name = "pytorch-cpu", url = "https://download.pytorch.org/whl/cpu", explicit = true } ] - [tool.ruff] line-length = 79 lint.select = [ @@ -317,13 +307,6 @@ ignore = [ ] [tool.deptry] -# torch and torchvision are listed explicitly in dev deps to allow -# [tool.uv.sources] to redirect them to the CPU-only PyTorch index, -# but they are not directly imported in the vws-python source code. -per_rule_ignores.DEP002 = [ - "torch", - "torchvision", -] optional_dependencies_dev_groups = [ "dev", "release", diff --git a/src/vws/async_query.py b/src/vws/async_query.py index 88536144f..9e5bd782a 100644 --- a/src/vws/async_query.py +++ b/src/vws/async_query.py @@ -12,6 +12,7 @@ from vws._image_utils import ImageType as _ImageType from vws._image_utils import get_image_data as _get_image_data +from vws.exceptions.base_exceptions import CloudRecoError from vws.exceptions.cloud_reco_exceptions import ( AuthenticationFailureError, BadImageError, @@ -119,6 +120,8 @@ async def query( given image is too large. ~vws.exceptions.custom_exceptions.ServerError: There is an error with Vuforia's servers. + ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned + a client error without a recognized JSON body. Returns: An ordered list of target details of matching @@ -186,7 +189,23 @@ async def query( ): # pragma: no cover raise ServerError(response=response) - result_code = json.loads(s=response.text)["result_code"] + content_type = { + key.lower(): value for key, value in response.headers.items() + }.get("content-type", "") + if ( + response.status_code >= HTTPStatus.BAD_REQUEST + and not content_type.lower().startswith("application/json") + ): + raise CloudRecoError(response=response) + + try: + response_body = json.loads(s=response.text) + except json.JSONDecodeError as exc: + if response.status_code >= HTTPStatus.BAD_REQUEST: + raise CloudRecoError(response=response) from exc + raise + + result_code = response_body["result_code"] if result_code != "Success": exception = { "AuthenticationFailure": (AuthenticationFailureError), @@ -196,9 +215,7 @@ async def query( }[result_code] raise exception(response=response) - result_list = list( - json.loads(s=response.text)["results"], - ) + result_list = list(response_body["results"]) return [ QueryResult.from_response_dict(response_dict=item) for item in result_list diff --git a/src/vws/query.py b/src/vws/query.py index 3d9e19d7f..7c5fce3d5 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -10,6 +10,7 @@ from vws._image_utils import ImageType as _ImageType from vws._image_utils import get_image_data as _get_image_data +from vws.exceptions.base_exceptions import CloudRecoError from vws.exceptions.cloud_reco_exceptions import ( AuthenticationFailureError, BadImageError, @@ -101,6 +102,8 @@ def query( given image is too large. ~vws.exceptions.custom_exceptions.ServerError: There is an error with Vuforia's servers. + ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned + a client error without a recognized JSON body. Returns: An ordered list of target details of matching targets. @@ -156,7 +159,23 @@ def query( ): # pragma: no cover raise ServerError(response=response) - result_code = json.loads(s=response.text)["result_code"] + content_type = { + key.lower(): value for key, value in response.headers.items() + }.get("content-type", "") + if ( + response.status_code >= HTTPStatus.BAD_REQUEST + and not content_type.lower().startswith("application/json") + ): + raise CloudRecoError(response=response) + + try: + response_body = json.loads(s=response.text) + except json.JSONDecodeError as exc: + if response.status_code >= HTTPStatus.BAD_REQUEST: + raise CloudRecoError(response=response) from exc + raise + + result_code = response_body["result_code"] if result_code != "Success": exception = { "AuthenticationFailure": AuthenticationFailureError, @@ -166,7 +185,7 @@ def query( }[result_code] raise exception(response=response) - result_list = list(json.loads(s=response.text)["results"]) + result_list = list(response_body["results"]) return [ QueryResult.from_response_dict(response_dict=item) for item in result_list diff --git a/tests/test_async_cloud_reco_exceptions.py b/tests/test_async_cloud_reco_exceptions.py index f425be299..d3f443d12 100644 --- a/tests/test_async_cloud_reco_exceptions.py +++ b/tests/test_async_cloud_reco_exceptions.py @@ -7,11 +7,12 @@ from http import HTTPStatus import pytest -from mock_vws import MockVWS +from mock_vws import CloudQueryFailureResponse, MockVWS from mock_vws.database import CloudDatabase from mock_vws.states import States from vws import AsyncCloudRecoService +from vws.exceptions.base_exceptions import CloudRecoError from vws.exceptions.cloud_reco_exceptions import ( AuthenticationFailureError, InactiveProjectError, @@ -118,3 +119,52 @@ async def test_inactive_project( response = exc.value.response assert response.status_code == HTTPStatus.FORBIDDEN assert response.tell_position != 0 + + +@pytest.mark.parametrize( + argnames=("body", "headers"), + argvalues=[ + ("", {"X-Query-Failure": "empty"}), + ( + "Arbitrary upstream failure", + { + "Content-Type": "application/json", + "X-Query-Failure": "text", + }, + ), + ], + ids=["empty", "arbitrary-text"], +) +@pytest.mark.asyncio +async def test_non_json_client_error( + *, + high_quality_image: io.BytesIO, + body: str, + headers: dict[str, str], +) -> None: + """Non-JSON 4xx responses raise a response-carrying error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.BAD_REQUEST, + headers=headers, + body=body, + ) + cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=CloudRecoError) as exc: + await cloud_reco_client.query(image=high_quality_image) + + response = exc.value.response + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.text == body + assert response.content == body.encode() + response_headers = { + key.lower(): value for key, value in response.headers.items() + } + assert response_headers["x-query-failure"] == headers["X-Query-Failure"] + assert response.request_body diff --git a/tests/test_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py index cb50a9db5..562247757 100644 --- a/tests/test_cloud_reco_exceptions.py +++ b/tests/test_cloud_reco_exceptions.py @@ -5,7 +5,7 @@ from http import HTTPStatus import pytest -from mock_vws import MockVWS +from mock_vws import CloudQueryFailureResponse, MockVWS from mock_vws.database import CloudDatabase from mock_vws.states import States @@ -126,3 +126,51 @@ def test_inactive_project( # We need one test which checks tell position # and so we choose this one almost at random. assert response.tell_position != 0 + + +@pytest.mark.parametrize( + argnames=("body", "headers"), + argvalues=[ + ("", {"X-Query-Failure": "empty"}), + ( + "Arbitrary upstream failure", + { + "Content-Type": "application/json", + "X-Query-Failure": "text", + }, + ), + ], + ids=["empty", "arbitrary-text"], +) +def test_non_json_client_error( + *, + high_quality_image: io.BytesIO, + body: str, + headers: dict[str, str], +) -> None: + """Non-JSON 4xx responses raise a response-carrying error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.BAD_REQUEST, + headers=headers, + body=body, + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=CloudRecoError) as exc: + cloud_reco_client.query(image=high_quality_image) + + response = exc.value.response + assert response.status_code == HTTPStatus.BAD_REQUEST + assert response.text == body + assert response.content == body.encode() + response_headers = { + key.lower(): value for key, value in response.headers.items() + } + assert response_headers["x-query-failure"] == headers["X-Query-Failure"] + assert response.request_body From f8344edef77c55c928db74650707b5291b44323b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 4 Aug 2026 16:18:17 +0100 Subject: [PATCH 2/2] Cover malformed successful query responses --- src/vws/async_query.py | 2 ++ src/vws/query.py | 2 ++ tests/test_async_cloud_reco_exceptions.py | 24 +++++++++++++++++++++++ tests/test_cloud_reco_exceptions.py | 23 ++++++++++++++++++++++ 4 files changed, 51 insertions(+) diff --git a/src/vws/async_query.py b/src/vws/async_query.py index 9e5bd782a..ec0704ef4 100644 --- a/src/vws/async_query.py +++ b/src/vws/async_query.py @@ -122,6 +122,8 @@ async def query( error with Vuforia's servers. ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned a client error without a recognized JSON body. + json.JSONDecodeError: Vuforia returned a successful response with + an invalid JSON body. Returns: An ordered list of target details of matching diff --git a/src/vws/query.py b/src/vws/query.py index 7c5fce3d5..3f69261ef 100644 --- a/src/vws/query.py +++ b/src/vws/query.py @@ -104,6 +104,8 @@ def query( error with Vuforia's servers. ~vws.exceptions.base_exceptions.CloudRecoError: Vuforia returned a client error without a recognized JSON body. + json.JSONDecodeError: Vuforia returned a successful response with + an invalid JSON body. Returns: An ordered list of target details of matching targets. diff --git a/tests/test_async_cloud_reco_exceptions.py b/tests/test_async_cloud_reco_exceptions.py index d3f443d12..a9c1395a5 100644 --- a/tests/test_async_cloud_reco_exceptions.py +++ b/tests/test_async_cloud_reco_exceptions.py @@ -3,6 +3,7 @@ """ import io # noqa: TC003 +import json import uuid from http import HTTPStatus @@ -168,3 +169,26 @@ async def test_non_json_client_error( } assert response_headers["x-query-failure"] == headers["X-Query-Failure"] assert response.request_body + + +@pytest.mark.asyncio +async def test_non_json_success_response( + *, + high_quality_image: io.BytesIO, +) -> None: + """Malformed successful responses retain the JSON parsing error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.OK, + headers={"Content-Type": "application/json"}, + body="Not JSON", + ) + cloud_reco_client = AsyncCloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=json.JSONDecodeError): + await cloud_reco_client.query(image=high_quality_image) diff --git a/tests/test_cloud_reco_exceptions.py b/tests/test_cloud_reco_exceptions.py index 562247757..29632000c 100644 --- a/tests/test_cloud_reco_exceptions.py +++ b/tests/test_cloud_reco_exceptions.py @@ -1,6 +1,7 @@ """Tests for exceptions raised when using the CloudRecoService.""" import io # noqa: TC003 +import json import uuid from http import HTTPStatus @@ -174,3 +175,25 @@ def test_non_json_client_error( } assert response_headers["x-query-failure"] == headers["X-Query-Failure"] assert response.request_body + + +def test_non_json_success_response( + *, + high_quality_image: io.BytesIO, +) -> None: + """Malformed successful responses retain the JSON parsing error.""" + database = CloudDatabase() + failure_response = CloudQueryFailureResponse( + status_code=HTTPStatus.OK, + headers={"Content-Type": "application/json"}, + body="Not JSON", + ) + cloud_reco_client = CloudRecoService( + client_access_key=database.client_access_key, + client_secret_key=database.client_secret_key, + ) + + with MockVWS(cloud_query_failure_response=failure_response) as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=json.JSONDecodeError): + cloud_reco_client.query(image=high_quality_image)