Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions newsfragments/3093.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Raise a response-carrying ``CloudRecoError`` when Cloud Query returns a documented empty or non-JSON 4xx response instead of leaking ``JSONDecodeError``.
19 changes: 1 addition & 18 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 23 additions & 4 deletions src/vws/async_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -119,6 +120,10 @@ 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.
json.JSONDecodeError: Vuforia returned a successful response with
an invalid JSON body.

Returns:
An ordered list of target details of matching
Expand Down Expand Up @@ -186,7 +191,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),
Expand All @@ -196,9 +217,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
Expand Down
25 changes: 23 additions & 2 deletions src/vws/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -101,6 +102,10 @@ 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.
json.JSONDecodeError: Vuforia returned a successful response with
an invalid JSON body.

Returns:
An ordered list of target details of matching targets.
Expand Down Expand Up @@ -156,7 +161,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,
Expand All @@ -166,7 +187,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
Expand Down
76 changes: 75 additions & 1 deletion tests/test_async_cloud_reco_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
"""

import io # noqa: TC003
import json
import uuid
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,
Expand Down Expand Up @@ -118,3 +120,75 @@ 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


@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)
73 changes: 72 additions & 1 deletion tests/test_cloud_reco_exceptions.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Tests for exceptions raised when using the CloudRecoService."""

import io # noqa: TC003
import json
import uuid
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

Expand Down Expand Up @@ -126,3 +127,73 @@ 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


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)
Loading