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
32 changes: 23 additions & 9 deletions docs/source/differences-to-vws.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 2 additions & 14 deletions docs/source/unverified-behavior.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions newsfragments/3572.change
Original file line number Diff line number Diff line 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.
4 changes: 2 additions & 2 deletions src/mock_vws/_flask_server/target_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 16 additions & 7 deletions src/mock_vws/_services_validators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
),
Expand Down
13 changes: 9 additions & 4 deletions src/mock_vws/_services_validators/auth_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,23 @@ 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.

Args:
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.
"""
Expand All @@ -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.',
Expand Down
11 changes: 0 additions & 11 deletions src/mock_vws/_services_validators/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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.
"""
Expand All @@ -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
Expand Down
23 changes: 16 additions & 7 deletions src/mock_vws/_services_validators/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
22 changes: 0 additions & 22 deletions src/mock_vws/_services_validators/request_rate_validators.py

This file was deleted.

7 changes: 3 additions & 4 deletions src/mock_vws/_services_validators/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down
2 changes: 1 addition & 1 deletion src/mock_vws/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions src/mock_vws/request_rate_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Loading