From 178b286692368f002a438772dfaa187c759d3481 Mon Sep 17 00:00:00 2001 From: Hanna Seithe Date: Wed, 5 Aug 2026 09:20:11 +0200 Subject: [PATCH] Add timeout for textlab API call --- integreat_cms/cms/views/utils/hix.py | 7 ++++ integreat_cms/core/settings.py | 6 +++ .../textlab_api/textlab_api_client.py | 2 +- tests/cms/views/utils/test_hix.py | 41 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/integreat_cms/cms/views/utils/hix.py b/integreat_cms/cms/views/utils/hix.py index d0c8cbe1ba..d533e86472 100644 --- a/integreat_cms/cms/views/utils/hix.py +++ b/integreat_cms/cms/views/utils/hix.py @@ -79,6 +79,13 @@ def lookup_hix_score_helper(text: str) -> TextlabResult: settings.TEXTLAB_API_USERNAME, settings.TEXTLAB_API_KEY, ).benchmark(normalized_text) + except TimeoutError as e: + logger.warning( + "HIX benchmark API call timed out after %s seconds: %r", + settings.TEXTLAB_API_TIMEOUT, + e, + ) + raise CacheMeIfYouCan from e except (URLError, OSError) as e: logger.warning("HIX benchmark API call failed: %r", e) raise CacheMeIfYouCan from e diff --git a/integreat_cms/core/settings.py b/integreat_cms/core/settings.py index 7d5a0a8a8b..2d2db99eb8 100644 --- a/integreat_cms/core/settings.py +++ b/integreat_cms/core/settings.py @@ -358,6 +358,12 @@ 420, ) +#: How many seconds to wait for the Textlab API to respond before giving up +TEXTLAB_API_TIMEOUT: Final[float] = env_float( + "INTEGREAT_CMS_TEXTLAB_API_TIMEOUT", + 30, +) + #: The minimum HIX score required for machine translation HIX_REQUIRED_FOR_MT: Final[float] = env_float( "INTEGREAT_CMS_HIX_REQUIRED_FOR_MT", diff --git a/integreat_cms/textlab_api/textlab_api_client.py b/integreat_cms/textlab_api/textlab_api_client.py index 3d4adb4a27..cde9861fc7 100644 --- a/integreat_cms/textlab_api/textlab_api_client.py +++ b/integreat_cms/textlab_api/textlab_api_client.py @@ -113,5 +113,5 @@ def post_request( request.add_header("authorization", f"Bearer {auth_token}") request.add_header("Content-Type", "application/json") request.add_header("User-Agent", "") - with urlopen(request) as response: # noqa: S310 + with urlopen(request, timeout=settings.TEXTLAB_API_TIMEOUT) as response: # noqa: S310 return json.loads(response.read().decode("utf-8")) diff --git a/tests/cms/views/utils/test_hix.py b/tests/cms/views/utils/test_hix.py index 858a9a1ba0..f5ae94d4cf 100644 --- a/tests/cms/views/utils/test_hix.py +++ b/tests/cms/views/utils/test_hix.py @@ -1,6 +1,9 @@ from __future__ import annotations import math +import socket +import threading +import time from copy import deepcopy from typing import TYPE_CHECKING @@ -349,3 +352,41 @@ def test_contact_card_gets_filtered_out() -> None: assert result is not None assert result["score"] is None + + +def test_hix_lookup_times_out_instead_of_hanging( + settings: SettingsWrapper, +) -> None: + """ + If the Textlab API accepts the connection but never responds, ``lookup_hix_score()`` + must give up after :attr:`~integreat_cms.core.settings.TEXTLAB_API_TIMEOUT` instead + of blocking forever (see #). + """ + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + + def black_hole() -> None: + with listener: + conn, _ = listener.accept() + with conn: + # Deliberately never send a response, simulating a stalled API + time.sleep(2) + + threading.Thread(target=black_hole, daemon=True).start() + + settings.TEXTLAB_API_ENABLED = True + settings.TEXTLAB_API_URL = f"http://127.0.0.1:{port}" + settings.TEXTLAB_API_TIMEOUT = 0.2 + + start = time.monotonic() + result = lookup_hix_score( + "Unique text for test_hix_lookup_times_out_instead_of_hanging", + ) + elapsed = time.monotonic() - start + + assert result is None + assert elapsed < 1, ( + "lookup_hix_score() should give up after TEXTLAB_API_TIMEOUT, not hang" + )