From 39b396efb45fc812332624cf9f5be5ef12734602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20Snoksrud?= Date: Wed, 17 Jun 2026 11:35:39 +0200 Subject: [PATCH 1/2] fix: add request timeouts to prevent indefinite hangs on half-open TCP connections Set a default (10s connect, 2min read) timeout on every requests.Session via functools.partial so all API calls are covered without per-call-site changes. job.run() overrides this with (30s connect, 30min read) since job execution can legitimately take up to 30 minutes. Applied to both NovemAPI and NovemGQL which manages its own session independently. Closes #239 --- novem/api_ref.py | 2 ++ novem/cli/gql.py | 2 ++ novem/job/__init__.py | 3 ++- tests/test_job.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/novem/api_ref.py b/novem/api_ref.py index 66998a2..561c9db 100644 --- a/novem/api_ref.py +++ b/novem/api_ref.py @@ -1,3 +1,4 @@ +import functools import os import sys import urllib.request @@ -132,6 +133,7 @@ def __init__( self._session = requests.Session() self._session.headers.update(get_ua(is_cli)) self._session.proxies = urllib.request.getproxies() + self._session.request = functools.partial(self._session.request, timeout=(10, 120)) if cfg.ignore_ssl: # supress ssl warnings diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 5cbe6f0..6b06ed9 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -6,6 +6,7 @@ """ import datetime +import functools import json import re import shutil @@ -35,6 +36,7 @@ def __init__(self, *, debug: bool = False, gql: Any = False, **connection: Any) _, config = get_current_config(**connection) self._config = config self._session = requests.Session() + self._session.request = functools.partial(self._session.request, timeout=(10, 120)) self._debug = debug # Support both old gql_debug and new gql parameter for debug mode self._gql_debug = gql is True # True when --gql with no argument diff --git a/novem/job/__init__.py b/novem/job/__init__.py index ad94e50..15be271 100644 --- a/novem/job/__init__.py +++ b/novem/job/__init__.py @@ -415,7 +415,7 @@ def run( ] if self._debug: print(f" files in: {len(upload)} ({list(upload.keys())})") - r = self._session.post(path, files=multipart, stream=bool(output)) + r = self._session.post(path, files=multipart, stream=bool(output), timeout=(30, 1800)) else: if self._debug: print(" files in: 0") @@ -424,6 +424,7 @@ def run( headers={"Content-type": "application/json; charset=utf-8"}, data="{}", stream=bool(output), + timeout=(30, 1800), ) if not r.ok: diff --git a/tests/test_job.py b/tests/test_job.py index d72b866..8401194 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -5,6 +5,8 @@ from functools import partial from unittest.mock import patch +from requests import Response + import pytest from novem import Job @@ -1015,3 +1017,48 @@ def test_dedup_path_with_conflicts(tmp_path): open(tmp_path / "file (1).txt", "w").close() assert _Job._dedup_path(str(tmp_path), "file.txt") == os.path.join(str(tmp_path), "file (2).txt") + + +# --------------------------------------------------------------------------- +# timeout tests +# --------------------------------------------------------------------------- + + +def _ok_response(content=b""): + r = Response() + r.status_code = 200 + r._content = content + return r + + +def test_session_default_timeout(requests_mock): + """Default (10, 120) timeout reaches session.send for ordinary API calls.""" + j, _ = _make_job(requests_mock) + + with patch.object(j._session, "send", return_value=_ok_response(b"ok")) as mock_send: + j.api_read("/log") + + assert mock_send.call_args.kwargs.get("timeout") == (10, 120) + + +def test_job_run_no_files_uses_job_timeout(requests_mock): + """run() without files passes timeout=(30, 1800) through to session.send.""" + j, _ = _make_job(requests_mock) + + with patch.object(j._session, "send", return_value=_ok_response()) as mock_send: + j.run() + + assert mock_send.call_args.kwargs.get("timeout") == (30, 1800) + + +def test_job_run_with_files_uses_job_timeout(requests_mock, tmp_path): + """run() with files passes timeout=(30, 1800) through to session.send.""" + j, _ = _make_job(requests_mock) + + f1 = tmp_path / "data.csv" + f1.write_text("a,b\n1,2\n") + + with patch.object(j._session, "send", return_value=_ok_response()) as mock_send: + j.run(files=[f"@{f1}"]) + + assert mock_send.call_args.kwargs.get("timeout") == (30, 1800) From 47960a1094b84ca04736c1e7acc2e969f5297fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20Snoksrud?= Date: Wed, 17 Jun 2026 12:02:15 +0200 Subject: [PATCH 2/2] fix: satisfy mypy and isort lint for request timeout change --- novem/api_ref.py | 4 +++- novem/cli/gql.py | 4 +++- tests/test_job.py | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/novem/api_ref.py b/novem/api_ref.py index 561c9db..839573d 100644 --- a/novem/api_ref.py +++ b/novem/api_ref.py @@ -133,7 +133,9 @@ def __init__( self._session = requests.Session() self._session.headers.update(get_ua(is_cli)) self._session.proxies = urllib.request.getproxies() - self._session.request = functools.partial(self._session.request, timeout=(10, 120)) + self._session.request = functools.partial( # type: ignore[method-assign] + self._session.request, timeout=(10, 120) + ) if cfg.ignore_ssl: # supress ssl warnings diff --git a/novem/cli/gql.py b/novem/cli/gql.py index 6b06ed9..b0a4f40 100644 --- a/novem/cli/gql.py +++ b/novem/cli/gql.py @@ -36,7 +36,9 @@ def __init__(self, *, debug: bool = False, gql: Any = False, **connection: Any) _, config = get_current_config(**connection) self._config = config self._session = requests.Session() - self._session.request = functools.partial(self._session.request, timeout=(10, 120)) + self._session.request = functools.partial( # type: ignore[method-assign] + self._session.request, timeout=(10, 120) + ) self._debug = debug # Support both old gql_debug and new gql parameter for debug mode self._gql_debug = gql is True # True when --gql with no argument diff --git a/tests/test_job.py b/tests/test_job.py index 8401194..7819e1e 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -5,9 +5,8 @@ from functools import partial from unittest.mock import patch -from requests import Response - import pytest +from requests import Response from novem import Job from novem.exceptions import Novem403, Novem404