From 694ea9c3ef4b6ff0b6256f44d411b5070c84781a Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 8 Sep 2026 11:01:44 +0100 Subject: [PATCH 1/2] Verify the VWS request rate limits against real Vuforia Observed against real Vuforia on 2026-09-08, by sending bursts to read-only endpoints: - GET /targets accepts two requests per fixed clock minute, not the documented one. DOCUMENTED_REQUEST_RATE_LIMITS now allows two. - The per-second limits are enforced roughly, by more than one Envoy layer, so the mock keeps the documented numbers with rolling windows. - A limit is keyed on the server access key and applied before the signature is checked. The mock now rate limits before authorization, so a bad-signature request counts and a request over the limit gets a 429 rather than a 401. - The 429 is Envoy's empty-bodied response with x-envoy-ratelimited, not a JSON TooManyRequests body. The mock now returns that shape. A test runs the GET /targets limit against every backend, with a fresh limited database for each mock. The never-attempted entry is removed and the differences document records what was observed but is not implemented: the clock-aligned window and the loose per-second limits. Closes #3572 Co-Authored-By: Claude Fable 5.1 --- docs/source/differences-to-vws.rst | 32 +++-- docs/source/unverified-behavior.rst | 16 +-- newsfragments/3572.change | 2 + src/mock_vws/_flask_server/target_manager.py | 4 +- src/mock_vws/_services_validators/__init__.py | 23 ++-- .../_services_validators/auth_validators.py | 13 +- src/mock_vws/_services_validators/context.py | 11 -- .../_services_validators/exceptions.py | 23 ++-- .../request_rate_validators.py | 22 ---- src/mock_vws/_services_validators/routes.py | 7 +- src/mock_vws/database.py | 2 +- src/mock_vws/request_rate_limits.py | 14 ++- tests/mock_vws/test_requests_mock_usage.py | 46 +++++-- tests/mock_vws/test_target_list.py | 112 +++++++++++++++++- tests/mock_vws/utils/assertions.py | 42 +++++++ 15 files changed, 272 insertions(+), 97 deletions(-) create mode 100644 newsfragments/3572.change delete mode 100644 src/mock_vws/_services_validators/request_rate_validators.py diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index c2a2e2169..3e9b73e52 100644 --- a/docs/source/differences-to-vws.rst +++ b/docs/source/differences-to-vws.rst @@ -184,16 +184,30 @@ endpoints in general, with 45 requests per second for ``GET /targets/{target_id}``, 10 requests per second for ``GET /duplicates/{target_id}``, and 1 request per minute for ``GET /targets``. -The mock models these limits separately for each group of endpoints, but it applies no limit by default. -Applying a limit of 1 request per minute to ``GET /targets`` by default would break the tests of anything which uses the mock. - -.. admonition:: Unverified assumption - - :ref:`unverified-request-rate-limits` +The limits were checked against real Vuforia on 2026-09-08, by sending bursts of requests to read-only endpoints: + +* ``GET /targets`` accepts two requests per minute, not one. + The window is a fixed clock minute: two requests at 40 seconds past the minute were accepted, a third was rejected, and a request three seconds into the next minute was accepted again. +* The per-second limits are enforced roughly, not exactly. + Bursts of 40 concurrent ``GET /summary`` requests saw between 17 and 37 succeed against the documented 15, and a burst of 120 ``GET /targets/{target_id}`` requests saw 74 succeed against the documented 45, so the limiter appears to be spread over more than one instance or window. +* A limit is keyed on the server access key in the ``Authorization`` header, so one database's burst does not affect another database. + Vuforia applies the limit before checking the signature, so a request with a bad signature counts towards the limit, and a request over the limit gets a ``429`` response whether or not it is signed correctly. + Requests without an ``Authorization`` header are not rate limited. +* A rate-limited request gets a ``429`` (``TOO MANY REQUESTS``) response from Envoy with an empty body, no ``Content-Type`` header and an ``x-envoy-ratelimited: true`` header. + Vuforia has an Envoy layer at its edge and another in front of the application, and either may reject the request. + Only a rejection by the inner layer carries an ``x-envoy-upstream-service-time`` header, which the mock always includes. + The ``TooManyRequests`` result code from Vuforia's result codes table does not appear. + +The mock returns the empty Envoy response, applies each limit before checking the request's signature, and tracks each limit separately for each database and each group of endpoints. +The mock's windows are rolling rather than clock-aligned, so two ``GET /targets`` requests block a third until a minute has passed since the first, and the mock enforces the per-second limits exactly. +The mock only limits requests whose access key belongs to a database, because the limits are configured on the database. + +The mock applies no limit by default. +Applying a limit of two requests per minute to ``GET /targets`` by default would break the tests of anything which uses the mock. Set ``request_rate_limits`` to :data:`mock_vws.request_rate_limits.DOCUMENTED_REQUEST_RATE_LIMITS` to apply -the documented limits:: +the limits which real Vuforia applies:: from mock_vws import MockVWS from mock_vws.database import CloudDatabase @@ -205,8 +219,8 @@ the documented limits:: with MockVWS() as mock: mock.add_cloud_database(cloud_database=database) - # A second ``GET /targets`` request within a minute returns - # ``TooManyRequests``. + # A third ``GET /targets`` request within a minute gets a ``429`` + # response. ... ``requests_per_second_limit`` remains available. It applies one limit to all diff --git a/docs/source/unverified-behavior.rst b/docs/source/unverified-behavior.rst index d4d4bbb0a..33b1f4cc7 100644 --- a/docs/source/unverified-behavior.rst +++ b/docs/source/unverified-behavior.rst @@ -38,19 +38,6 @@ The status code and the body shape come from Vuforia's documentation and from th A real database with an exhausted request quota would verify this. No such response has been seen. -.. _unverified-request-rate-limits: - -Request rate limits -------------------- - -:Category: never-attempted -:API: VWS Target API - -Vuforia documents a limit of 15 requests per second for VWS endpoints in general, 45 per second for ``GET /targets/{target_id}``, 10 per second for ``GET /duplicates/{target_id}`` and one per minute for ``GET /targets``. -The mock models the limits separately for each group of endpoints, and applies them only when it is asked to. - -Sending more than the documented number of requests to a real database, and seeing what it returns, would verify this. - .. _unverified-additional-result-codes: Additional result codes @@ -59,8 +46,9 @@ Additional result codes :Category: never-attempted :API: VWS Target API -``ProjectSuspended``, ``ProjectHasNoApiAccess``, ``TargetQuotaReached`` and ``TooManyRequests`` come from Vuforia's result codes table. +``ProjectSuspended``, ``ProjectHasNoApiAccess`` and ``TargetQuotaReached`` come from Vuforia's result codes table. No response from a real database in any of those states has been seen, which is why the mock's ``ProjectHasNoApiAccess`` casing is the table's casing rather than an observed one. +The table also lists ``TooManyRequests``, but a rate-limited request to a real database gets a ``429`` response with no body at all, so the mock never returns that result code. A database put into each state by the Target Manager portal would verify these. diff --git a/newsfragments/3572.change b/newsfragments/3572.change new file mode 100644 index 000000000..bc4adbd49 --- /dev/null +++ b/newsfragments/3572.change @@ -0,0 +1,2 @@ +Match real Vuforia's request rate limiting, which was checked against it on 2026-09-08. +``DOCUMENTED_REQUEST_RATE_LIMITS`` now allows two ``GET /targets`` requests per minute rather than one, a rate-limited request gets Envoy's empty-bodied ``429`` response rather than a JSON ``TooManyRequests`` body, and the limits are applied before the request's signature is checked. diff --git a/src/mock_vws/_flask_server/target_manager.py b/src/mock_vws/_flask_server/target_manager.py index 3471b4bcd..d17a4781d 100644 --- a/src/mock_vws/_flask_server/target_manager.py +++ b/src/mock_vws/_flask_server/target_manager.py @@ -464,8 +464,8 @@ def create_cloud_database() -> Response: :reqjson int requests_per_second_limit: (Optional) The maximum number of VWS requests accepted in a rolling one-second window, across all VWS - endpoints. Set this to zero to make VWS endpoints return - ``TooManyRequests``. + endpoints. Set this to zero to make VWS endpoints return a ``429`` + response. :reqjson request_rate_limits: (Optional) Request rate limits for individual groups of VWS endpoints. This is an object with the optional diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index e444ae7ad..777ac30e0 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -5,6 +5,7 @@ from beartype import beartype from mock_vws._database_matchers import AnyDatabase +from mock_vws.database import CloudDatabase from .auth_validators import ( validate_access_key_exists, @@ -32,6 +33,11 @@ def run_services_validators[DatabaseT: AnyDatabase]( NGINX rejects a request with an over-long header line before it reaches Vuforia, so that is checked first. + Vuforia's Envoy layer then applies the request rate limits, keyed on the + access key in the ``Authorization`` header and before the signature is + checked, so a request with a bad signature still uses up the database's + budget and a database over its limit gets a ``429`` response rather + than a ``401`` response. Every request is then authorized, because the validators which follow are given the database which the request's server keys belong to. Which validators follow, and in which order, is decided by the route the @@ -51,10 +57,19 @@ def run_services_validators[DatabaseT: AnyDatabase]( validate_header_lines_not_too_large(request_headers=request_headers) validate_auth_header_exists(request_headers=request_headers) validate_auth_header_has_signature(request_headers=request_headers) - validate_access_key_exists( + database_for_access_key = validate_access_key_exists( request_headers=request_headers, databases=databases, ) + route = match_route( + request_path=request_path, + request_method=request_method, + ) + if isinstance(database_for_access_key, CloudDatabase): + request_rate_limiter.validate( + database=database_for_access_key, + endpoint=route.rate_limited_endpoint, + ) database = validate_authorization( request_headers=request_headers, request_body=request_body, @@ -63,19 +78,13 @@ def run_services_validators[DatabaseT: AnyDatabase]( databases=databases, ) - route = match_route( - request_path=request_path, - request_method=request_method, - ) context = ValidatorContext( request_path=request_path, request_headers=request_headers, request_body=request_body, database=database, - request_rate_limiter=request_rate_limiter, mandatory_keys=route.mandatory_keys, optional_keys=route.optional_keys, - rate_limited_endpoint=route.rate_limited_endpoint, allowed_for_inactive_cloud_project=( route.allowed_for_inactive_cloud_project ), diff --git a/src/mock_vws/_services_validators/auth_validators.py b/src/mock_vws/_services_validators/auth_validators.py index ccc86d927..f979c9183 100644 --- a/src/mock_vws/_services_validators/auth_validators.py +++ b/src/mock_vws/_services_validators/auth_validators.py @@ -35,11 +35,11 @@ def validate_auth_header_exists(*, request_headers: Mapping[str, str]) -> None: @beartype -def validate_access_key_exists( +def validate_access_key_exists[DatabaseT: AnyDatabase]( *, request_headers: Mapping[str, str], - databases: Iterable[AnyDatabase], -) -> None: + databases: Iterable[DatabaseT], +) -> DatabaseT: """Validate the authorization header includes an access key for a database. @@ -47,6 +47,11 @@ def validate_access_key_exists( request_headers: The headers sent with the request. databases: All Vuforia databases. + Returns: + The database whose server access key the header names. The header's + signature has not been checked, so the request is not yet known to + be authorized for that database. + Raises: FailError: The access key does not match a given database. """ @@ -55,7 +60,7 @@ def validate_access_key_exists( _, access_key = first_part.split(sep=" ") for database in databases: if access_key == database.server_access_key: - return + return database _LOGGER.warning( 'The access key "%s" does not match a known database.', diff --git a/src/mock_vws/_services_validators/context.py b/src/mock_vws/_services_validators/context.py index 8f85bd760..86f43c452 100644 --- a/src/mock_vws/_services_validators/context.py +++ b/src/mock_vws/_services_validators/context.py @@ -10,9 +10,6 @@ from mock_vws._base64_decoding import decode_base64 from mock_vws._database_matchers import AnyDatabase -from mock_vws.request_rate_limits import RateLimitedEndpoint - -from .request_rate_limiter import RequestRateLimiter @beartype @@ -44,11 +41,8 @@ class ValidatorContext: request_headers: The headers sent with the request. request_body: The body of the request. database: The database which the request's server keys belong to. - request_rate_limiter: The rate limiter tracking recent requests. mandatory_keys: Keys which the route requires in the request body. optional_keys: Keys which the route allows in the request body. - rate_limited_endpoint: The group of endpoints which the route shares - a request rate limit with. allowed_for_inactive_cloud_project: Whether the route works against an inactive cloud database. @@ -57,11 +51,8 @@ class ValidatorContext: request_headers: The headers sent with the request. request_body: The body of the request. database: The database which the request's server keys belong to. - request_rate_limiter: The rate limiter tracking recent requests. mandatory_keys: Keys which the route requires in the request body. optional_keys: Keys which the route allows in the request body. - rate_limited_endpoint: The group of endpoints which the route shares - a request rate limit with. allowed_for_inactive_cloud_project: Whether the route works against an inactive cloud database. """ @@ -70,10 +61,8 @@ class ValidatorContext: request_headers: Mapping[str, str] request_body: bytes database: AnyDatabase - request_rate_limiter: RequestRateLimiter mandatory_keys: frozenset[str] optional_keys: frozenset[str] - rate_limited_endpoint: RateLimitedEndpoint allowed_for_inactive_cloud_project: bool @cached_property diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py index 3d1864faf..eafa9ad70 100644 --- a/src/mock_vws/_services_validators/exceptions.py +++ b/src/mock_vws/_services_validators/exceptions.py @@ -100,19 +100,28 @@ def __init__(self) -> None: @beartype class TooManyRequestsError(ValidatorError): - """Exception raised when a database exceeds its request rate limit.""" + """Exception raised when a database exceeds its request rate limit. + + Real Vuforia's Envoy layer applies the rate limits, before the request + reaches the application, and its response has no body and no + ``Content-Type`` header. This was observed on 2026-09-08. + """ def __init__(self) -> None: - """Initialize a ``TooManyRequests`` response.""" + """Initialize a ``429 Too Many Requests`` response.""" super().__init__() self.status_code = HTTPStatus.TOO_MANY_REQUESTS - self.response_text = result_code_response_text( - result_code=ResultCodes.TOO_MANY_REQUESTS, - ) + self.response_text = "" self.headers = { - **_STANDARD_HEADERS, + "Connection": "keep-alive", + "Content-Length": "0", "Date": http_date(), - "Content-Length": str(object=len(self.response_text)), + "server": "envoy", + "strict-transport-security": "max-age=31536000", + "x-aws-region": "us-east-2, us-west-2", + "x-content-type-options": "nosniff", + "x-envoy-ratelimited": "true", + "x-envoy-upstream-service-time": "5", } diff --git a/src/mock_vws/_services_validators/request_rate_validators.py b/src/mock_vws/_services_validators/request_rate_validators.py deleted file mode 100644 index 9c34d6e3a..000000000 --- a/src/mock_vws/_services_validators/request_rate_validators.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Validators for the VWS request rates.""" - -from beartype import beartype - -from mock_vws.database import CloudDatabase - -from .context import ValidatorContext - - -@beartype -def validate_request_rate(*, context: ValidatorContext) -> None: - """Apply the configured request rates to the matching cloud - database. - - Args: - context: The context of the request. - """ - if isinstance(context.database, CloudDatabase): - context.request_rate_limiter.validate( - database=context.database, - endpoint=context.rate_limited_endpoint, - ) diff --git a/src/mock_vws/_services_validators/routes.py b/src/mock_vws/_services_validators/routes.py index 8d8302eb7..5ae91e545 100644 --- a/src/mock_vws/_services_validators/routes.py +++ b/src/mock_vws/_services_validators/routes.py @@ -65,7 +65,6 @@ ) from .project_state_validators import validate_project_state from .request_quota_validators import validate_request_quota -from .request_rate_validators import validate_request_rate from .target_quota_validators import validate_target_quota from .target_validators import validate_target_id_exists from .width_validators import validate_width @@ -128,11 +127,11 @@ class Route: validators: Sequence[Validator] -# Every route is quota checked, rate limited and refused when the project is -# in a state which does not allow it. +# Every route is quota checked and refused when the project is in a state +# which does not allow it. The request rate limits are applied before any +# route validator, by ``run_services_validators``. _PROJECT_VALIDATORS: Sequence[Validator] = ( validate_request_quota, - validate_request_rate, validate_project_state, ) diff --git a/src/mock_vws/database.py b/src/mock_vws/database.py index 3deb97f4a..bcb67aae2 100644 --- a/src/mock_vws/database.py +++ b/src/mock_vws/database.py @@ -100,7 +100,7 @@ class CloudDatabase: whatever it is set to. requests_per_second_limit: The maximum number of VWS requests accepted in a rolling one-second window, across all VWS endpoints. Set this - to ``0`` to make VWS endpoints return ``TooManyRequests``. By + to ``0`` to make VWS endpoints return a ``429`` response. By default, the mock does not apply this limit. request_rate_limits: Request rate limits which apply to individual groups of VWS endpoints, tracked separately from each other and diff --git a/src/mock_vws/request_rate_limits.py b/src/mock_vws/request_rate_limits.py index 16feb62a6..ae5eb144f 100644 --- a/src/mock_vws/request_rate_limits.py +++ b/src/mock_vws/request_rate_limits.py @@ -170,10 +170,16 @@ def from_dict(cls, limits_dict: RequestRateLimitsDict) -> Self: other=RequestRateLimit(max_requests=15, window_seconds=1.0), get_target=RequestRateLimit(max_requests=45, window_seconds=1.0), get_duplicates=RequestRateLimit(max_requests=10, window_seconds=1.0), - list_targets=RequestRateLimit(max_requests=1, window_seconds=60.0), + list_targets=RequestRateLimit(max_requests=2, window_seconds=60.0), ) -"""The request rate limits documented by Vuforia. +"""The request rate limits which Vuforia documents, corrected by +observation. -These limits have not been verified against the real Vuforia Web Services, -and so they are not applied by default. +Vuforia documents 15 requests per second for VWS endpoints in general, 45 per +second for ``GET /targets/{target_id}``, 10 per second for +``GET /duplicates/{target_id}`` and one per minute for ``GET /targets``. +Real Vuforia was observed on 2026-09-08 to accept two ``GET /targets`` +requests per minute, not one, so that is the limit here. + +These limits are not applied by default. """ diff --git a/tests/mock_vws/test_requests_mock_usage.py b/tests/mock_vws/test_requests_mock_usage.py index 3e95039bc..01fb9090a 100644 --- a/tests/mock_vws/test_requests_mock_usage.py +++ b/tests/mock_vws/test_requests_mock_usage.py @@ -21,6 +21,7 @@ from PIL import Image from vws import VWS, CloudRecoService, VuMarkService from vws.exceptions.vws_exceptions import ( + AuthenticationFailureError, ProjectSuspendedError, RequestQuotaReachedError, TargetQuotaReachedError, @@ -52,7 +53,10 @@ from mock_vws.target import ImageTarget, VuMarkTarget from mock_vws.target_raters import HardcodedTargetTrackingRater from tests.mock_vws.utils import Endpoint -from tests.mock_vws.utils.assertions import assert_vws_failure +from tests.mock_vws.utils.assertions import ( + assert_vws_failure, + assert_vws_too_many_requests, +) from tests.mock_vws.utils.usage_test_helpers import ( processing_time_seconds, ) @@ -479,12 +483,35 @@ def test_zero_limit() -> None: ) as exc_info: client.list_targets() - assert_vws_failure( - response=exc_info.value.response, - status_code=HTTPStatus.TOO_MANY_REQUESTS, - result_code=ResultCodes.TOO_MANY_REQUESTS, + assert_vws_too_many_requests(response=exc_info.value.response) + + @staticmethod + def test_limit_applies_before_authentication() -> None: + """The limit is keyed on the access key and applied before the + signature is checked, as real Vuforia's Envoy layer does. + + A request with a bad signature uses up the budget, and a request over + the limit is rejected as rate limited rather than as unauthorized. + """ + database = CloudDatabase(requests_per_second_limit=1) + client_with_bad_secret = VWS( + server_access_key=database.server_access_key, + server_secret_key=uuid.uuid4().hex, + ) + client = VWS( + server_access_key=database.server_access_key, + server_secret_key=database.server_secret_key, ) + with MockVWS() as mock: + mock.add_cloud_database(cloud_database=database) + with pytest.raises(expected_exception=AuthenticationFailureError): + client_with_bad_secret.list_targets() + with pytest.raises(expected_exception=TooManyRequestsError): + client_with_bad_secret.list_targets() + with pytest.raises(expected_exception=TooManyRequestsError): + client.list_targets() + @staticmethod def test_rolling_window() -> None: """Requests are accepted again after the rolling window passes.""" @@ -672,7 +699,8 @@ def test_documented_limits() -> None: with MockVWS() as mock: mock.add_cloud_database(cloud_database=database) - # ``GET /targets`` is limited to one request per minute. + # ``GET /targets`` is limited to two requests per minute. + client.list_targets() client.list_targets() with pytest.raises( expected_exception=TooManyRequestsError, @@ -682,11 +710,7 @@ def test_documented_limits() -> None: # Other endpoints have their own budgets. client.get_database_summary_report() - assert_vws_failure( - response=exc_info.value.response, - status_code=HTTPStatus.TOO_MANY_REQUESTS, - result_code=ResultCodes.TOO_MANY_REQUESTS, - ) + assert_vws_too_many_requests(response=exc_info.value.response) @staticmethod def test_get_target_and_duplicates_limits( diff --git a/tests/mock_vws/test_target_list.py b/tests/mock_vws/test_target_list.py index 47f7935e7..a8b0dcf22 100644 --- a/tests/mock_vws/test_target_list.py +++ b/tests/mock_vws/test_target_list.py @@ -1,12 +1,25 @@ """Tests for the mock of the target list endpoint.""" import io +import os import uuid +from http import HTTPMethod, HTTPStatus import pytest +import requests from vws import VWS +from vws.response import Response +from vws_auth_tools import authorization_header, rfc_1123_date -from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend +from mock_vws._constants import ResultCodes +from mock_vws.database import CloudDatabase +from mock_vws.request_rate_limits import DOCUMENTED_REQUEST_RATE_LIMITS +from tests.mock_vws.fixtures.vuforia_backends import ( + VuforiaBackend, + running_in_memory_mock, +) +from tests.mock_vws.utils import Endpoint +from tests.mock_vws.utils.assertions import assert_vws_too_many_requests @pytest.mark.usefixtures("verify_mock_vuforia") @@ -70,3 +83,100 @@ def test_inactive_project(inactive_vws_client: VWS) -> None: """The project's active state does not affect the target list.""" # No exception is raised. inactive_vws_client.list_targets() + + +@pytest.fixture(name="rate_limited_database") +def fixture_rate_limited_database( + verify_mock_vuforia: VuforiaBackend, + vuforia_database: CloudDatabase, +) -> CloudDatabase: + """Return a database which applies real Vuforia's request rate limits. + + The real database applies them itself. Each mock is given a fresh + database with the limits, so that the shared database which the other + tests use is not limited. + """ + match verify_mock_vuforia: + case VuforiaBackend.REAL: + return vuforia_database + case VuforiaBackend.MOCK: + database = CloudDatabase( + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, + ) + running_in_memory_mock().add_cloud_database( + cloud_database=database, + ) + return database + case VuforiaBackend.FLASK_IN_PROCESS: + database = CloudDatabase( + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, + ) + target_manager_base_url = os.environ["TARGET_MANAGER_BASE_URL"] + response = requests.post( + url=f"{target_manager_base_url}/cloud_databases", + json=database.to_dict(), + timeout=30, + ) + response.raise_for_status() + return database + + +@pytest.mark.usefixtures("verify_mock_vuforia") +class TestRateLimit: + """Tests for the request rate limit of the target list endpoint.""" + + @staticmethod + def test_two_requests_per_minute( + *, + verify_mock_vuforia: VuforiaBackend, + rate_limited_database: CloudDatabase, + ) -> None: + """A third ``GET /targets`` request within a minute is rate + limited, + with the empty response which Envoy gives. + + Real Vuforia's window is a fixed clock minute, and the fixture which + empties the database before each test has already listed the + targets, so the real backend may reject the first, second or third + request. The mocks use a rolling window on a fresh database, so they + reject exactly the third. + """ + limit = DOCUMENTED_REQUEST_RATE_LIMITS.list_targets + assert limit is not None + responses: list[Response] = [] + for _ in range(5): + date = rfc_1123_date() + authorization = authorization_header( + access_key=rate_limited_database.server_access_key, + secret_key=rate_limited_database.server_secret_key, + method=HTTPMethod.GET, + content=b"", + content_type="", + date=date, + request_path="/targets", + ) + endpoint = Endpoint( + base_url="https://vws.vuforia.com", + path_url="/targets", + method=HTTPMethod.GET, + headers={"Authorization": authorization, "Date": date}, + data=b"", + successful_headers_result_code=ResultCodes.SUCCESS, + successful_headers_status_code=HTTPStatus.OK, + access_key=rate_limited_database.server_access_key, + secret_key=rate_limited_database.server_secret_key, + ) + response = endpoint.send() + responses.append(response) + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + break + + status_codes = [response.status_code for response in responses] + if verify_mock_vuforia != VuforiaBackend.REAL: + assert status_codes == [ + *[HTTPStatus.OK] * limit.max_requests, + HTTPStatus.TOO_MANY_REQUESTS, + ] + assert status_codes[-1] == HTTPStatus.TOO_MANY_REQUESTS + assert status_codes.count(HTTPStatus.OK) <= limit.max_requests + assert_vws_too_many_requests(response=responses[-1]) diff --git a/tests/mock_vws/utils/assertions.py b/tests/mock_vws/utils/assertions.py index 6c2fc4f22..bd39ba753 100644 --- a/tests/mock_vws/utils/assertions.py +++ b/tests/mock_vws/utils/assertions.py @@ -48,6 +48,48 @@ def assert_vws_failure( ) +@beartype +def assert_vws_too_many_requests(*, response: Response) -> None: + """Assert that a response is the rate-limited response which real + Vuforia's Envoy layer gives. + + The response has no body and no ``Content-Type`` header. Real Vuforia + has an Envoy layer at its edge and another in front of the application, + and either may reject the request. Only a rejection by the inner layer + carries an ``x-envoy-upstream-service-time`` header. This was observed + against real Vuforia on 2026-09-08. + + Args: + response: The response returned by a request to VWS. + + Raises: + AssertionError: The response is not the rate-limited response. + """ + assert response.status_code == HTTPStatus.TOO_MANY_REQUESTS + assert response.text == "" + required_header_keys = { + "connection", + "content-length", + "date", + "server", + "strict-transport-security", + "x-aws-region", + "x-content-type-options", + "x-envoy-ratelimited", + } + optional_header_keys = {"x-envoy-upstream-service-time"} + response_header_keys = {str.lower(key) for key in response.headers} + assert required_header_keys <= response_header_keys + assert response_header_keys <= required_header_keys | optional_header_keys + assert response.headers["Content-Length"] == "0" + assert response.headers["server"] == "envoy" + assert response.headers["x-envoy-ratelimited"] == "true" + assert response.headers["x-content-type-options"] == "nosniff" + assert "-" in response.headers["x-aws-region"] + assert response.headers["strict-transport-security"] == "max-age=31536000" + assert_valid_date_header(response=response) + + @beartype def assert_valid_date_header( *, From e081f62d7be80385780d4e4fa018a22a0b5533a4 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Tue, 8 Sep 2026 12:52:34 +0100 Subject: [PATCH 2/2] Take every branch of the rate limit test The fixture's match statement had no fall-through arm and the request loop always broke out, so branch coverage saw two arcs never taken. Co-Authored-By: Claude Fable 5.1 --- tests/mock_vws/test_target_list.py | 47 +++++++++++++++--------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/mock_vws/test_target_list.py b/tests/mock_vws/test_target_list.py index 88390f70c..ec098cd8d 100644 --- a/tests/mock_vws/test_target_list.py +++ b/tests/mock_vws/test_target_list.py @@ -96,29 +96,24 @@ def fixture_rate_limited_database( database with the limits, so that the shared database which the other tests use is not limited. """ - match verify_mock_vuforia: - case VuforiaBackend.REAL: - return vuforia_database - case VuforiaBackend.MOCK: - database = CloudDatabase( - request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, - ) - running_in_memory_mock().add_cloud_database( - cloud_database=database, - ) - return database - case VuforiaBackend.FLASK_IN_PROCESS: - database = CloudDatabase( - request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS, - ) - target_manager_base_url = os.environ["TARGET_MANAGER_BASE_URL"] - response = requests.post( - url=f"{target_manager_base_url}/cloud_databases", - json=database.to_dict(), - timeout=30, - ) - response.raise_for_status() - return database + if verify_mock_vuforia == VuforiaBackend.REAL: + return vuforia_database + + database = CloudDatabase( + request_rate_limits=DOCUMENTED_REQUEST_RATE_LIMITS + ) + if verify_mock_vuforia == VuforiaBackend.MOCK: + running_in_memory_mock().add_cloud_database(cloud_database=database) + return database + + target_manager_base_url = os.environ["TARGET_MANAGER_BASE_URL"] + response = requests.post( + url=f"{target_manager_base_url}/cloud_databases", + json=database.to_dict(), + timeout=30, + ) + response.raise_for_status() + return database @pytest.mark.usefixtures("verify_mock_vuforia") @@ -144,7 +139,10 @@ def test_two_requests_per_minute( limit = DOCUMENTED_REQUEST_RATE_LIMITS.list_targets assert limit is not None responses: list[Response] = [] - for _ in range(5): + # Real Vuforia's fixed window means the rejection may come on any + # of the first three requests, so requests are sent until one is + # rejected, with a cap well above the limit. + while True: date = rfc_1123_date() authorization = authorization_header( access_key=rate_limited_database.server_access_key, @@ -170,6 +168,7 @@ def test_two_requests_per_minute( responses.append(response) if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: break + assert len(responses) <= limit.max_requests + 2 status_codes = [response.status_code for response in responses] if verify_mock_vuforia != VuforiaBackend.REAL: