Skip to content

Commit 3252731

Browse files
authored
Merge pull request crs4#178 from kikkomep/fix/http-requester-handling
fix(http): reconfigure HttpRequester singleton in place instead of resetting it
2 parents ded0279 + 877ea8d commit 3252731

3 files changed

Lines changed: 212 additions & 17 deletions

File tree

rocrate_validator/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2699,9 +2699,9 @@ def __post_init__(self):
26992699
"Offline mode enabled without a persistent cache path: "
27002700
"all HTTP-backed resources will fail unless pre-populated."
27012701
)
2702-
# Reset any previously initialized singleton so new settings take effect.
2703-
HttpRequester.reset()
2704-
# initialize the HTTP cache
2702+
# Re-apply the cache settings to the HTTP requester. ``initialize_cache``
2703+
# reconfigures the existing singleton in place (rather than dropping it),
2704+
# so new settings take effect without discarding state set on the instance.
27052705
HttpRequester.initialize_cache(
27062706
cache_path=str(self.cache_path) if self.cache_path is not None else None,
27072707
cache_max_age=self.cache_max_age,

rocrate_validator/utils/http.py

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,13 @@ def __getattr__(self, name):
232232
"""
233233
if name.upper() in {"GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"}:
234234
method = name.lower()
235-
session_method = getattr(self.session, method)
236235

237236
def _wrapped(url, *args, **kwargs):
237+
# Resolve the session method lazily, at call time, so the wrapper
238+
# always targets the current session. This keeps the wrapper valid
239+
# after the session is rebuilt in place (see ``_reconfigure``) and
240+
# avoids holding a reference to a closed session.
241+
session_method = getattr(self.session, method)
238242
response = session_method(url, *args, **kwargs)
239243
_log_cache_outcome(method.upper(), url, response, offline=self.offline)
240244
return response
@@ -357,8 +361,55 @@ def initialize_cache(cls,
357361
:param no_cache: When ``True``, disable the HTTP cache entirely and
358362
use a plain ``requests.Session``. Incompatible with ``offline``.
359363
"""
360-
return cls(cache_max_age=cache_max_age, cache_path=cache_path,
361-
offline=offline, no_cache=no_cache)
364+
with cls._lock:
365+
instance = cls._instance
366+
if instance is None:
367+
return cls(cache_max_age=cache_max_age, cache_path=cache_path,
368+
offline=offline, no_cache=no_cache)
369+
# Re-apply the configuration without recreating the instance:
370+
# we keep the same singleton in place and only rebuild its underlying session,
371+
# rather than dropping and recreating the object (as ``reset`` does).
372+
instance._reconfigure(cache_max_age=cache_max_age, cache_path=cache_path,
373+
offline=offline, no_cache=no_cache)
374+
return instance
375+
376+
def _close_session(self) -> None:
377+
"""Close the current session and remove its cache file if it is temporary."""
378+
session = getattr(self, "session", None)
379+
if session is not None and hasattr(session, "close"):
380+
try:
381+
session.close()
382+
except Exception as e:
383+
logger.debug("Error closing previous session: %s", e)
384+
if getattr(self, "permanent_cache", True) is False:
385+
try:
386+
self.cleanup()
387+
except Exception as e:
388+
logger.debug("Error cleaning up previous cache: %s", e)
389+
390+
def _reconfigure(self,
391+
cache_max_age: int = constants.DEFAULT_HTTP_CACHE_MAX_AGE,
392+
cache_path: Optional[str] = None,
393+
offline: bool = False,
394+
no_cache: bool = False) -> None:
395+
"""
396+
Rebuild the underlying session with new cache settings while preserving
397+
the singleton instance (and any attributes set on it, e.g. test patches).
398+
"""
399+
with self._lock:
400+
self._close_session()
401+
try:
402+
self.cache_max_age = int(cache_max_age)
403+
except ValueError:
404+
raise TypeError("cache_max_age must be an integer")
405+
self.cache_path_prefix = cache_path
406+
self.offline = bool(offline)
407+
self.no_cache = bool(no_cache)
408+
self.permanent_cache = cache_path is not None
409+
# ``__initialize_session__`` asserts the instance is not yet initialized.
410+
self._initialized = False
411+
self.__initialize_session__(cache_max_age, cache_path)
412+
self._initialized = True
362413

363414
@classmethod
364415
def reset(cls) -> None:
@@ -369,17 +420,7 @@ def reset(cls) -> None:
369420
with cls._lock:
370421
instance = cls._instance
371422
if instance is not None:
372-
try:
373-
session = getattr(instance, "session", None)
374-
if session is not None and hasattr(session, "close"):
375-
session.close()
376-
except Exception as e:
377-
logger.debug("Error closing previous session: %s", e)
378-
if getattr(instance, "permanent_cache", True) is False:
379-
try:
380-
instance.cleanup()
381-
except Exception as e:
382-
logger.debug("Error cleaning up previous cache: %s", e)
423+
instance._close_session()
383424
cls._instance = None
384425

385426

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# Copyright (c) 2024-2026 CRS4
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from unittest.mock import MagicMock
18+
19+
import pytest
20+
21+
from rocrate_validator.utils.http import HttpRequester
22+
23+
24+
@pytest.fixture(autouse=True)
25+
def _reset_singleton():
26+
HttpRequester.reset()
27+
yield
28+
HttpRequester.reset()
29+
30+
31+
def _initialize(cache_path, offline=False, cache_max_age=-1):
32+
return HttpRequester.initialize_cache(
33+
cache_path=str(cache_path),
34+
cache_max_age=cache_max_age,
35+
offline=offline,
36+
)
37+
38+
39+
def _fake_session(status_code=200):
40+
"""A session-like mock whose ``get`` returns a sentinel response."""
41+
session = MagicMock()
42+
response = MagicMock(status_code=status_code, from_cache=False)
43+
session.get.return_value = response
44+
return session, response
45+
46+
47+
def test_initialize_cache_creates_instance_when_absent(tmp_path):
48+
assert HttpRequester._instance is None
49+
requester = _initialize(tmp_path / "cache")
50+
assert isinstance(requester, HttpRequester)
51+
assert HttpRequester._instance is requester
52+
53+
54+
def test_initialize_cache_reuses_existing_instance(tmp_path):
55+
first = _initialize(tmp_path / "cache-1")
56+
second = _initialize(tmp_path / "cache-2")
57+
# The singleton is reconfigured in place rather than recreated.
58+
assert second is first
59+
60+
61+
def test_reconfigure_applies_new_settings(tmp_path):
62+
requester = _initialize(tmp_path / "cache", offline=False, cache_max_age=60)
63+
assert requester.offline is False
64+
65+
same = _initialize(tmp_path / "cache", offline=True, cache_max_age=-1)
66+
assert same is requester
67+
assert same.offline is True
68+
# Offline mode is enforced on the freshly rebuilt session.
69+
assert getattr(same.session.settings, "only_if_cached", False) is True
70+
71+
72+
def test_reconfigure_rebuilds_underlying_session(tmp_path):
73+
requester = _initialize(tmp_path / "cache-1", cache_max_age=60)
74+
old_session = requester.session
75+
_initialize(tmp_path / "cache-2", cache_max_age=60)
76+
assert requester.session is not old_session
77+
78+
79+
def test_reconfigure_preserves_instance_attributes(tmp_path):
80+
"""Regression: reconfiguring the cache must not discard state set on the
81+
singleton (e.g. methods patched by tests)."""
82+
requester = _initialize(tmp_path / "cache-1", cache_max_age=60)
83+
sentinel = object()
84+
requester.custom_marker = sentinel
85+
86+
_initialize(tmp_path / "cache-2", cache_max_age=60)
87+
88+
assert requester.custom_marker is sentinel
89+
90+
91+
def test_method_wrapper_targets_current_session(tmp_path):
92+
"""The ``__getattr__`` HTTP wrappers resolve the session at call time, so a
93+
wrapper obtained before a session swap still hits the live session."""
94+
requester = _initialize(tmp_path / "cache", cache_max_age=60)
95+
96+
first_session, _ = _fake_session()
97+
requester.session = first_session
98+
wrapper = requester.get # captured before swapping the session
99+
100+
second_session, expected = _fake_session(status_code=201)
101+
requester.session = second_session
102+
103+
result = wrapper("https://example.org/x")
104+
105+
assert result is expected
106+
second_session.get.assert_called_once()
107+
first_session.get.assert_not_called()
108+
109+
110+
def test_pinned_wrapper_survives_reconfigure(tmp_path):
111+
"""Mimics how ``pytest.monkeypatch`` teardown leaves a method wrapper pinned
112+
as an instance attribute: after a reconfigure rebuilds the session, that
113+
wrapper must still target the live session, not a closed one."""
114+
requester = _initialize(tmp_path / "cache-1", cache_max_age=60)
115+
requester.get = requester.get # pin the wrapper as an instance attribute
116+
117+
_initialize(tmp_path / "cache-2", cache_max_age=60) # rebuilds the session
118+
119+
mock_session, expected = _fake_session()
120+
requester.session = mock_session
121+
122+
result = requester.get("https://example.org/x")
123+
124+
assert result is expected
125+
mock_session.get.assert_called_once()
126+
127+
128+
def test_reset_drops_instance(tmp_path):
129+
requester = _initialize(tmp_path / "cache", cache_max_age=60)
130+
HttpRequester.reset()
131+
assert HttpRequester._instance is None
132+
# A subsequent initialization yields a brand-new instance.
133+
assert _initialize(tmp_path / "cache", cache_max_age=60) is not requester
134+
135+
136+
def test_validation_settings_preserves_singleton(tmp_path):
137+
"""Constructing ``ValidationSettings`` reconfigures the cache in place and
138+
must not drop the existing requester (nor any state held on it)."""
139+
from rocrate_validator.models import ValidationSettings
140+
from rocrate_validator.utils.uri import URI
141+
142+
requester = _initialize(tmp_path / "cache", cache_max_age=60)
143+
marker = object()
144+
requester.custom_marker = marker
145+
146+
# ``offline=True`` keeps the construction self-contained (no warm-up/network).
147+
ValidationSettings(
148+
rocrate_uri=URI("."),
149+
offline=True,
150+
cache_path=tmp_path / "cache",
151+
)
152+
153+
assert HttpRequester._instance is requester
154+
assert requester.custom_marker is marker

0 commit comments

Comments
 (0)