Skip to content

Commit

Permalink
Further error-handling improvements. Bump to v0.0.15.
Browse files Browse the repository at this point in the history
  • Loading branch information
davepeck committed Sep 23, 2024
1 parent 14c35e1 commit ac79d04
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 52 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
description = "Tools for online voter registration in the United States of America"
name = "voter-tools"
version = "0.0.14"
version = "0.0.15"
readme = "README.md"
requires-python = ">=3.11"
authors = [{ name = "Dave Peck", email = "dave@frontseat.org" }]
Expand Down
37 changes: 37 additions & 0 deletions tests/pa/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,20 @@ def handler(request: httpx.Request):
_ = client.set_application(application)
self.assertEqual(ctx.exception.errors()[0].type, "unexpected")

def test_empty_failure_do_not_raise(self):
"""Test behavior when API returns an empty response."""

# Unfortunately, this *does* happen. Inexplicably, at least in
# the sandbox.
def handler(request: httpx.Request):
xml_response = """<RESPONSE></RESPONSE>"""
return httpx.Response(200, json=xml_response)

application = self._valid_application()
client = self._client(handler)
response = client.set_application(application, raise_validation_error=False)
self.assertTrue(response.has_error())

def test_validation_error(self):
"""Test behavior when API returns a validation error response."""

Expand Down Expand Up @@ -865,4 +879,27 @@ def handler(request: httpx.Request):
application = self._valid_application()
client = self._client(handler)
with self.assertRaises(InvalidAccessKeyError):
# Set raise_exception INTENTIONALLY to false
_ = client.set_application(application)

def test_unparsable_response_xml(self):
"""Test behavior when API returns an unparsable response."""

def handler(request: httpx.Request):
return httpx.Response(200, content=b"not xml")

application = self._valid_application()
client = self._client(handler)
with self.assertRaises(ValueError):
_ = client.set_application(application)

def test_unparsable_response_xml_root(self):
"""Test behavior when API returns an unparsable response."""

def handler(request: httpx.Request):
return httpx.Response(200, content=b"<BINGO />")

application = self._valid_application()
client = self._client(handler)
with self.assertRaises(ValueError):
_ = client.set_application(application)
119 changes: 68 additions & 51 deletions voter_tools/pa/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
APIError,
APIValidationError,
ServiceUnavailableError,
UnparsableResponseError,
build_error_for_codes,
)

Expand Down Expand Up @@ -1524,7 +1525,7 @@ def build_url(self, action: Action, params: dict | None = None) -> str:
encoded = urlencode(query_prefix)
return f"{self.api_url}?JSONv2&{encoded}"

def _raw_get(self, action: Action, params: dict | None = None) -> str:
def _get(self, action: Action, params: dict | None = None) -> str:
"""Perform a raw GET request to the Pennsylvania OVR API."""
url = self.build_url(action, params)
try:
Expand All @@ -1542,7 +1543,7 @@ def _raw_get(self, action: Action, params: dict | None = None) -> str:
) from e
return response.json()

def _raw_post(
def _post(
self,
action: Action,
data: XmlElement | str, # type: ignore
Expand All @@ -1565,7 +1566,6 @@ def _raw_post(
assert isinstance(data_str, str), f"DATA was an unexpected type: {type(data)}"

data_jsonable = {"ApplicationData": data_str}
# print("TODO DAVE remove this: DATA jsonable: ", data_jsonable)
try:
response = self._client.post(
url,
Expand All @@ -1583,37 +1583,6 @@ def _raw_post(
raise APIError("Unexpected status code. Please try again later.") from e
return response.json()

def _get(self, action: Action, params: dict | None = None) -> XmlElement:
"""Perform a GET request to the Pennsylvania OVR API."""
raw = self._raw_get(action, params)
try:
return xml_fromstring(raw) # type: ignore
except Exception as e:
raise APIError("Unparsable response.") from e

def _post(
self, action: Action, data: XmlElement | str, params: dict | None = None
) -> XmlElement:
"""Perform a POST request to the Pennsylvania OVR API."""
raw = self._raw_post(action, data, params)
try:
return xml_fromstring(raw) # type: ignore
except Exception as e:
raise APIError("Unparsable response.") from e

def _raise_if_error(self, xml_response: XmlElement) -> None:
"""Raise an APIError or subclass if the xml_response indicates an error."""
# Regardless of the specific invocation we just made, the API *always*
# returns a "generic" API response when there's an error code.
try:
# ET.indent(xml_response)
# ET.dump(xml_response)
api_response = APIResponse.from_xml_tree(xml_response)
api_response.raise_for_error()
except px.ParsingError:
# The response was not a "generic" response so there was no error.
pass

def invoke(
self,
action: Action,
Expand All @@ -1625,28 +1594,39 @@ def invoke(
Look for errors in the response and raise an exception if one is found.
"""
response_data = (
self._post(action, data, params)
if data is not None
else self._get(action, params)
raw = (
self._get(action, params)
if data is None
else self._post(action, data, params)
)
self._raise_if_error(response_data)
return response_data
try:
return xml_fromstring(raw) # type: ignore
except Exception as e:
raise UnparsableResponseError() from e

def get_application_setup(self) -> SetupResponse:
"""Get the possible values for a voter reg + optional mail-in ballot app."""
data = self.invoke(Action.GET_APPLICATION_SETUP)
return SetupResponse.from_xml_tree(data)
try:
return SetupResponse.from_xml_tree(data)
except (px.ParsingError, p.ValidationError) as e:
raise UnparsableResponseError() from e

def get_ballot_application_setup(self) -> SetupResponse:
"""Get the possible values for a mail-in ballot app."""
data = self.invoke(Action.GET_BALLOT_APPLICATION_SETUP)
return SetupResponse.from_xml_tree(data)
try:
return SetupResponse.from_xml_tree(data)
except (px.ParsingError, p.ValidationError) as e:
raise UnparsableResponseError() from e

def get_languages(self) -> LanguagesResponse:
"""Get the available languages for the PA OVR API."""
data = self.invoke(Action.GET_LANGUAGES)
return LanguagesResponse.from_xml_tree(data)
try:
return LanguagesResponse.from_xml_tree(data)
except (px.ParsingError, p.ValidationError) as e:
raise UnparsableResponseError() from e

def get_xml_template(self) -> XmlElement:
"""Get XML tags and format for voter reg + optional mail-in ballot app."""
Expand All @@ -1659,22 +1639,59 @@ def get_ballot_xml_template(self) -> XmlElement:
def get_error_values(self) -> ErrorValuesResponse:
"""Get the possible error values for the PA OVR API."""
data = self.invoke(Action.GET_ERROR_VALUES)
return ErrorValuesResponse.from_xml_tree(data)
try:
return ErrorValuesResponse.from_xml_tree(data)
except (px.ParsingError, p.ValidationError) as e:
raise UnparsableResponseError() from e

def get_municipalities(self, county: str) -> MunicipalitiesResponse:
"""Get the available municipalities in a given county."""
data = self.invoke(Action.GET_MUNICIPALITIES, params={"County": county})
return MunicipalitiesResponse.from_xml_tree(data)
try:
return MunicipalitiesResponse.from_xml_tree(data)
except (px.ParsingError, p.ValidationError) as e:
raise UnparsableResponseError() from e

def set_application(
self, application: VoterApplication, raise_validation_error: bool = True
) -> APIResponse:
"""
Submit a voter registration + optional mail-in ballot app.
def set_application(self, application: VoterApplication) -> APIResponse:
"""Submit a voter registration + optional mail-in ballot app."""
If the PA API returns a response with a validation error, and
`raise_validation_error` is True, this method will raise an exception.
Otherwise, the response will be returned as-is.
"""
xml_tree = application.to_xml_tree()
data = self.invoke(Action.SET_APPLICATION, data=xml_tree)
return APIResponse.from_xml_tree(data)
try:
api_response = APIResponse.from_xml_tree(data)
except (px.ParsingError, p.ValidationError) as e:
raise UnparsableResponseError() from e
# CONSIDER allowing callers to decide whether to raise here or not.
if raise_validation_error:
api_response.raise_for_error()
assert not api_response.has_error()
return api_response

def set_ballot_application(
self, application: VoterApplication, raise_validation_error: bool = True
) -> APIResponse:
"""
Submit a mail-in ballot app.
def set_ballot_application(self, application: VoterApplication) -> APIResponse:
"""Submit a mail-in ballot app."""
If the PA API returns a response with a validation error, and
`raise_validation_error` is True, this method will raise an exception.
Otherwise, the response will be returned as-is.
"""
data = self.invoke(
Action.SET_BALLOT_APPLICATION, data=application.to_xml_tree()
)
return APIResponse.from_xml_tree(data)
try:
api_response = APIResponse.from_xml_tree(data)
except (px.ParsingError, p.ValidationError) as e:
raise UnparsableResponseError() from e
if raise_validation_error:
api_response.raise_for_error()
assert not api_response.has_error()
return api_response
6 changes: 6 additions & 0 deletions voter_tools/pa/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ def __init__(self, message: str | None = None) -> None:
super().__init__(message or self._default_message)


class UnparsableResponseError(APIError):
"""The PA API returned a response that could not be parsed."""

pass


class TimeoutError(APIError):
"""Raised when a request to the server times out."""

Expand Down

0 comments on commit ac79d04

Please sign in to comment.