Skip to content

Commit 704ad32

Browse files
Merge branch 'main' into checker-specific-ignores
2 parents 5b6ac6b + 7ec1f62 commit 704ad32

26 files changed

Lines changed: 1173 additions & 219 deletions

CHANGELOG.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@ Changelog
33

44
.. towncrier release notes start
55
6+
2026.09.08.1
7+
------------
8+
9+
- The URL of a reco counts report now has the shape of the presigned cloud storage URL which real Vuforia returns.
10+
The report file is named ``{date}-{hour}.csv`` for the current month and ``{month}.csv`` for the previous month, so two requests for the same month in the same hour name the same file and serve the report which the first request generated.
11+
The URL carries the query parameters of a presigned URL, and the mock honors ``X-Amz-Date`` and ``X-Amz-Expires`` as real Vuforia's storage does, so a URL which is out of date or which has no query parameters gives the ``403`` XML error document which Amazon S3 gives.
12+
A report which is not yet generated gives the ``404`` ``NoSuchKey`` XML error document which Amazon S3 gives, as real Vuforia has now been observed to do, rather than an empty body.
13+
14+
2026.09.08
15+
----------
16+
17+
- The response delay and client timeout simulation is now provided by the ``mock-response-delay`` package.
18+
19+
- The mock now returns NGINX's ``400 Request Header Or Cookie Too Large`` response for any request header line longer than 8190 bytes, as real Vuforia does.
20+
21+
- Match real Vuforia's request rate limiting, which was checked against it on 2026-09-08.
22+
``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.
23+
624
2026.09.06
725
----------
826

docs/source/differences-to-vws.rst

Lines changed: 80 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -184,16 +184,30 @@ endpoints in general, with 45 requests per second for
184184
``GET /targets/{target_id}``, 10 requests per second for
185185
``GET /duplicates/{target_id}``, and 1 request per minute for ``GET /targets``.
186186

187-
The mock models these limits separately for each group of endpoints, but it applies no limit by default.
188-
Applying a limit of 1 request per minute to ``GET /targets`` by default would break the tests of anything which uses the mock.
189-
190-
.. admonition:: Unverified assumption
191-
192-
:ref:`unverified-request-rate-limits`
187+
The limits were checked against real Vuforia on 2026-09-08, by sending bursts of requests to read-only endpoints:
188+
189+
* ``GET /targets`` accepts two requests per minute, not one.
190+
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.
191+
* The per-second limits are enforced roughly, not exactly.
192+
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.
193+
* A limit is keyed on the server access key in the ``Authorization`` header, so one database's burst does not affect another database.
194+
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.
195+
Requests without an ``Authorization`` header are not rate limited.
196+
* 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.
197+
Vuforia has an Envoy layer at its edge and another in front of the application, and either may reject the request.
198+
Only a rejection by the inner layer carries an ``x-envoy-upstream-service-time`` header, which the mock always includes.
199+
The ``TooManyRequests`` result code from Vuforia's result codes table does not appear.
200+
201+
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.
202+
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.
203+
The mock only limits requests whose access key belongs to a database, because the limits are configured on the database.
204+
205+
The mock applies no limit by default.
206+
Applying a limit of two requests per minute to ``GET /targets`` by default would break the tests of anything which uses the mock.
193207

194208
Set ``request_rate_limits`` to
195209
:data:`mock_vws.request_rate_limits.DOCUMENTED_REQUEST_RATE_LIMITS` to apply
196-
the documented limits::
210+
the limits which real Vuforia applies::
197211

198212
from mock_vws import MockVWS
199213
from mock_vws.database import CloudDatabase
@@ -205,8 +219,8 @@ the documented limits::
205219

206220
with MockVWS() as mock:
207221
mock.add_cloud_database(cloud_database=database)
208-
# A second ``GET /targets`` request within a minute returns
209-
# ``TooManyRequests``.
222+
# A third ``GET /targets`` request within a minute gets a ``429``
223+
# response.
210224
...
211225

212226
``requests_per_second_limit`` remains available. It applies one limit to all
@@ -457,35 +471,70 @@ As real Vuforia does, the mock returns a 401 response with the
457471
server keys but which names any other database, including one named by its
458472
name rather than by its ID.
459473

460-
Real Vuforia returns a presigned URL for cloud storage.
461-
The mock returns a URL served by the mock itself, without the query
462-
parameters of a presigned URL, so the mock's URL never expires where a real
463-
one expires after just under seven days.
474+
Real Vuforia returns a presigned URL for cloud storage, of this form:
475+
476+
.. code-block:: text
477+
478+
https://guacamole-targetstore-production-targets.s3.us-west-1.amazonaws.com/reports/{database_id}/{file_name}.csv
479+
?X-Amz-Security-Token=...
480+
&X-Amz-Algorithm=AWS4-HMAC-SHA256
481+
&X-Amz-Date=20260808T210052Z
482+
&X-Amz-SignedHeaders=host
483+
&X-Amz-Credential=.../20260808/us-west-1/s3/aws4_request
484+
&X-Amz-Expires=604799
485+
&X-Amz-Signature=...
486+
487+
The mock returns a URL with the same path and the same query parameters,
488+
served by the mock itself rather than by cloud storage.
464489
The URL returned by the Flask and Docker mock is built from the
465490
:envvar:`VWS_BASE_URL` environment variable.
466-
The report takes :paramref:`~mock_vws.MockVWS.processing_time_seconds`
467-
seconds to generate in the mock.
468-
The documentation says a real report takes between a few seconds and one
469-
hour, but a report for a database with no recognitions has been observed
470-
ready within seconds.
491+
The credential, the security token and the signature are placeholders of
492+
the right shape.
493+
The mock does not check the signature, so a URL whose signature or file name
494+
has been changed, which real Vuforia refuses with a ``SignatureDoesNotMatch``
495+
error document, is served by the mock as if it were signed.
471496

472497
Real Vuforia names the report file after the requested month, and does so
473498
differently for each of the two months it accepts.
474-
A report for the current month is named for the date and the hour, such as
499+
A report for the current month is named for the UTC date and hour, such as
475500
``2026-08-08-21.csv``, and a report for the previous month is named for the
476501
month, such as ``2026-07.csv``.
477-
The mock names every report after an opaque report identifier, so the
478-
requested month cannot be recovered from the mock's URL, and two requests for
479-
the same month never give the same URL.
480-
481-
The mock's URL returns a 404 response until the report is ready, and requires
482-
no authorization.
483-
The lack of authorization matches real Vuforia, whose URL carries its own
484-
signature.
485-
486-
.. admonition:: Unverified assumption
487-
488-
:ref:`unverified-reco-counts-report-not-ready`
502+
The mock does the same, so two requests for the same month in the same hour
503+
name the same file, and two requests for the previous month always do.
504+
Real Vuforia does not generate the report again for such a request: the URL
505+
which the second request returns serves the file which the first request
506+
generated, unchanged.
507+
The mock does the same, so recognition counts set between the two requests
508+
are not in the report which the second URL serves.
509+
510+
The URL expires ``X-Amz-Expires`` seconds after its ``X-Amz-Date``, which
511+
is one second under seven days.
512+
Real Vuforia's storage checks that the URL is in date before it checks the
513+
signature, so a URL whose ``X-Amz-Date`` or ``X-Amz-Expires`` has been
514+
edited to put it out of date gives the same 403 response as a URL which has
515+
expired, even though the edit invalidates the signature.
516+
The mock honors those two parameters in the same way, so code which handles
517+
a stale URL can be tested by editing them.
518+
The 403 response is the XML ``AccessDenied`` error document which Amazon S3
519+
gives, with a ``Request has expired`` message, the expiry time and the
520+
server time.
521+
A URL without those parameters gives the ``AccessDenied`` error document
522+
with an ``Access Denied`` message, as it does on real Vuforia.
523+
524+
Until the report is ready, the URL gives a 404 response with the XML
525+
``NoSuchKey`` error document which Amazon S3 gives, naming the file's key.
526+
The mock does the same, and the mock gives the same response for a file
527+
which no request generated.
528+
The mock's error documents carry random request identifiers where Amazon's
529+
carry its own.
530+
The report takes :paramref:`~mock_vws.MockVWS.processing_time_seconds`
531+
seconds to generate in the mock.
532+
The documentation says a real report takes between a few seconds and one
533+
hour, but a real report has been observed ready within a second of the
534+
request, and the 404 response has been observed by fetching the URL straight
535+
after the request.
536+
The download requires no authorization beyond the query parameters of the
537+
URL, as on real Vuforia.
489538

490539
Paths which the mock does not serve
491540
-----------------------------------

docs/source/unverified-behavior.rst

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,6 @@ The status code and the body shape come from Vuforia's documentation and from th
3838
A real database with an exhausted request quota would verify this.
3939
No such response has been seen.
4040

41-
.. _unverified-request-rate-limits:
42-
43-
Request rate limits
44-
-------------------
45-
46-
:Category: never-attempted
47-
:API: VWS Target API
48-
49-
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``.
50-
The mock models the limits separately for each group of endpoints, and applies them only when it is asked to.
51-
52-
Sending more than the documented number of requests to a real database, and seeing what it returns, would verify this.
53-
5441
.. _unverified-project-suspended:
5542

5643
A suspended database
@@ -79,8 +66,9 @@ Additional result codes
7966
:Category: never-attempted
8067
:API: VWS Target API
8168

82-
``ProjectHasNoApiAccess``, ``TargetQuotaReached`` and ``TooManyRequests`` come from Vuforia's result codes table.
69+
``ProjectHasNoApiAccess`` and ``TargetQuotaReached`` come from Vuforia's result codes table.
8370
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.
71+
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.
8472

8573
A database put into each state by the Target Manager portal would verify these.
8674

@@ -97,19 +85,6 @@ The mock does not implement this, so a user of the mock sees a successful respon
9785

9886
A database with more than a million images would verify this, which a test account cannot hold.
9987

100-
.. _unverified-reco-counts-report-not-ready:
101-
102-
A reco counts report which is not ready
103-
---------------------------------------
104-
105-
:Category: never-attempted
106-
:API: Reco Counts Report API
107-
108-
The URL which the mock returns for a reco counts report gives a 404 response until the report is ready.
109-
110-
A request for a real report which caught it before it was generated would verify this.
111-
Every real report requested so far has been ready by the time it was asked for.
112-
11388
.. _unverified-reco-counts-report-row-order:
11489

11590
The order of reco counts report rows

newsfragments/3415.change

Lines changed: 0 additions & 1 deletion
This file was deleted.

newsfragments/3571.change

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/mock_vws/_flask_server/target_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,8 @@ def create_cloud_database() -> Response:
464464
465465
:reqjson int requests_per_second_limit: (Optional) The maximum number of
466466
VWS requests accepted in a rolling one-second window, across all VWS
467-
endpoints. Set this to zero to make VWS endpoints return
468-
``TooManyRequests``.
467+
endpoints. Set this to zero to make VWS endpoints return a ``429``
468+
response.
469469
470470
:reqjson request_rate_limits: (Optional) Request rate limits for
471471
individual groups of VWS endpoints. This is an object with the optional

src/mock_vws/_flask_server/vws.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ def __init__(self) -> None:
286286

287287
@property
288288
def reco_counts_reports(self) -> dict[str, RecoCountsReport]:
289-
"""All reco counts reports, keyed by report identifier."""
289+
"""All reco counts reports, keyed by report file path."""
290290
with self._lock:
291291
return dict(self._reports)
292292

@@ -299,7 +299,7 @@ def add_reco_counts_report(
299299
) -> None:
300300
"""Add a reco counts report."""
301301
with self._lock:
302-
self._reports[reco_counts_report.uuid_] = reco_counts_report
302+
self._reports[reco_counts_report.key] = reco_counts_report
303303

304304

305305
_RECO_COUNTS_REPORT_STORE = _InMemoryRecoCountsReportStore()
@@ -364,7 +364,7 @@ def validate_request() -> None:
364364
if (
365365
request.path.startswith("/oauth2/")
366366
or request.path.startswith("/modeltargets/")
367-
or request.path.startswith("/reports/recoCounts/")
367+
or request.path.startswith("/reports/")
368368
):
369369
return
370370
_ = run_services_validators(
@@ -707,20 +707,25 @@ def reco_counts_report(database_id: str) -> Response:
707707

708708

709709
@VWS_FLASK_APP.route(
710-
rule="/reports/recoCounts/<string:report_id>",
710+
rule="/reports/<string:database_id>/<string:file_name>",
711711
methods=[HTTPMethod.GET],
712712
)
713713
@beartype
714-
def download_reco_counts_report(report_id: str) -> Response:
714+
def download_reco_counts_report(database_id: str, file_name: str) -> Response:
715715
"""Download a generated reco counts report.
716716
717717
This stands in for the presigned URL which real Vuforia returns, so it
718-
does not require any authorization.
718+
does not require any authorization beyond the query parameters of that
719+
URL.
719720
"""
721+
# The report is looked up by the whole path, which names the database
722+
# and the file.
723+
del database_id
724+
del file_name
720725
return _to_flask_response(
721726
api_response=download_report(
722727
report_store=_RECO_COUNTS_REPORT_STORE,
723-
report_id=report_id,
728+
request_path=request.full_path,
724729
),
725730
)
726731

src/mock_vws/_mock_common.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,14 @@
1919
RECO_COUNTS_REPORT_PATH_PATTERN = (
2020
f"/imagetargets/databases/{DATABASE_ID_PATTERN}/reports/recoCounts"
2121
)
22-
# The path which stands in for a reco counts report presigned URL.
23-
RECO_COUNTS_DOWNLOAD_PATH_PATTERN = "/reports/recoCounts/[A-Za-z0-9]+"
22+
# The path which stands in for a reco counts report presigned URL, with the
23+
# query string of that URL.
24+
# Any file name is matched, so that a file which no request generated gives
25+
# the response which cloud storage gives for a missing object rather than
26+
# leaving the request unmatched.
27+
RECO_COUNTS_DOWNLOAD_PATH_PATTERN = (
28+
f"/reports/{DATABASE_ID_PATTERN}/[^/?]+(\\?.*)?"
29+
)
2430

2531

2632
@beartype

0 commit comments

Comments
 (0)