diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d94888a88..2290e4675 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -65,6 +65,9 @@ jobs:
- tests/mock_vws/test_content_length.py::TestIncorrect::test_not_integer
- tests/mock_vws/test_content_length.py::TestIncorrect::test_too_large
- tests/mock_vws/test_content_length.py::TestIncorrect::test_too_small
+ - tests/mock_vws/test_header_size.py::TestOversizedHeaderLine::test_header_too_large
+ - tests/mock_vws/test_header_size.py::TestOversizedHeaderLine::test_cookie_too_large
+ - tests/mock_vws/test_header_size.py::TestOversizedHeaderLine::test_large_header_within_limit
- tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_success
- tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_active_images
- tests/mock_vws/test_database_summary.py::TestDatabaseSummary::test_failed_images
diff --git a/docs/source/differences-to-vws.rst b/docs/source/differences-to-vws.rst
index 68beaaace..c2a2e2169 100644
--- a/docs/source/differences-to-vws.rst
+++ b/docs/source/differences-to-vws.rst
@@ -134,16 +134,26 @@ The mock uses the fixed sample value ``us-east-2, us-west-2`` for
``x-aws-region`` response headers. The regions returned by the real Vuforia
Web Services can differ, so tests should not rely on the mock's exact value.
+.. _differences-nginx-error-cases:
+
NGINX Error cases
-----------------
-Vuforia uses NGINX.
-This has error handling which is not duplicated in the mock.
-For example, Vuforia is documented as returning a 400 (``BAD REQUEST``) response if a header or cookie is given which is larger than 8 KiB.
-
-.. admonition:: Unverified assumption
-
- :ref:`unverified-nginx-oversized-header-or-cookie`
+Vuforia uses NGINX in front of both the Target API and the Query API.
+NGINX reads each request header line into an 8 KiB buffer, and returns a 400 (``BAD REQUEST``) response with an HTML body titled ``400 Request Header Or Cookie Too Large`` for a line which does not fit.
+The line's terminating CRLF also counts towards the buffer, so the longest accepted line is 8190 bytes, where a line is the header name, a colon, a space and the value.
+This was observed against real Vuforia on 2026-09-08.
+
+The mock returns that response for any header line longer than 8190 bytes.
+The mock does not implement the following related behaviors, which were observed in the same session:
+
+* The Target API's Envoy layer lets a ``Cookie`` line slightly over the limit through.
+ A ``Cookie`` line of 8193 bytes was accepted and one of 8300 bytes was rejected.
+* The Target API's AWS load balancer rejects a header line of 16384 bytes or more itself, with a shorter HTML body and a ``Server: awselb/2.0`` header.
+ A ``Cookie`` line of that size passes the load balancer and is rejected by NGINX instead.
+* The Query API's application server rejects a request whose headers total about 8 KiB with a 431 (``REQUEST HEADER FIELDS TOO LARGE``) HTML response before the NGINX limit is reached.
+ With the headers which a query normally has, a header line of 7500 bytes was accepted and one of 8000 bytes was rejected this way.
+* The Model Target Web API, the OAuth2 token endpoint and reco counts report downloads in the mock do not apply the limit.
Result codes
------------
diff --git a/docs/source/unverified-behavior.rst b/docs/source/unverified-behavior.rst
index 79fc320bf..d4d4bbb0a 100644
--- a/docs/source/unverified-behavior.rst
+++ b/docs/source/unverified-behavior.rst
@@ -77,19 +77,6 @@ The mock does not implement this, so a user of the mock sees a successful respon
A database with more than a million images would verify this, which a test account cannot hold.
-.. _unverified-nginx-oversized-header-or-cookie:
-
-Large headers and cookies
--------------------------
-
-:Category: never-attempted
-:API: Cross-cutting request handling
-
-Vuforia runs behind NGINX, which is documented as returning a 400 (``BAD REQUEST``) response for a header or a cookie larger than 8 KiB.
-The mock does not implement this, and no test sends such a request to either.
-
-Sending a request with a header larger than 8 KiB to a real database would verify this.
-
.. _unverified-reco-counts-report-not-ready:
A reco counts report which is not ready
diff --git a/newsfragments/3571.change b/newsfragments/3571.change
new file mode 100644
index 000000000..c2c76aeff
--- /dev/null
+++ b/newsfragments/3571.change
@@ -0,0 +1 @@
+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.
diff --git a/src/mock_vws/_mock_common.py b/src/mock_vws/_mock_common.py
index 3e14af108..718f682e2 100644
--- a/src/mock_vws/_mock_common.py
+++ b/src/mock_vws/_mock_common.py
@@ -6,7 +6,7 @@
import uuid
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
-from typing import Any, override
+from typing import Any, Final, override
from beartype import beartype
@@ -144,3 +144,28 @@ def json_dump(*, body: dict[str, Any]) -> str:
JSON dump of data in the same way that Vuforia dumps data.
"""
return json.dumps(obj=body, separators=(",", ":"))
+
+
+# NGINX, which sits in front of both Vuforia APIs, reads each header line
+# into an 8 KiB buffer which also holds the line's terminating CRLF.
+# A line of 8190 bytes is accepted and a line of 8191 bytes is rejected.
+MAX_HEADER_LINE_LENGTH: Final[int] = 8190
+
+
+@beartype
+def has_oversized_header_line(*, request_headers: Mapping[str, str]) -> bool:
+ """Whether any header line is too long for NGINX's header buffer.
+
+ A header line is the header name, a colon, a space and the value, as
+ sent on the wire.
+
+ Args:
+ request_headers: The headers sent with the request.
+
+ Returns:
+ Whether any header line is longer than ``MAX_HEADER_LINE_LENGTH``.
+ """
+ return any(
+ len(f"{name}: {value}".encode()) > MAX_HEADER_LINE_LENGTH
+ for name, value in request_headers.items()
+ )
diff --git a/src/mock_vws/_query_validators/__init__.py b/src/mock_vws/_query_validators/__init__.py
index 454767494..6b8d9aeac 100644
--- a/src/mock_vws/_query_validators/__init__.py
+++ b/src/mock_vws/_query_validators/__init__.py
@@ -26,6 +26,7 @@
validate_date_in_range,
)
from .fields_validators import validate_extra_fields
+from .header_size_validators import validate_header_lines_not_too_large
from .image_validators import (
validate_image_dimensions,
validate_image_field_given,
@@ -49,6 +50,9 @@ def run_query_validators(
) -> None:
"""Run all validators.
+ NGINX rejects a request with an over-long header line before it reaches
+ Vuforia, so that is checked first.
+
Args:
request_path: The path of the request.
request_headers: The headers sent with the request.
@@ -56,6 +60,7 @@ def run_query_validators(
request_method: The HTTP method of the request.
databases: All Vuforia databases.
"""
+ validate_header_lines_not_too_large(request_headers=request_headers)
validate_content_length_header_is_int(request_headers=request_headers)
validate_content_length_header_not_too_large(
request_headers=request_headers,
diff --git a/src/mock_vws/_query_validators/exceptions.py b/src/mock_vws/_query_validators/exceptions.py
index 3ab09bb1e..c7250a485 100644
--- a/src/mock_vws/_query_validators/exceptions.py
+++ b/src/mock_vws/_query_validators/exceptions.py
@@ -457,6 +457,43 @@ def __init__(self) -> None:
}
+@beartype
+class RequestHeaderOrCookieTooLargeError(ValidatorError):
+ """Exception raised when a request header line is too long for NGINX.
+
+ NGINX rejects the request before it reaches the Vuforia application,
+ so this takes precedence over every other validation, including
+ authorization.
+ """
+
+ def __init__(self) -> None:
+ """Initialize an NGINX request header too large response."""
+ super().__init__()
+ self.status_code = HTTPStatus.BAD_REQUEST
+ self.response_text = "".join(
+ f"{line}\r\n"
+ for line in (
+ "",
+ (
+ "
400 Request Header Or Cookie Too Large"
+ ""
+ ),
+ "",
+ "400 Bad Request
",
+ "Request Header Or Cookie Too Large",
+ "
nginx",
+ "",
+ "",
+ )
+ )
+ self.headers = {
+ **_BASE_HEADERS,
+ "Content-Type": "text/html",
+ "Date": http_date(),
+ "Content-Length": str(object=len(self.response_text)),
+ }
+
+
@beartype
class RequestEntityTooLargeError(ValidatorError):
"""Exception raised when the given image file size is too large."""
diff --git a/src/mock_vws/_query_validators/header_size_validators.py b/src/mock_vws/_query_validators/header_size_validators.py
new file mode 100644
index 000000000..ace167689
--- /dev/null
+++ b/src/mock_vws/_query_validators/header_size_validators.py
@@ -0,0 +1,32 @@
+"""Validators for the size of request headers."""
+
+import logging
+from collections.abc import Mapping
+
+from beartype import beartype
+
+from mock_vws._mock_common import has_oversized_header_line
+from mock_vws._query_validators.exceptions import (
+ RequestHeaderOrCookieTooLargeError,
+)
+
+_LOGGER = logging.getLogger(name=__name__)
+
+
+@beartype
+def validate_header_lines_not_too_large(
+ *,
+ request_headers: Mapping[str, str],
+) -> None:
+ """Validate that no header line is too long for NGINX.
+
+ Args:
+ request_headers: The headers sent with the request.
+
+ Raises:
+ RequestHeaderOrCookieTooLargeError: A header line is longer than
+ NGINX's header buffer.
+ """
+ if has_oversized_header_line(request_headers=request_headers):
+ _LOGGER.warning(msg="A request header line is too large.")
+ raise RequestHeaderOrCookieTooLargeError
diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py
index 474569255..e444ae7ad 100644
--- a/src/mock_vws/_services_validators/__init__.py
+++ b/src/mock_vws/_services_validators/__init__.py
@@ -13,6 +13,7 @@
validate_authorization,
)
from .context import ValidatorContext
+from .header_size_validators import validate_header_lines_not_too_large
from .request_rate_limiter import RequestRateLimiter
from .routes import match_route
@@ -29,7 +30,9 @@ def run_services_validators[DatabaseT: AnyDatabase](
) -> DatabaseT:
"""Run the validators which apply to the request.
- Every request is authorized first, because the validators which follow
+ NGINX rejects a request with an over-long header line before it reaches
+ Vuforia, so that is checked first.
+ 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
request was made to. See :py:mod:`mock_vws._services_validators.routes`.
@@ -45,6 +48,7 @@ def run_services_validators[DatabaseT: AnyDatabase](
Returns:
The database which the request's server keys belong to.
"""
+ 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(
diff --git a/src/mock_vws/_services_validators/exceptions.py b/src/mock_vws/_services_validators/exceptions.py
index 51867dfb3..3d1864faf 100644
--- a/src/mock_vws/_services_validators/exceptions.py
+++ b/src/mock_vws/_services_validators/exceptions.py
@@ -356,6 +356,43 @@ def __init__(self) -> None: # pragma: no cover
}
+@beartype
+class RequestHeaderOrCookieTooLargeError(ValidatorError):
+ """Exception raised when a request header line is too long for NGINX.
+
+ NGINX rejects the request before it reaches the Vuforia application,
+ so this takes precedence over every other validation, including
+ authorization.
+ """
+
+ def __init__(self) -> None:
+ """Initialize an NGINX request header too large response."""
+ super().__init__()
+ self.status_code = HTTPStatus.BAD_REQUEST
+ self.response_text = "".join(
+ f"{line}\r\n"
+ for line in (
+ "",
+ (
+ "400 Request Header Or Cookie Too Large"
+ ""
+ ),
+ "",
+ "400 Bad Request
",
+ "Request Header Or Cookie Too Large",
+ "
nginx",
+ "",
+ "",
+ )
+ )
+ self.headers = {
+ **_STANDARD_HEADERS,
+ "Content-Type": "text/html",
+ "Date": http_date(),
+ "Content-Length": str(object=len(self.response_text)),
+ }
+
+
@beartype
class ContentLengthHeaderNotIntError(ValidatorError):
"""
diff --git a/src/mock_vws/_services_validators/header_size_validators.py b/src/mock_vws/_services_validators/header_size_validators.py
new file mode 100644
index 000000000..1aa9a251f
--- /dev/null
+++ b/src/mock_vws/_services_validators/header_size_validators.py
@@ -0,0 +1,32 @@
+"""Validators for the size of request headers."""
+
+import logging
+from collections.abc import Mapping
+
+from beartype import beartype
+
+from mock_vws._mock_common import has_oversized_header_line
+from mock_vws._services_validators.exceptions import (
+ RequestHeaderOrCookieTooLargeError,
+)
+
+_LOGGER = logging.getLogger(name=__name__)
+
+
+@beartype
+def validate_header_lines_not_too_large(
+ *,
+ request_headers: Mapping[str, str],
+) -> None:
+ """Validate that no header line is too long for NGINX.
+
+ Args:
+ request_headers: The headers sent with the request.
+
+ Raises:
+ RequestHeaderOrCookieTooLargeError: A header line is longer than
+ NGINX's header buffer.
+ """
+ if has_oversized_header_line(request_headers=request_headers):
+ _LOGGER.warning(msg="A request header line is too large.")
+ raise RequestHeaderOrCookieTooLargeError
diff --git a/tests/mock_vws/test_header_size.py b/tests/mock_vws/test_header_size.py
new file mode 100644
index 000000000..f0ef52b7f
--- /dev/null
+++ b/tests/mock_vws/test_header_size.py
@@ -0,0 +1,189 @@
+"""Tests for requests with header lines which are too long."""
+
+from http import HTTPStatus
+from urllib.parse import urlparse
+
+import pytest
+from vws.response import Response
+
+from mock_vws._mock_common import MAX_HEADER_LINE_LENGTH
+from tests.mock_vws.utils import Endpoint
+from tests.mock_vws.utils.assertions import (
+ assert_query_success,
+ assert_valid_date_header,
+ assert_vws_response,
+)
+from tests.mock_vws.utils.too_many_requests import handle_server_errors
+
+# The body which NGINX gives for a header line which does not fit in its
+# 8 KiB header buffer.
+_NGINX_TOO_LARGE_RESPONSE_TEXT = "".join(
+ f"{line}\r\n"
+ for line in (
+ "",
+ "400 Request Header Or Cookie Too Large",
+ "",
+ "400 Bad Request
",
+ "Request Header Or Cookie Too Large",
+ "
nginx",
+ "",
+ "",
+ )
+)
+
+
+def _endpoint_with_header(
+ *,
+ endpoint: Endpoint,
+ name: str,
+ value: str,
+) -> Endpoint:
+ """Return the given endpoint with one extra request header."""
+ return Endpoint(
+ base_url=endpoint.base_url,
+ path_url=endpoint.path_url,
+ method=endpoint.method,
+ headers={**endpoint.headers, name: value},
+ data=endpoint.data,
+ successful_headers_result_code=endpoint.successful_headers_result_code,
+ successful_headers_status_code=endpoint.successful_headers_status_code,
+ access_key=endpoint.access_key,
+ secret_key=endpoint.secret_key,
+ )
+
+
+def _header_value_for_line_length(*, name: str, line_length: int) -> str:
+ """Return a value which makes ``name: value`` the given length."""
+ return "a" * (line_length - len(f"{name}: "))
+
+
+def _assert_nginx_too_large_response(
+ *,
+ endpoint: Endpoint,
+ response: Response,
+) -> None:
+ """Assert that the response is NGINX's rejection of a long header."""
+ assert response.status_code == HTTPStatus.BAD_REQUEST
+ assert response.text == _NGINX_TOO_LARGE_RESPONSE_TEXT
+ # Header names are compared case-insensitively because the Target API
+ # sends some in lower case, and the transports differ in what they keep.
+ headers = {key.lower(): value for key, value in response.headers.items()}
+ assert headers["content-type"] == "text/html"
+ assert headers["content-length"] == str(
+ object=len(_NGINX_TOO_LARGE_RESPONSE_TEXT),
+ )
+ assert headers["connection"] == "keep-alive"
+
+ netloc = urlparse(url=endpoint.base_url).netloc
+ if netloc == "cloudreco.vuforia.com":
+ assert headers["server"] == "nginx"
+ return
+
+ # NGINX for the Target API sits behind Envoy, which adds its own headers.
+ assert headers["server"] == "envoy"
+ assert headers["x-content-type-options"] == "nosniff"
+ assert headers["strict-transport-security"] == "max-age=31536000"
+ assert "x-aws-region" in headers
+ assert "x-envoy-upstream-service-time" in headers
+
+
+@pytest.mark.usefixtures("verify_mock_vuforia")
+class TestOversizedHeaderLine:
+ """Tests for header lines which do not fit in NGINX's 8 KiB buffer.
+
+ A header line is the header name, a colon, a space and the value.
+ NGINX's buffer also holds the line's CRLF, so the longest accepted
+ line is 8190 bytes.
+ """
+
+ @staticmethod
+ def test_header_too_large(endpoint: Endpoint) -> None:
+ """A header line one byte too long for NGINX gives a
+ ``BAD_REQUEST``
+ response with NGINX's HTML body, before any authorization.
+ """
+ name = "X-Padding"
+ new_endpoint = _endpoint_with_header(
+ endpoint=endpoint,
+ name=name,
+ value=_header_value_for_line_length(
+ name=name,
+ line_length=MAX_HEADER_LINE_LENGTH + 1,
+ ),
+ )
+
+ response = new_endpoint.send()
+ handle_server_errors(response=response)
+ assert_valid_date_header(response=response)
+ _assert_nginx_too_large_response(
+ endpoint=endpoint,
+ response=response,
+ )
+
+ @staticmethod
+ def test_cookie_too_large(endpoint: Endpoint) -> None:
+ """A cookie which makes the ``Cookie`` line too long for NGINX
+ gives
+ the same ``BAD_REQUEST`` response as any other header.
+
+ The Envoy layer in front of the Target API lets a ``Cookie`` line
+ slightly over the limit through, so this sends one well over it.
+ See :ref:`differences-nginx-error-cases`.
+ """
+ name = "Cookie"
+ new_endpoint = _endpoint_with_header(
+ endpoint=endpoint,
+ name=name,
+ value="pad="
+ + _header_value_for_line_length(
+ name=name,
+ line_length=MAX_HEADER_LINE_LENGTH + 1000,
+ ),
+ )
+
+ response = new_endpoint.send()
+ handle_server_errors(response=response)
+ assert_valid_date_header(response=response)
+ _assert_nginx_too_large_response(
+ endpoint=endpoint,
+ response=response,
+ )
+
+ @staticmethod
+ def test_large_header_within_limit(endpoint: Endpoint) -> None:
+ """A large header line which fits in NGINX's buffer is accepted.
+
+ This does not send a line at the limit exactly, because the Query
+ API rejects header blocks of about 8 KiB in total with a ``431``
+ response from its application server. See
+ :ref:`differences-nginx-error-cases`.
+ """
+ name = "X-Padding"
+ new_endpoint = _endpoint_with_header(
+ endpoint=endpoint,
+ name=name,
+ value=_header_value_for_line_length(
+ name=name,
+ line_length=MAX_HEADER_LINE_LENGTH // 2,
+ ),
+ )
+
+ response = new_endpoint.send()
+ handle_server_errors(response=response)
+
+ netloc = urlparse(url=endpoint.base_url).netloc
+ if netloc == "cloudreco.vuforia.com":
+ assert_query_success(response=response)
+ return
+
+ if endpoint.successful_headers_result_code is None:
+ assert (
+ response.status_code == endpoint.successful_headers_status_code
+ )
+ return
+
+ assert_vws_response(
+ response=response,
+ status_code=endpoint.successful_headers_status_code,
+ result_code=endpoint.successful_headers_result_code,
+ )
diff --git a/tests/mock_vws/utils/retries.py b/tests/mock_vws/utils/retries.py
index a8895367b..c435bb7f9 100644
--- a/tests/mock_vws/utils/retries.py
+++ b/tests/mock_vws/utils/retries.py
@@ -1,5 +1,8 @@
"""Helpers for retrying requests to VWS."""
+from requests.exceptions import ConnectionError as RequestsConnectionError
+from requests.exceptions import ConnectTimeout as RequestsConnectTimeout
+from requests.exceptions import ReadTimeout as RequestsReadTimeout
from requests.exceptions import Timeout as RequestsTimeout
from tenacity import retry
from tenacity.retry import retry_if_exception_type
@@ -11,7 +14,19 @@
UnknownTargetError,
)
-TRANSIENT_VWS_EXCEPTIONS = (TooManyRequestsError, ServerError, RequestsTimeout)
+# ``pytest-retry`` checks whether the type of the exception which failed a
+# test is *in* this tuple, so a subclass of a listed type is not retried.
+# ``requests`` raises the ``Timeout`` subclasses below, never ``Timeout``
+# itself, so each one is listed. ``Timeout`` and ``ConnectionError`` stay
+# for the ``tenacity`` retries, which do use ``isinstance``.
+TRANSIENT_VWS_EXCEPTIONS = (
+ TooManyRequestsError,
+ ServerError,
+ RequestsTimeout,
+ RequestsReadTimeout,
+ RequestsConnectTimeout,
+ RequestsConnectionError,
+)
TRANSIENT_VWS_RETRY_ATTEMPTS = 10
# We rely on pytest-retry for exceptions *during* tests.