Skip to content

Commit b116d26

Browse files
fix: bubble up sparql endpoint errors (#447)
* bubble up sparql endpoint errors * black
1 parent 3f71677 commit b116d26

File tree

4 files changed

+239
-4
lines changed

4 files changed

+239
-4
lines changed

prez/repositories/remote_sparql.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ async def _send_query(self, query: str, mediatype="text/turtle") -> httpx.Respon
4242
)
4343
try:
4444
response = await self.async_client.send(query_rq, stream=True)
45+
response.raise_for_status()
4546
return response
4647
except httpx.TimeoutException as e:
4748
timeout_msg = (
@@ -51,6 +52,10 @@ async def _send_query(self, query: str, mediatype="text/turtle") -> httpx.Respon
5152
timeout_msg += f" (sent '{settings.sparql_timeout_param_name}={settings.sparql_timeout}' to remote endpoint)"
5253
log.error(timeout_msg)
5354
raise httpx.TimeoutException(timeout_msg) from e
55+
except httpx.HTTPStatusError as e:
56+
# Read the response body for error details
57+
await e.response.aread()
58+
raise
5459

5560
async def rdf_query_to_rdflib_graph(
5661
self, query: str, into_graph: Graph | None = None

prez/services/exception_catchers.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,33 @@ async def catch_missing_filter_query_param(
107107

108108

109109
async def catch_httpx_error(request: Request, exc: httpx.HTTPError):
110+
# Handle HTTP status errors from SPARQL endpoint - pass through the actual error
111+
if isinstance(exc, httpx.HTTPStatusError):
112+
# Extract the actual status code and response from the SPARQL endpoint
113+
status_code = exc.response.status_code
114+
try:
115+
# Try to get the response text if available
116+
error_detail = (
117+
exc.response.text if hasattr(exc.response, "text") else str(exc)
118+
)
119+
except Exception:
120+
error_detail = str(exc)
121+
122+
return JSONResponse(
123+
status_code=status_code,
124+
content={
125+
"error": "SPARQL_ENDPOINT_ERROR",
126+
"detail": error_detail,
127+
},
128+
)
129+
110130
# Determine appropriate status code based on exception type
111-
if isinstance(exc, httpx.ConnectError):
112-
status_code = 503 # Service Unavailable
113-
error_type = "SPARQL_CONNECTION_ERROR"
114-
elif isinstance(exc, httpx.TimeoutException):
131+
if isinstance(exc, httpx.TimeoutException):
115132
status_code = 504 # Gateway Timeout
116133
error_type = "SPARQL_TIMEOUT_ERROR"
134+
elif isinstance(exc, httpx.ConnectError):
135+
status_code = 503 # Service Unavailable
136+
error_type = "SPARQL_CONNECTION_ERROR"
117137
else:
118138
status_code = 502 # Bad Gateway
119139
error_type = "SPARQL_ERROR"

tests/test_remote_sparql_timeout.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def test_send_query_with_timeout_param(self, remote_repo, mock_async_clien
2626
"""Test that timeout parameter is added to form data when configured."""
2727
# Setup
2828
mock_response = Mock(spec=httpx.Response)
29+
mock_response.raise_for_status = Mock() # Mock raise_for_status method
2930
mock_async_client.build_request.return_value = Mock()
3031
mock_async_client.send.return_value = mock_response
3132

@@ -51,6 +52,7 @@ async def test_send_query_without_timeout_param(
5152
"""Test that no timeout parameter is added when not configured."""
5253
# Setup
5354
mock_response = Mock(spec=httpx.Response)
55+
mock_response.raise_for_status = Mock() # Mock raise_for_status method
5456
mock_async_client.build_request.return_value = Mock()
5557
mock_async_client.send.return_value = mock_response
5658

@@ -119,6 +121,7 @@ async def test_sparql_post_with_timeout_param(self, remote_repo, mock_async_clie
119121
async def test_different_timeout_param_names(self, remote_repo, mock_async_client):
120122
"""Test various timeout parameter names work correctly."""
121123
mock_response = Mock(spec=httpx.Response)
124+
mock_response.raise_for_status = Mock() # Mock raise_for_status method
122125
mock_async_client.build_request.return_value = Mock()
123126
mock_async_client.send.return_value = mock_response
124127

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""Tests for SPARQL endpoint error passthrough functionality."""
2+
3+
import pytest
4+
from unittest.mock import AsyncMock, Mock, patch
5+
import httpx
6+
from fastapi import FastAPI
7+
from fastapi.testclient import TestClient
8+
from prez.repositories.remote_sparql import RemoteSparqlRepo
9+
from prez.services.exception_catchers import catch_httpx_error
10+
from prez.config import settings
11+
from starlette.requests import Request
12+
13+
14+
@pytest.fixture
15+
def mock_async_client():
16+
"""Mock httpx AsyncClient for testing."""
17+
return Mock(spec=httpx.AsyncClient)
18+
19+
20+
@pytest.fixture
21+
def remote_repo(mock_async_client):
22+
"""Create RemoteSparqlRepo with mocked client."""
23+
with patch.object(settings, "sparql_endpoint", "http://test-sparql-endpoint.com"):
24+
return RemoteSparqlRepo(mock_async_client)
25+
26+
27+
class TestSparqlErrorPassthrough:
28+
"""Test that SPARQL endpoint errors are passed through correctly."""
29+
30+
@pytest.mark.asyncio
31+
async def test_send_query_with_400_error(self, remote_repo, mock_async_client):
32+
"""Test that 400 errors from SPARQL endpoint are raised as HTTPStatusError."""
33+
# Setup mock response with 400 status code
34+
mock_response = Mock(spec=httpx.Response)
35+
mock_response.status_code = 400
36+
mock_response.text = "Invalid SPARQL query syntax"
37+
38+
# Mock aread to make response.text available
39+
async def mock_aread():
40+
return b"Invalid SPARQL query syntax"
41+
42+
mock_response.aread = mock_aread
43+
44+
# Make raise_for_status raise HTTPStatusError
45+
def raise_status_error():
46+
raise httpx.HTTPStatusError(
47+
"Client error '400 Bad Request' for url",
48+
request=Mock(),
49+
response=mock_response,
50+
)
51+
52+
mock_response.raise_for_status = raise_status_error
53+
mock_async_client.build_request.return_value = Mock()
54+
mock_async_client.send.return_value = mock_response
55+
56+
# Execute and verify HTTPStatusError is raised
57+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
58+
await remote_repo._send_query("INVALID QUERY")
59+
60+
assert exc_info.value.response.status_code == 400
61+
assert exc_info.value.response.text == "Invalid SPARQL query syntax"
62+
63+
@pytest.mark.asyncio
64+
async def test_send_query_with_500_error(self, remote_repo, mock_async_client):
65+
"""Test that 500 errors from SPARQL endpoint are raised as HTTPStatusError."""
66+
# Setup mock response with 500 status code
67+
mock_response = Mock(spec=httpx.Response)
68+
mock_response.status_code = 500
69+
mock_response.text = "Internal SPARQL server error: query execution failed"
70+
71+
# Mock aread to make response.text available
72+
async def mock_aread():
73+
return b"Internal SPARQL server error: query execution failed"
74+
75+
mock_response.aread = mock_aread
76+
77+
# Make raise_for_status raise HTTPStatusError
78+
def raise_status_error():
79+
raise httpx.HTTPStatusError(
80+
"Server error '500 Internal Server Error' for url",
81+
request=Mock(),
82+
response=mock_response,
83+
)
84+
85+
mock_response.raise_for_status = raise_status_error
86+
mock_async_client.build_request.return_value = Mock()
87+
mock_async_client.send.return_value = mock_response
88+
89+
# Execute and verify HTTPStatusError is raised
90+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
91+
await remote_repo._send_query("SELECT * WHERE { ?s ?p ?o }")
92+
93+
assert exc_info.value.response.status_code == 500
94+
assert "Internal SPARQL server error" in exc_info.value.response.text
95+
96+
@pytest.mark.asyncio
97+
async def test_exception_handler_passes_through_status_code(self):
98+
"""Test that catch_httpx_error handler passes through the SPARQL endpoint status code."""
99+
# Create mock request
100+
mock_request = Mock(spec=Request)
101+
102+
# Create mock response with error
103+
mock_response = Mock(spec=httpx.Response)
104+
mock_response.status_code = 400
105+
mock_response.text = "Malformed SPARQL query"
106+
107+
# Create HTTPStatusError
108+
exc = httpx.HTTPStatusError(
109+
"Client error '400 Bad Request' for url",
110+
request=Mock(),
111+
response=mock_response,
112+
)
113+
114+
# Call the exception handler
115+
response = await catch_httpx_error(mock_request, exc)
116+
117+
# Verify response has correct status code and error details
118+
assert response.status_code == 400
119+
assert response.body is not None
120+
121+
# Parse response body
122+
import json
123+
124+
body = json.loads(response.body)
125+
assert body["error"] == "SPARQL_ENDPOINT_ERROR"
126+
assert "Malformed SPARQL query" in body["detail"]
127+
128+
@pytest.mark.asyncio
129+
async def test_exception_handler_passes_through_500_status(self):
130+
"""Test that catch_httpx_error handler passes through 500 status code."""
131+
mock_request = Mock(spec=Request)
132+
133+
mock_response = Mock(spec=httpx.Response)
134+
mock_response.status_code = 500
135+
mock_response.text = "Database connection failed"
136+
137+
exc = httpx.HTTPStatusError(
138+
"Server error '500 Internal Server Error' for url",
139+
request=Mock(),
140+
response=mock_response,
141+
)
142+
143+
response = await catch_httpx_error(mock_request, exc)
144+
145+
assert response.status_code == 500
146+
147+
import json
148+
149+
body = json.loads(response.body)
150+
assert body["error"] == "SPARQL_ENDPOINT_ERROR"
151+
assert "Database connection failed" in body["detail"]
152+
153+
@pytest.mark.asyncio
154+
async def test_exception_handler_still_handles_timeout(self):
155+
"""Test that catch_httpx_error still properly handles timeout exceptions."""
156+
mock_request = Mock(spec=Request)
157+
158+
exc = httpx.TimeoutException("Request timed out after 30 seconds")
159+
160+
response = await catch_httpx_error(mock_request, exc)
161+
162+
assert response.status_code == 504
163+
164+
import json
165+
166+
body = json.loads(response.body)
167+
assert body["error"] == "SPARQL_TIMEOUT_ERROR"
168+
assert "timed out" in body["detail"]
169+
170+
@pytest.mark.asyncio
171+
async def test_exception_handler_still_handles_connect_error(self):
172+
"""Test that catch_httpx_error still properly handles connection errors."""
173+
mock_request = Mock(spec=Request)
174+
175+
exc = httpx.ConnectError("Connection refused")
176+
177+
response = await catch_httpx_error(mock_request, exc)
178+
179+
assert response.status_code == 503
180+
181+
import json
182+
183+
body = json.loads(response.body)
184+
assert body["error"] == "SPARQL_CONNECTION_ERROR"
185+
assert "Connection refused" in body["detail"]
186+
187+
@pytest.mark.asyncio
188+
async def test_send_query_success_does_not_raise(
189+
self, remote_repo, mock_async_client
190+
):
191+
"""Test that successful queries don't raise exceptions."""
192+
# Setup successful mock response
193+
mock_response = Mock(spec=httpx.Response)
194+
mock_response.status_code = 200
195+
mock_response.raise_for_status = Mock() # Does nothing on success
196+
197+
async def mock_aread():
198+
return b"<rdf>...</rdf>"
199+
200+
mock_response.aread = mock_aread
201+
202+
mock_async_client.build_request.return_value = Mock()
203+
mock_async_client.send.return_value = mock_response
204+
205+
# Should not raise any exception
206+
result = await remote_repo._send_query("SELECT * WHERE { ?s ?p ?o }")
207+
assert result == mock_response

0 commit comments

Comments
 (0)