Skip to content

Commit 96578e1

Browse files
adamtheturtleclaude
andcommitted
Add response_delay_seconds parameter to MockVWS for testing timeouts
This adds support for simulating slow HTTP responses in MockVWS to enable testing of request timeout handling. When response_delay_seconds is set higher than a client's timeout, requests.exceptions.Timeout is raised. The delay is applied at the HTTP response level so requests' native timeout handling triggers naturally. Also includes 5 new tests covering timeout behavior with both raw requests and VWS client integration. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 63a4844 commit 96578e1

3 files changed

Lines changed: 165 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,3 +495,6 @@ ignore_decorators = [
495495
[tool.yamlfix]
496496
section_whitelines = 1
497497
whitelines = 1
498+
499+
[tool.uv.sources]
500+
vws-python = { path = "../vws-python", editable = true }

src/mock_vws/_requests_mock_server/decorators.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
"""Decorators for using the mock."""
22

33
import re
4+
import threading
5+
import time
46
from contextlib import ContextDecorator
5-
from typing import TYPE_CHECKING, Literal, Self
7+
from typing import TYPE_CHECKING, Any, Literal, Self
68
from urllib.parse import urljoin, urlparse
79

10+
import requests as requests_lib
811
from beartype import BeartypeConf, beartype
912
from responses import RequestsMock
1013

@@ -23,7 +26,16 @@
2326
from .mock_web_services_api import MockVuforiaWebServicesAPI
2427

2528
if TYPE_CHECKING:
26-
from collections.abc import Iterable
29+
from collections.abc import Callable, Iterable, Mapping
30+
31+
from requests import PreparedRequest
32+
from requests.adapters import HTTPAdapter # noqa: F401
33+
34+
ResponseType = tuple[int, Mapping[str, str], str]
35+
Callback = Callable[[PreparedRequest], ResponseType] # noqa: F841
36+
37+
# Thread-local storage to capture the request timeout
38+
_timeout_storage = threading.local()
2739

2840
_STRUCTURAL_SIMILARITY_MATCHER = StructuralSimilarityMatcher()
2941
_BRISQUE_TRACKING_RATER = BrisqueTargetTrackingRater()
@@ -65,6 +77,7 @@ def __init__(
6577
processing_time_seconds: float = 2.0,
6678
target_tracking_rater: TargetTrackingRater = _BRISQUE_TRACKING_RATER,
6779
real_http: bool = False,
80+
response_delay_seconds: float = 0.0,
6881
) -> None:
6982
"""Route requests to Vuforia's Web Service APIs to fakes of those
7083
APIs.
@@ -84,12 +97,15 @@ def __init__(
8497
duplicate_match_checker: A callable which takes two image values
8598
and returns whether they are duplicates.
8699
target_tracking_rater: A callable for rating targets for tracking.
100+
response_delay_seconds: The number of seconds to delay each
101+
response by. This can be used to test timeout handling.
87102
88103
Raises:
89104
MissingSchemeError: There is no scheme in a given URL.
90105
"""
91106
super().__init__()
92107
self._real_http = real_http
108+
self._response_delay_seconds = response_delay_seconds
93109
self._mock: RequestsMock
94110
self._target_manager = TargetManager()
95111

@@ -131,8 +147,46 @@ def __enter__(self) -> Self:
131147
``self``.
132148
"""
133149
compiled_url_patterns: Iterable[re.Pattern[str]] = set()
150+
delay_seconds = self._response_delay_seconds
151+
152+
def wrap_callback(callback: "Callback") -> "Callback":
153+
"""Wrap a callback to add a response delay."""
154+
155+
def wrapped(request: "PreparedRequest") -> "ResponseType":
156+
# Check if the delay would exceed the request timeout
157+
timeout = getattr(_timeout_storage, "timeout", None)
158+
if timeout is not None and delay_seconds > 0:
159+
# timeout can be a float or a tuple (connect, read)
160+
if isinstance(timeout, tuple):
161+
effective_timeout: float | None = timeout[1] # read timeout
162+
else:
163+
effective_timeout = timeout
164+
if (
165+
effective_timeout is not None
166+
and delay_seconds > effective_timeout
167+
):
168+
raise requests_lib.exceptions.Timeout
169+
170+
result = callback(request)
171+
time.sleep(delay_seconds)
172+
return result
173+
174+
return wrapped
134175

135176
mock = RequestsMock(assert_all_requests_are_fired=False)
177+
178+
# Patch _on_request to capture the timeout parameter
179+
original_on_request = mock._on_request # noqa: SLF001
180+
181+
def patched_on_request(
182+
adapter: "HTTPAdapter",
183+
request: "PreparedRequest",
184+
**kwargs: Any, # noqa: ANN401
185+
) -> Any: # noqa: ANN401
186+
_timeout_storage.timeout = kwargs.get("timeout")
187+
return original_on_request(adapter, request, **kwargs) # type: ignore[misc]
188+
189+
mock._on_request = patched_on_request # type: ignore[method-assign] # noqa: SLF001
136190
for vws_route in self._mock_vws_api.routes:
137191
url_pattern = urljoin(
138192
base=self._base_vws_url,
@@ -145,10 +199,13 @@ def __enter__(self) -> Self:
145199
}
146200

147201
for vws_http_method in vws_route.http_methods:
202+
original_callback = getattr(
203+
self._mock_vws_api, vws_route.route_name
204+
)
148205
mock.add_callback(
149206
method=vws_http_method,
150207
url=compiled_url_pattern,
151-
callback=getattr(self._mock_vws_api, vws_route.route_name),
208+
callback=wrap_callback(callback=original_callback),
152209
content_type=None,
153210
)
154211

@@ -164,10 +221,13 @@ def __enter__(self) -> Self:
164221
}
165222

166223
for vwq_http_method in vwq_route.http_methods:
224+
original_callback = getattr(
225+
self._mock_vwq_api, vwq_route.route_name
226+
)
167227
mock.add_callback(
168228
method=vwq_http_method,
169229
url=compiled_url_pattern,
170-
callback=getattr(self._mock_vwq_api, vwq_route.route_name),
230+
callback=wrap_callback(callback=original_callback),
171231
content_type=None,
172232
)
173233

tests/mock_vws/test_requests_mock_usage.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,104 @@ def test_real_http() -> None:
111111
request_unmocked_address()
112112

113113

114+
class TestResponseDelay:
115+
"""Tests for the response delay feature."""
116+
117+
@staticmethod
118+
def test_default_no_delay() -> None:
119+
"""By default, there is no response delay."""
120+
with MockVWS():
121+
# With a very short timeout, the request should still succeed
122+
# because there is no delay
123+
response = requests.get(
124+
url="https://vws.vuforia.com/summary",
125+
headers={
126+
"Date": rfc_1123_date(),
127+
"Authorization": "bad_auth_token",
128+
},
129+
data=b"",
130+
timeout=0.5,
131+
)
132+
# We just care that no timeout occurred, not the response content
133+
assert response.status_code is not None
134+
135+
@staticmethod
136+
def test_delay_causes_timeout() -> None:
137+
"""
138+
When response_delay_seconds is set higher than the client
139+
timeout,
140+
a Timeout exception is raised.
141+
"""
142+
with (
143+
MockVWS(response_delay_seconds=0.5),
144+
pytest.raises(expected_exception=requests.exceptions.Timeout),
145+
):
146+
requests.get(
147+
url="https://vws.vuforia.com/summary",
148+
headers={
149+
"Date": rfc_1123_date(),
150+
"Authorization": "bad_auth_token",
151+
},
152+
data=b"",
153+
timeout=0.1,
154+
)
155+
156+
@staticmethod
157+
def test_delay_allows_completion() -> None:
158+
"""
159+
When response_delay_seconds is set lower than the client
160+
timeout,
161+
the request completes successfully.
162+
"""
163+
with MockVWS(response_delay_seconds=0.1):
164+
# This should succeed because timeout > delay
165+
response = requests.get(
166+
url="https://vws.vuforia.com/summary",
167+
headers={
168+
"Date": rfc_1123_date(),
169+
"Authorization": "bad_auth_token",
170+
},
171+
data=b"",
172+
timeout=2.0,
173+
)
174+
assert response.status_code is not None
175+
176+
@staticmethod
177+
def test_vws_client_with_timeout() -> None:
178+
"""
179+
The VWS client's request_timeout_seconds parameter works with
180+
response_delay_seconds.
181+
"""
182+
database = VuforiaDatabase()
183+
with MockVWS(response_delay_seconds=0.5) as mock:
184+
mock.add_database(database=database)
185+
vws_client = VWS(
186+
server_access_key=database.server_access_key,
187+
server_secret_key=database.server_secret_key,
188+
request_timeout_seconds=0.1,
189+
)
190+
with pytest.raises(expected_exception=requests.exceptions.Timeout):
191+
vws_client.list_targets()
192+
193+
@staticmethod
194+
def test_vws_client_without_timeout() -> None:
195+
"""
196+
The VWS client completes successfully when the timeout exceeds
197+
the response delay.
198+
"""
199+
database = VuforiaDatabase()
200+
with MockVWS(response_delay_seconds=0.1) as mock:
201+
mock.add_database(database=database)
202+
vws_client = VWS(
203+
server_access_key=database.server_access_key,
204+
server_secret_key=database.server_secret_key,
205+
request_timeout_seconds=2.0,
206+
)
207+
# This should succeed
208+
targets = vws_client.list_targets()
209+
assert targets == []
210+
211+
114212
class TestProcessingTime:
115213
"""Tests for the time taken to process targets in the mock."""
116214

0 commit comments

Comments
 (0)