Skip to content

Commit 95593c6

Browse files
authored
Merge pull request #498 from rejasupotaro/raise-custom-error-with-message
Include Vespa error message in custom error raised
2 parents 1bff361 + 3e8d50e commit 95593c6

File tree

4 files changed

+119
-9
lines changed

4 files changed

+119
-9
lines changed

vespa/application.py

+31-7
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
from pandas import DataFrame
1313
from requests import Session
1414
from requests.models import Response
15-
from requests.exceptions import ConnectionError
15+
from requests.exceptions import ConnectionError, HTTPError, JSONDecodeError
1616
from requests.adapters import HTTPAdapter
1717
from urllib3.util import Retry
1818
from tenacity import retry, wait_exponential, stop_after_attempt
1919
from time import sleep
2020

21+
from vespa.exceptions import VespaError
2122
from vespa.io import VespaQueryResponse, VespaResponse
2223
from vespa.package import ApplicationPackage
2324

@@ -55,6 +56,29 @@ def parse_feed_df(df: DataFrame, include_id: bool, id_field="id") -> List[Dict[s
5556
return batch
5657

5758

59+
def raise_for_status(response: Response) -> None:
60+
"""
61+
Raises an appropriate error if necessary.
62+
63+
If the response contains an error message, VespaError is raised along with HTTPError to provide more details.
64+
65+
:param response: Response object from Vespa API.
66+
:raises HTTPError: If status_code is between 400 and 599.
67+
:raises VespaError: If the response JSON contains an error message.
68+
"""
69+
try:
70+
response.raise_for_status()
71+
except HTTPError as http_error:
72+
try:
73+
response_json = response.json()
74+
except JSONDecodeError:
75+
raise http_error
76+
errors = response_json.get("root", {}).get("errors", [])
77+
if not errors:
78+
raise http_error
79+
raise VespaError(errors) from http_error
80+
81+
5882
class Vespa(object):
5983
def __init__(
6084
self,
@@ -836,7 +860,7 @@ def feed_data_point(
836860
)
837861
vespa_format = {"fields": fields}
838862
response = self.http_session.post(end_point, json=vespa_format, cert=self.cert)
839-
response.raise_for_status()
863+
raise_for_status(response)
840864
return VespaResponse(
841865
json=response.json(),
842866
status_code=response.status_code,
@@ -858,7 +882,7 @@ def query(
858882
:raises HTTPError: if one occurred
859883
"""
860884
response = self.http_session.post(self.app.search_end_point, json=body, cert=self.cert)
861-
response.raise_for_status()
885+
raise_for_status(response)
862886
return VespaQueryResponse(
863887
json=response.json(), status_code=response.status_code, url=str(response.url)
864888
)
@@ -882,7 +906,7 @@ def delete_data(
882906
self.app.end_point, namespace, schema, str(data_id)
883907
)
884908
response = self.http_session.delete(end_point, cert=self.cert)
885-
response.raise_for_status()
909+
raise_for_status(response)
886910
return VespaResponse(
887911
json=response.json(),
888912
status_code=response.status_code,
@@ -909,7 +933,7 @@ def delete_all_docs(
909933
self.app.end_point, namespace, schema, content_cluster_name
910934
)
911935
response = self.http_session.delete(end_point, cert=self.cert)
912-
response.raise_for_status()
936+
raise_for_status(response)
913937
return response
914938

915939
def get_data(
@@ -931,7 +955,7 @@ def get_data(
931955
self.app.end_point, namespace, schema, str(data_id)
932956
)
933957
response = self.http_session.get(end_point, cert=self.cert)
934-
response.raise_for_status()
958+
raise_for_status(response)
935959
return VespaResponse(
936960
json=response.json(),
937961
status_code=response.status_code,
@@ -966,7 +990,7 @@ def update_data(
966990
)
967991
vespa_format = {"fields": {k: {"assign": v} for k, v in fields.items()}}
968992
response = self.http_session.put(end_point, json=vespa_format, cert=self.cert)
969-
response.raise_for_status()
993+
raise_for_status(response)
970994
return VespaResponse(
971995
json=response.json(),
972996
status_code=response.status_code,

vespa/exceptions.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
class VespaError(Exception):
2+
"""Vespa returned an error response"""

vespa/test_application.py

+84-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
22

3+
import json
34
import unittest
5+
import pytest
6+
from unittest.mock import PropertyMock, patch
47
from pandas import DataFrame
8+
from requests.models import HTTPError, Response
59

610
from vespa.package import ApplicationPackage, Schema, Document
7-
from vespa.application import Vespa, parse_feed_df
11+
from vespa.application import Vespa, parse_feed_df, raise_for_status
12+
from vespa.exceptions import VespaError
813

914

1015
class TestVespa(unittest.TestCase):
@@ -126,6 +131,84 @@ def test_parse_simplified_feed_batch_with_wrong_columns(self):
126131
_ = parse_feed_df(df=missing_id_df, include_id=True)
127132

128133

134+
135+
class TestRaiseForStatus(unittest.TestCase):
136+
def test_successful_response(self):
137+
response = Response()
138+
response.status_code = 200
139+
try:
140+
raise_for_status(response)
141+
except Exception as e:
142+
self.fail(f"No exceptions were expected to be raised but {type(e).__name__} occurred")
143+
144+
def test_successful_response_with_error_content(self):
145+
with patch("requests.models.Response.content", new_callable=PropertyMock) as mock_content:
146+
response_json = {
147+
"root": {
148+
"errors": [
149+
{"code": 1, "summary": "summary", "message": "message"},
150+
],
151+
},
152+
}
153+
mock_content.return_value = json.dumps(response_json).encode("utf-8")
154+
response = Response()
155+
response.status_code = 200
156+
try:
157+
raise_for_status(response)
158+
except Exception as e:
159+
self.fail(f"No exceptions were expected to be raised but {type(e).__name__} occurred")
160+
161+
def test_failure_response_for_400(self):
162+
response = Response()
163+
response.status_code = 400
164+
response.reason = "reason"
165+
response.url = "http://localhost:8080"
166+
with pytest.raises(HTTPError) as e:
167+
raise_for_status(response)
168+
self.assertEqual(str(e.value), "400 Client Error: reason for url: http://localhost:8080")
169+
170+
def test_failure_response_for_500(self):
171+
response = Response()
172+
response.status_code = 500
173+
response.reason = "reason"
174+
response.url = "http://localhost:8080"
175+
with pytest.raises(HTTPError) as e:
176+
raise_for_status(response)
177+
self.assertEqual(str(e.value), "500 Server Error: reason for url: http://localhost:8080")
178+
179+
def test_failure_response_without_error_content(self):
180+
with patch("requests.models.Response.content", new_callable=PropertyMock) as mock_content:
181+
response_json = {
182+
"root": {
183+
"errors": [],
184+
},
185+
}
186+
mock_content.return_value = json.dumps(response_json).encode("utf-8")
187+
response = Response()
188+
response.status_code = 400
189+
response.reason = "reason"
190+
response.url = "http://localhost:8080"
191+
with pytest.raises(HTTPError):
192+
raise_for_status(response)
193+
194+
def test_failure_response_with_error_content(self):
195+
with patch("requests.models.Response.content", new_callable=PropertyMock) as mock_content:
196+
response_json = {
197+
"root": {
198+
"errors": [
199+
{"code": 1, "summary": "summary", "message": "message"},
200+
],
201+
},
202+
}
203+
mock_content.return_value = json.dumps(response_json).encode("utf-8")
204+
response = Response()
205+
response.status_code = 400
206+
response.reason = "reason"
207+
response.url = "http://localhost:8080"
208+
with pytest.raises(VespaError):
209+
raise_for_status(response)
210+
211+
129212
class TestVespaCollectData(unittest.TestCase):
130213
def setUp(self) -> None:
131214
self.app = Vespa(url="http://localhost", port=8080)

vespa/test_integration_docker.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
from vespa.deployment import VespaDocker
2626
from vespa.application import VespaSync
27+
from vespa.exceptions import VespaError
2728

2829

2930
CONTAINER_STOP_TIMEOUT = 10
@@ -210,7 +211,7 @@ def redeploy_with_container_stopped(self, application_package):
210211
def redeploy_with_application_package_changes(self, application_package):
211212
self.vespa_docker = VespaDocker(port=8089)
212213
app = self.vespa_docker.deploy(application_package=application_package)
213-
with pytest.raises(HTTPError):
214+
with pytest.raises(VespaError):
214215
app.query(
215216
body={
216217
"yql": "select * from sources * where default contains 'music'",

0 commit comments

Comments
 (0)