From 5c2c8a4821dbd189125888de076796b16ed2bdf4 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 14 Oct 2026 12:52:30 +0100 Subject: [PATCH 1/2] Keep request timeout policy in the backend adapter --- tests/mock_vws/fixtures/vuforia_backends.py | 15 +- .../test_request_timeout_policy.py | 238 ++++++++++++++++++ tests/mock_vws/utils/__init__.py | 10 +- .../mock_vws/utils/request_timeout_policy.py | 44 ++++ 4 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 tests/mock_vws/requests_mock/test_request_timeout_policy.py create mode 100644 tests/mock_vws/utils/request_timeout_policy.py diff --git a/tests/mock_vws/fixtures/vuforia_backends.py b/tests/mock_vws/fixtures/vuforia_backends.py index f936f4103..fae9bce5c 100644 --- a/tests/mock_vws/fixtures/vuforia_backends.py +++ b/tests/mock_vws/fixtures/vuforia_backends.py @@ -14,6 +14,7 @@ import responses from beartype import beartype from pytest_multi_backend import backend_fixture +from requests.exceptions import Timeout as RequestsTimeout from requests_mock_flask import add_flask_app_to_mock from vws import VWS from vws.exceptions.vws_exceptions import ( @@ -50,6 +51,9 @@ retrying_transient_real_backend_failures, ) from tests.mock_vws.utils.recognition_counts import SeedRecognitionCounts +from tests.mock_vws.utils.request_timeout_policy import ( + handling_request_timeouts, +) from tests.mock_vws.utils.retries import ( RETRY_ON_TARGET_STILL_PROCESSING, RETRY_ON_TRANSIENT_VWS_FAILURE, @@ -166,6 +170,13 @@ def _vumark_database( ) +@beartype +def _handle_real_request_timeout(*, url: str, error: RequestsTimeout) -> None: + """Skip a shared request which timed out against the real service.""" + del url, error + pytest.skip(reason="The real Vuforia service timed out.") + + @beartype def _enable_use_real_vuforia( *, @@ -186,7 +197,8 @@ def _enable_use_real_vuforia( _delete_all_targets( database_keys=working_database, base_vws_url=base_vws_url ) - yield + with handling_request_timeouts(handler=_handle_real_request_timeout): + yield @beartype @@ -396,6 +408,7 @@ def _enable_use_real_model_target_vuforia( assert bool(monkeypatch) with ( retrying_transient_real_backend_failures(), + handling_request_timeouts(handler=_handle_real_request_timeout), handling_unexpected_model_target_responses( handler=_handle_real_model_target_response, ), diff --git a/tests/mock_vws/requests_mock/test_request_timeout_policy.py b/tests/mock_vws/requests_mock/test_request_timeout_policy.py new file mode 100644 index 000000000..165890d90 --- /dev/null +++ b/tests/mock_vws/requests_mock/test_request_timeout_policy.py @@ -0,0 +1,238 @@ +"""Tests for timeout handling supplied by backend adapters.""" + +from collections.abc import Generator +from http import HTTPMethod, HTTPStatus + +import pytest +import requests +from requests.exceptions import ConnectTimeout, ReadTimeout +from requests.exceptions import Timeout as RequestsTimeout +from vws import VWS + +from mock_vws.database import CloudDatabase +from mock_vws.states import States +from tests.mock_vws.fixtures import vuforia_backends +from tests.mock_vws.fixtures.credentials import ( + InactiveVuMarkCloudDatabase, + VuMarkCloudDatabase, +) +from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend +from tests.mock_vws.utils import Endpoint, ModelTargetEndpoint +from tests.mock_vws.utils.request_timeout_policy import ( + handle_request_timeout, + handling_request_timeouts, +) + + +@pytest.fixture(name="_vws_backend") +def fixture_vws_backend() -> VuforiaBackend: + """Select the real policy using synthetic credentials and + transport. + """ + return VuforiaBackend.REAL + + +@pytest.fixture(name="_model_target_backend") +def fixture_model_target_backend() -> VuforiaBackend: + """Select the real policy without Model Target credential lookup.""" + return VuforiaBackend.REAL + + +@pytest.fixture( + name="base_vws_url", + params=[ + "https://vws.vuforia.com", + "https://cloudreco.vuforia.com", + "http://consumer.example", + ], +) +def fixture_base_vws_url(*, request: pytest.FixtureRequest) -> str: + """Supply canonical and custom URLs for controlled requests.""" + url = request.param + assert isinstance(url, str) + return url + + +@pytest.fixture( + name="request_timeout", + params=[RequestsTimeout, ConnectTimeout, ReadTimeout], +) +def fixture_request_timeout( + *, request: pytest.FixtureRequest +) -> RequestsTimeout: + """Supply a controlled base or derived timeout exception.""" + timeout_type = request.param + assert isinstance(timeout_type, type) + assert issubclass(timeout_type, RequestsTimeout) + return timeout_type("Controlled timeout") + + +@pytest.fixture(name="timing_out_endpoint", params=[False, True]) +def fixture_timing_out_endpoint( + *, + request: pytest.FixtureRequest, + base_vws_url: str, +) -> Endpoint | ModelTargetEndpoint: + """A shared endpoint which is sent through the controlled + transport. + """ + is_model_target = request.param + assert isinstance(is_model_target, bool) + if is_model_target: + return ModelTargetEndpoint( + base_url=base_vws_url, + path_url="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/modeltargets/datasets", + method=HTTPMethod.GET, + headers={}, + data=b"", + takes_json_body=False, + ) + return Endpoint( + base_url=base_vws_url, + path_url="/targets", + method=HTTPMethod.GET, + headers={}, + data=b"", + successful_headers_result_code=None, + successful_headers_status_code=HTTPStatus.OK, + access_key="mock-access-key", + secret_key="mock-secret-key", + ) + + +@pytest.fixture(autouse=True) +def fixture_controlled_transport( + *, + monkeypatch: pytest.MonkeyPatch, + request_timeout: RequestsTimeout, +) -> Generator[None]: + """Isolate setup and transport from real accounts and requests. + + Yields: + ``None``, while the controlled transport is installed. + """ + + def send_timeout( + session: requests.Session, + *, + request: requests.PreparedRequest, + timeout: int, + ) -> requests.Response: + """Raise the controlled timeout instead of sending a request.""" + del session, request, timeout + raise request_timeout + + def no_targets(client: VWS) -> list[str]: + """Avoid real-service calls during database cleanup.""" + del client + return [] + + monkeypatch.setattr( + target=requests.Session, name="send", value=send_timeout + ) + monkeypatch.setattr(target=VWS, name="list_targets", value=no_targets) + monkeypatch.setattr( + target=vuforia_backends, + name="get_real_cloud_database", + value=CloudDatabase, + ) + monkeypatch.setattr( + target=vuforia_backends, + name="get_real_inactive_cloud_database", + value=lambda: CloudDatabase(state=States.PROJECT_INACTIVE), + ) + monkeypatch.setattr( + target=vuforia_backends, + name="get_real_vumark_database", + value=lambda: VuMarkCloudDatabase( + target_manager_database_name="mock-vumark", + server_access_key="mock-vumark-access", + server_secret_key="mock-vumark-secret", + target_id="mock-target", + processing_target_id="mock-processing-target", + ), + ) + monkeypatch.setattr( + target=vuforia_backends, + name="get_real_inactive_vumark_database", + value=lambda: InactiveVuMarkCloudDatabase( + target_manager_database_name="mock-inactive-vumark", + server_access_key="mock-inactive-vumark-access", + server_secret_key="mock-inactive-vumark-secret", + ), + ) + handle_request_timeout( + url="https://vws.vuforia.com/targets", error=request_timeout + ) + yield + # Backend fixtures finish first, restoring the strict default. + handle_request_timeout( + url="https://vws.vuforia.com/targets", error=request_timeout + ) + + +def test_default_propagates_timeouts( + *, + timing_out_endpoint: Endpoint | ModelTargetEndpoint, + request_timeout: RequestsTimeout, +) -> None: + """A host name alone never changes the strict shared timeout + behavior. + """ + with pytest.raises(expected_exception=RequestsTimeout) as exc: + _ = timing_out_endpoint.send() + assert exc.value is request_timeout + + +@pytest.mark.usefixtures("verify_mock_vuforia") +def test_real_vws_adapter_skips_timeouts( + *, timing_out_endpoint: Endpoint | ModelTargetEndpoint +) -> None: + """The real VWS setup installs the timeout policy for supplied + URLs. + """ + with pytest.raises( + expected_exception=pytest.skip.Exception, + match=r"The real Vuforia service timed out\.", + ): + _ = timing_out_endpoint.send() + + +@pytest.mark.usefixtures("verify_model_target_mock_vuforia") +def test_real_model_target_adapter_skips_timeouts( + *, timing_out_endpoint: Endpoint | ModelTargetEndpoint +) -> None: + """The real Model Target setup installs the same timeout policy.""" + with pytest.raises( + expected_exception=pytest.skip.Exception, + match=r"The real Vuforia service timed out\.", + ): + _ = timing_out_endpoint.send() + + +def test_nested_timeout_handlers(*, request_timeout: RequestsTimeout) -> None: + """Nested policies restore the outer handler and finally the + default. + """ + calls: list[str] = [] + + def outer_handler(*, url: str, error: RequestsTimeout) -> None: + """Record a timeout handled by the outer context.""" + assert url == "http://consumer.example/targets" + assert error is request_timeout + calls.append("outer") + + def inner_handler(*, url: str, error: RequestsTimeout) -> None: + """Record a timeout handled by the inner context.""" + assert url == "http://consumer.example/targets" + assert error is request_timeout + calls.append("inner") + + url = "http://consumer.example/targets" + with handling_request_timeouts(handler=outer_handler): + handle_request_timeout(url=url, error=request_timeout) + with handling_request_timeouts(handler=inner_handler): + handle_request_timeout(url=url, error=request_timeout) + handle_request_timeout(url=url, error=request_timeout) + handle_request_timeout(url=url, error=request_timeout) + assert calls == ["outer", "inner", "outer"] diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 8db33fb66..ac5230a7e 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -7,7 +7,6 @@ from typing import Literal from urllib.parse import urljoin -import pytest import requests from beartype import beartype from PIL import Image @@ -19,6 +18,7 @@ from tests.mock_vws.utils.model_target_retries import ( send_with_transient_retries, ) +from tests.mock_vws.utils.request_timeout_policy import handle_request_timeout _REQUEST_TIMEOUT_SECONDS = 5 @@ -46,12 +46,8 @@ def _send_request( request=prepared_request, timeout=_REQUEST_TIMEOUT_SECONDS, ) - # A live-service timeout is nondeterministic and cannot be forced reliably. - except RequestsTimeout: # pragma: no cover - if url.startswith( - ("https://vws.vuforia.com/", "https://cloudreco.vuforia.com/"), - ): - pytest.skip(reason="The real Vuforia service timed out.") + except RequestsTimeout as error: + handle_request_timeout(url=url, error=error) raise return Response( text=requests_response.text, diff --git a/tests/mock_vws/utils/request_timeout_policy.py b/tests/mock_vws/utils/request_timeout_policy.py new file mode 100644 index 000000000..f8d1b14fa --- /dev/null +++ b/tests/mock_vws/utils/request_timeout_policy.py @@ -0,0 +1,44 @@ +"""Backend-supplied handling of request timeouts.""" + +import contextlib +from collections.abc import Generator +from typing import Protocol, runtime_checkable + +from beartype import beartype +from requests.exceptions import Timeout as RequestsTimeout + + +@runtime_checkable +class RequestTimeoutHandler(Protocol): + """A backend operation for a request which timed out.""" + + def __call__(self, *, url: str, error: RequestsTimeout) -> None: + """Handle a timeout before the shared sender propagates it.""" + ... + + +_TIMEOUT_HANDLERS: list[RequestTimeoutHandler] = [] + + +@contextlib.contextmanager +@beartype +def handling_request_timeouts( + *, handler: RequestTimeoutHandler +) -> Generator[None]: + """Use the supplied timeout handler within this context. + + Yields: + ``None``, with the supplied handler active. + """ + _TIMEOUT_HANDLERS.append(handler) + try: + yield + finally: + _ = _TIMEOUT_HANDLERS.pop() + + +@beartype +def handle_request_timeout(*, url: str, error: RequestsTimeout) -> None: + """Notify the backend before propagating a request timeout.""" + if bool(_TIMEOUT_HANDLERS): + _TIMEOUT_HANDLERS[-1](url=url, error=error) From 303c39158467dc1174710eb6fcd7fc602afd1b10 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 14 Oct 2026 13:51:26 +0100 Subject: [PATCH 2/2] Test timeout policies directly without monkeypatching --- .../test_request_timeout_policy.py | 216 +++++------------- tests/mock_vws/utils/__init__.py | 10 +- .../mock_vws/utils/request_timeout_policy.py | 17 +- 3 files changed, 80 insertions(+), 163 deletions(-) diff --git a/tests/mock_vws/requests_mock/test_request_timeout_policy.py b/tests/mock_vws/requests_mock/test_request_timeout_policy.py index 165890d90..2e9211798 100644 --- a/tests/mock_vws/requests_mock/test_request_timeout_policy.py +++ b/tests/mock_vws/requests_mock/test_request_timeout_policy.py @@ -1,40 +1,21 @@ """Tests for timeout handling supplied by backend adapters.""" from collections.abc import Generator -from http import HTTPMethod, HTTPStatus import pytest -import requests from requests.exceptions import ConnectTimeout, ReadTimeout from requests.exceptions import Timeout as RequestsTimeout -from vws import VWS - -from mock_vws.database import CloudDatabase -from mock_vws.states import States -from tests.mock_vws.fixtures import vuforia_backends -from tests.mock_vws.fixtures.credentials import ( - InactiveVuMarkCloudDatabase, - VuMarkCloudDatabase, -) + from tests.mock_vws.fixtures.vuforia_backends import VuforiaBackend -from tests.mock_vws.utils import Endpoint, ModelTargetEndpoint from tests.mock_vws.utils.request_timeout_policy import ( - handle_request_timeout, handling_request_timeouts, + propagating_request_timeouts, ) -@pytest.fixture(name="_vws_backend") -def fixture_vws_backend() -> VuforiaBackend: - """Select the real policy using synthetic credentials and - transport. - """ - return VuforiaBackend.REAL - - @pytest.fixture(name="_model_target_backend") def fixture_model_target_backend() -> VuforiaBackend: - """Select the real policy without Model Target credential lookup.""" + """Select the real policy without credential lookup or requests.""" return VuforiaBackend.REAL @@ -47,7 +28,7 @@ def fixture_model_target_backend() -> VuforiaBackend: ], ) def fixture_base_vws_url(*, request: pytest.FixtureRequest) -> str: - """Supply canonical and custom URLs for controlled requests.""" + """Supply canonical and custom URLs for controlled timeouts.""" url = request.param assert isinstance(url, str) return url @@ -67,154 +48,63 @@ def fixture_request_timeout( return timeout_type("Controlled timeout") -@pytest.fixture(name="timing_out_endpoint", params=[False, True]) -def fixture_timing_out_endpoint( - *, - request: pytest.FixtureRequest, - base_vws_url: str, -) -> Endpoint | ModelTargetEndpoint: - """A shared endpoint which is sent through the controlled - transport. - """ - is_model_target = request.param - assert isinstance(is_model_target, bool) - if is_model_target: - return ModelTargetEndpoint( - base_url=base_vws_url, - path_url="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/modeltargets/datasets", - method=HTTPMethod.GET, - headers={}, - data=b"", - takes_json_body=False, - ) - return Endpoint( - base_url=base_vws_url, - path_url="/targets", - method=HTTPMethod.GET, - headers={}, - data=b"", - successful_headers_result_code=None, - successful_headers_status_code=HTTPStatus.OK, - access_key="mock-access-key", - secret_key="mock-secret-key", - ) - - @pytest.fixture(autouse=True) -def fixture_controlled_transport( - *, - monkeypatch: pytest.MonkeyPatch, - request_timeout: RequestsTimeout, +def fixture_check_timeout_policy_cleanup( + *, request_timeout: RequestsTimeout ) -> Generator[None]: - """Isolate setup and transport from real accounts and requests. + """Check the default before setup and after backend fixtures finish. Yields: - ``None``, while the controlled transport is installed. + ``None``, while the test and its backend fixtures run. """ - - def send_timeout( - session: requests.Session, - *, - request: requests.PreparedRequest, - timeout: int, - ) -> requests.Response: - """Raise the controlled timeout instead of sending a request.""" - del session, request, timeout + with ( + pytest.raises(expected_exception=RequestsTimeout), + propagating_request_timeouts(url="https://vws.vuforia.com/targets"), + ): raise request_timeout - - def no_targets(client: VWS) -> list[str]: - """Avoid real-service calls during database cleanup.""" - del client - return [] - - monkeypatch.setattr( - target=requests.Session, name="send", value=send_timeout - ) - monkeypatch.setattr(target=VWS, name="list_targets", value=no_targets) - monkeypatch.setattr( - target=vuforia_backends, - name="get_real_cloud_database", - value=CloudDatabase, - ) - monkeypatch.setattr( - target=vuforia_backends, - name="get_real_inactive_cloud_database", - value=lambda: CloudDatabase(state=States.PROJECT_INACTIVE), - ) - monkeypatch.setattr( - target=vuforia_backends, - name="get_real_vumark_database", - value=lambda: VuMarkCloudDatabase( - target_manager_database_name="mock-vumark", - server_access_key="mock-vumark-access", - server_secret_key="mock-vumark-secret", - target_id="mock-target", - processing_target_id="mock-processing-target", - ), - ) - monkeypatch.setattr( - target=vuforia_backends, - name="get_real_inactive_vumark_database", - value=lambda: InactiveVuMarkCloudDatabase( - target_manager_database_name="mock-inactive-vumark", - server_access_key="mock-inactive-vumark-access", - server_secret_key="mock-inactive-vumark-secret", - ), - ) - handle_request_timeout( - url="https://vws.vuforia.com/targets", error=request_timeout - ) yield - # Backend fixtures finish first, restoring the strict default. - handle_request_timeout( - url="https://vws.vuforia.com/targets", error=request_timeout - ) + with ( + pytest.raises(expected_exception=RequestsTimeout), + propagating_request_timeouts(url="https://vws.vuforia.com/targets"), + ): + raise request_timeout def test_default_propagates_timeouts( - *, - timing_out_endpoint: Endpoint | ModelTargetEndpoint, - request_timeout: RequestsTimeout, + *, base_vws_url: str, request_timeout: RequestsTimeout ) -> None: """A host name alone never changes the strict shared timeout behavior. """ - with pytest.raises(expected_exception=RequestsTimeout) as exc: - _ = timing_out_endpoint.send() - assert exc.value is request_timeout - - -@pytest.mark.usefixtures("verify_mock_vuforia") -def test_real_vws_adapter_skips_timeouts( - *, timing_out_endpoint: Endpoint | ModelTargetEndpoint -) -> None: - """The real VWS setup installs the timeout policy for supplied - URLs. - """ - with pytest.raises( - expected_exception=pytest.skip.Exception, - match=r"The real Vuforia service timed out\.", + with ( + pytest.raises(expected_exception=RequestsTimeout) as exc, + propagating_request_timeouts(url=f"{base_vws_url}/targets"), ): - _ = timing_out_endpoint.send() + raise request_timeout + assert exc.value is request_timeout @pytest.mark.usefixtures("verify_model_target_mock_vuforia") -def test_real_model_target_adapter_skips_timeouts( - *, timing_out_endpoint: Endpoint | ModelTargetEndpoint +def test_real_adapter_skips_timeouts( + *, base_vws_url: str, request_timeout: RequestsTimeout ) -> None: - """The real Model Target setup installs the same timeout policy.""" - with pytest.raises( - expected_exception=pytest.skip.Exception, - match=r"The real Vuforia service timed out\.", + """The real adapter skips a timeout at any supplied URL.""" + with ( + pytest.raises( + expected_exception=pytest.skip.Exception, + match=r"The real Vuforia service timed out\.", + ), + propagating_request_timeouts(url=f"{base_vws_url}/targets"), ): - _ = timing_out_endpoint.send() + raise request_timeout def test_nested_timeout_handlers(*, request_timeout: RequestsTimeout) -> None: - """Nested policies restore the outer handler and finally the - default. + """Nested policies restore the outer handler after a raised + exception. """ calls: list[str] = [] + url = "http://consumer.example/targets" def outer_handler(*, url: str, error: RequestsTimeout) -> None: """Record a timeout handled by the outer context.""" @@ -223,16 +113,36 @@ def outer_handler(*, url: str, error: RequestsTimeout) -> None: calls.append("outer") def inner_handler(*, url: str, error: RequestsTimeout) -> None: - """Record a timeout handled by the inner context.""" + """Skip a timeout handled by the inner context.""" assert url == "http://consumer.example/targets" assert error is request_timeout calls.append("inner") + pytest.skip(reason="Controlled inner timeout") - url = "http://consumer.example/targets" with handling_request_timeouts(handler=outer_handler): - handle_request_timeout(url=url, error=request_timeout) - with handling_request_timeouts(handler=inner_handler): - handle_request_timeout(url=url, error=request_timeout) - handle_request_timeout(url=url, error=request_timeout) - handle_request_timeout(url=url, error=request_timeout) + with ( + pytest.raises(expected_exception=RequestsTimeout) as exc, + propagating_request_timeouts(url=url), + ): + raise request_timeout + assert exc.value is request_timeout + with ( + pytest.raises( + expected_exception=pytest.skip.Exception, + match="Controlled inner timeout", + ), + handling_request_timeouts(handler=inner_handler), + propagating_request_timeouts(url=url), + ): + raise request_timeout + with ( + pytest.raises(expected_exception=RequestsTimeout), + propagating_request_timeouts(url=url), + ): + raise request_timeout + with ( + pytest.raises(expected_exception=RequestsTimeout), + propagating_request_timeouts(url=url), + ): + raise request_timeout assert calls == ["outer", "inner", "outer"] diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index ac5230a7e..a093f6562 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -10,7 +10,6 @@ import requests from beartype import beartype from PIL import Image -from requests.exceptions import Timeout as RequestsTimeout from requests.structures import CaseInsensitiveDict from vws.response import Response @@ -18,7 +17,9 @@ from tests.mock_vws.utils.model_target_retries import ( send_with_transient_retries, ) -from tests.mock_vws.utils.request_timeout_policy import handle_request_timeout +from tests.mock_vws.utils.request_timeout_policy import ( + propagating_request_timeouts, +) _REQUEST_TIMEOUT_SECONDS = 5 @@ -41,14 +42,11 @@ def _send_request( prepared_request = request.prepare() prepared_request.headers = CaseInsensitiveDict(data=headers) session = requests.Session() - try: + with propagating_request_timeouts(url=url): requests_response = session.send( request=prepared_request, timeout=_REQUEST_TIMEOUT_SECONDS, ) - except RequestsTimeout as error: - handle_request_timeout(url=url, error=error) - raise return Response( text=requests_response.text, url=requests_response.url, diff --git a/tests/mock_vws/utils/request_timeout_policy.py b/tests/mock_vws/utils/request_timeout_policy.py index f8d1b14fa..a7fd6f317 100644 --- a/tests/mock_vws/utils/request_timeout_policy.py +++ b/tests/mock_vws/utils/request_timeout_policy.py @@ -37,8 +37,17 @@ def handling_request_timeouts( _ = _TIMEOUT_HANDLERS.pop() +@contextlib.contextmanager @beartype -def handle_request_timeout(*, url: str, error: RequestsTimeout) -> None: - """Notify the backend before propagating a request timeout.""" - if bool(_TIMEOUT_HANDLERS): - _TIMEOUT_HANDLERS[-1](url=url, error=error) +def propagating_request_timeouts(*, url: str) -> Generator[None]: + """Apply the active policy to timeouts raised by a request. + + Yields: + ``None``, while the request runs. + """ + try: + yield + except RequestsTimeout as error: + if bool(_TIMEOUT_HANDLERS): + _TIMEOUT_HANDLERS[-1](url=url, error=error) + raise