From eaaca96dd0593a0c9244df09a66e356dbdd5b809 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:54:17 +0100 Subject: [PATCH 1/2] =?UTF-8?q?fix(sparql):=20POST=20403=20=E2=86=92=20fal?= =?UTF-8?q?lback=20GET=20(WAF/Virtuoso)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il plugin considerava 'ok' qualsiasi risposta HTTP (is_ok = response presente, err None) → il fallback GET non scattava mai su endpoint che rifiutano POST con 403 (es. dati.senato.it — accetta solo GET). Fix: - POST ok solo se status < 400 - fallback GET solo su 4xx (WAF/Virtuoso rifiutano POST, accettano GET) - 5xx solleva DownloadError con codice HTTP esplicito (non mascherare l'errore server col fallback) Test: test_sparql_fetch_post_403_falls_back_to_get (regressione, caso dati.senato.it reale). 25/25 sparql plugin + 9 contracts pass. --- tests/test_sparql_plugin.py | 27 +++++++++++++++++++++++ toolkit/plugins/sparql.py | 43 ++++++++++++++++++++++++++----------- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/tests/test_sparql_plugin.py b/tests/test_sparql_plugin.py index 769ee800..66048b7f 100644 --- a/tests/test_sparql_plugin.py +++ b/tests/test_sparql_plugin.py @@ -86,6 +86,33 @@ def test_sparql_fetch_http_error(): source.fetch("https://example.test/sparql", "SELECT * WHERE { }") +def test_sparql_fetch_post_403_falls_back_to_get(): + """POST 403 (WAF/Virtuoso) deve cadere sul fallback GET. + + Regressione: il plugin considerava ok il 403 (is_ok = response presente, + err None) e salvava il body HTML come CSV — il fallback GET non scattava. + Caso reale: dati.senato.it accetta solo GET (POST → 403). + """ + with patch("toolkit.plugins.sparql.HttpClient") as mock_cls: + mock_cls.return_value.post.return_value = _http_ok( + status=403, + text="Forbidden", + ) + 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: diff --git a/toolkit/plugins/sparql.py b/toolkit/plugins/sparql.py index 94d02d5c..a073c8fe 100644 --- a/toolkit/plugins/sparql.py +++ b/toolkit/plugins/sparql.py @@ -48,20 +48,39 @@ def _do_fetch(self, endpoint: str, q: str, accept_format: str) -> bytes: headers=headers, retries=2, ) - if result.is_ok: + # 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. + 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 --- - url = f"{endpoint}?query={urllib.parse.quote(q)}" - get_headers = { - "Accept": ( - "application/sparql-results+xml," - "application/sparql-results+json,application/json,text/csv" - ), - } - result = self._client.get(url, headers=get_headers) - if result.is_ok: - 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: + url = f"{endpoint}?query={urllib.parse.quote(q)}" + get_headers = { + "Accept": ( + "application/sparql-results+xml," + "application/sparql-results+json,application/json,text/csv" + ), + } + result = self._client.get(url, headers=get_headers) + get_status = ( + result.response.status_code + if result.is_ok and result.response is not None + else None + ) + if get_status is not None and get_status < 400: + return self._parse_response(result.response, is_json) + + if post_status is not None and post_status >= 500: + raise DownloadError( + f"SPARQL endpoint returned HTTP {post_status} for {endpoint}: " + f"{(result.response.text or '')[:200]}" + ) raise DownloadError( f"SPARQL request failed for {endpoint}: POST → {result.err or 'unknown'}" From d892a90d2a76b9711e2867fe814d15e8f7a9fffa Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:03:46 +0100 Subject: [PATCH 2/2] fix(sparql): type-safe body nel raise 5xx (mypy union-attr) Il fix mypy del CI segnalava 'Item None of ResponseLike|None has no attribute text' sul raise 5xx. Gestito il caso response=None. --- toolkit/plugins/sparql.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/toolkit/plugins/sparql.py b/toolkit/plugins/sparql.py index a073c8fe..de749fc5 100644 --- a/toolkit/plugins/sparql.py +++ b/toolkit/plugins/sparql.py @@ -77,9 +77,9 @@ def _do_fetch(self, endpoint: str, q: str, accept_format: str) -> bytes: return self._parse_response(result.response, is_json) if post_status is not None and post_status >= 500: + body = (result.response.text or "")[:200] if result.response is not None else "" raise DownloadError( - f"SPARQL endpoint returned HTTP {post_status} for {endpoint}: " - f"{(result.response.text or '')[:200]}" + f"SPARQL endpoint returned HTTP {post_status} for {endpoint}: {body}" ) raise DownloadError(