diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst index 10d25c6fd..8417ef3f7 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 df6e60067..3d92bda1f 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-project-suspended: A suspended database @@ -79,8 +66,9 @@ Additional result codes :Category: never-attempted :API: VWS Target API -``ProjectHasNoApiAccess``, ``TargetQuotaReached`` and ``TooManyRequests`` come from Vuforia's result codes table. +``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 5b3b100f3..e9aaca141 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 931a53ed8..309001660 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 62d9aafba..dfaa90a90 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 e25c3dc76..d94390c97 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 4e3a86750..ca6d1358c 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 35ab0698a..42ee089bb 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. + _targets = client.list_targets() _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. _summary = 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 ffeb6b4be..ec098cd8d 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,99 @@ 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. + """ + 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") +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] = [] + # 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, + 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 + assert len(responses) <= limit.max_requests + 2 + + 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 db98e9769..e5b12dc02 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( *,