Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions tests/test_sparql_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,50 @@ def test_sparql_fetch_post_403_falls_back_to_get():
assert mock_cls.return_value.get.called # il fallback GET è scattato


def test_sparql_fetch_get_fallback_5xx_raises():
"""POST 403 → GET fallback 500 → DownloadError con status del GET.

Il fallback GET non deve mascherare un errore server del GET stesso.
"""
with patch("toolkit.plugins.sparql.HttpClient") as mock_cls:
mock_cls.return_value.post.return_value = _http_ok(
status=403,
text="<!DOCTYPE html><html>Forbidden</html>",
)
mock_cls.return_value.get.return_value = _http_ok(
status=500,
text="Internal Server Error",
)
source = SparqlSource()
with pytest.raises(DownloadError, match="GET fallback returned HTTP 500"):
source.fetch("https://example.test/sparql", "SELECT * WHERE { }")
"""POST con errore di rete/timeout (response None) deve cadere sul GET.

Estensione del fix 403: il docstring prometteva il fallback anche su
timeout — il POST con err (connection refused) produce post_status=None
e prima del fix andava dritto al raise senza provare il GET.
"""
from lab_connectors.http import HttpResult

with patch("toolkit.plugins.sparql.HttpClient") as mock_cls:
mock_cls.return_value.post.return_value = HttpResult(
response=None, err=TimeoutError("connection refused")
)
mock_cls.return_value.get.return_value = _http_ok(
status=200,
text="name,value\nfoo,123\n",
headers={"Content-Type": "text/csv"},
)
source = SparqlSource()
payload, origin = source.fetch(
"https://dati.senato.it/sparql",
"SELECT ?name ?value WHERE { }",
accept_format="csv",
)
assert b"foo" in payload
assert mock_cls.return_value.get.called # il fallback GET è scattato


def test_sparql_fetch_network_error():
"""Network error raises DownloadError."""
with patch("toolkit.plugins.sparql.HttpClient") as mock_cls:
Expand Down
15 changes: 11 additions & 4 deletions toolkit/plugins/sparql.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,18 @@ def _do_fetch(self, endpoint: str, q: str, accept_format: str) -> bytes:
)
# is_ok è True anche su errori HTTP (response presente, err None) —
# ma una risposta 4xx/5xx NON è un risultato SPARQL valido.
# Fallback GET solo su 4xx (WAF/Virtuoso rifiutano POST): 403 è il
# caso tipico. Su 5xx il fallback non deve mascherare l'errore server.
# Fallback GET quando il POST non produce dati validi:
# - 4xx (WAF/Virtuoso rifiutano POST, es. 403 Senato)
# - errore di rete/timeout (post_status None)
# Su 5xx NON si fa fallback (errore server reale, non va mascherato).
post_status = (
result.response.status_code if result.is_ok and result.response is not None else None
)
if post_status is not None and post_status < 400:
return self._parse_response(result.response, is_json)

# --- Tentativo 2: GET fallback (solo se POST è fallito con 4xx) ---
if post_status is not None and 400 <= post_status < 500:
# --- Tentativo 2: GET fallback (4xx o errore di rete/timeout) ---
if post_status is None or 400 <= post_status < 500:
url = f"{endpoint}?query={urllib.parse.quote(q)}"
get_headers = {
"Accept": (
Expand All @@ -75,6 +77,11 @@ def _do_fetch(self, endpoint: str, q: str, accept_format: str) -> bytes:
)
if get_status is not None and get_status < 400:
return self._parse_response(result.response, is_json)
if get_status is not None and get_status >= 500:
body = (result.response.text or "")[:200] if result.response is not None else ""
raise DownloadError(
f"SPARQL GET fallback returned HTTP {get_status} for {endpoint}: {body}"
)

if post_status is not None and post_status >= 500:
body = (result.response.text or "")[:200] if result.response is not None else ""
Expand Down
Loading