Show MediaWiki API error messages
This commit is contained in:
parent
1442620ece
commit
0188cbe0bf
3 changed files with 98 additions and 7 deletions
|
|
@ -32,6 +32,7 @@ class QueryError(Exception):
|
||||||
|
|
||||||
def __init__(self, query: str, r: requests.Response):
|
def __init__(self, query: str, r: requests.Response):
|
||||||
"""Init."""
|
"""Init."""
|
||||||
|
super().__init__(query, r)
|
||||||
self.query = query
|
self.query = query
|
||||||
self.r = r
|
self.r = r
|
||||||
|
|
||||||
|
|
@ -39,12 +40,33 @@ class QueryError(Exception):
|
||||||
class APIResponseError(Exception):
|
class APIResponseError(Exception):
|
||||||
"""Custom exception for API errors with response text."""
|
"""Custom exception for API errors with response text."""
|
||||||
|
|
||||||
def __init__(self, message: str, response: requests.Response):
|
def __init__(
|
||||||
|
self, message: str, response: requests.Response, detail: str | None = None
|
||||||
|
):
|
||||||
"""Init."""
|
"""Init."""
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.detail = detail or message
|
||||||
self.response = response
|
self.response = response
|
||||||
|
|
||||||
|
|
||||||
|
def mediawiki_error_message(
|
||||||
|
response: requests.Response, json_data: dict[str, typing.Any] | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Extract the useful error message from a MediaWiki API response."""
|
||||||
|
if json_data and isinstance(json_data.get("error"), dict):
|
||||||
|
error = json_data["error"]
|
||||||
|
if isinstance(error.get("info"), str):
|
||||||
|
return typing.cast(str, error["info"])
|
||||||
|
if isinstance(error.get("code"), str):
|
||||||
|
return typing.cast(str, error["code"])
|
||||||
|
|
||||||
|
if response.text:
|
||||||
|
return response.text.strip()
|
||||||
|
|
||||||
|
return response.reason or f"HTTP {response.status_code}"
|
||||||
|
|
||||||
|
|
||||||
@backoff.on_exception(
|
@backoff.on_exception(
|
||||||
backoff.expo,
|
backoff.expo,
|
||||||
(RequestException, APIResponseError),
|
(RequestException, APIResponseError),
|
||||||
|
|
@ -58,9 +80,18 @@ def api_call(params: dict[str, str | int]) -> dict[str, typing.Any]:
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
"https://www.wikidata.org/w/api.php", params=api_params, headers=headers
|
"https://www.wikidata.org/w/api.php", params=api_params, headers=headers
|
||||||
)
|
)
|
||||||
return typing.cast(dict[str, typing.Any], r.json())
|
json_data = typing.cast(dict[str, typing.Any], r.json())
|
||||||
except JSONDecodeError:
|
except JSONDecodeError:
|
||||||
raise APIResponseError("Failed to decode JSON", r)
|
detail = mediawiki_error_message(r)
|
||||||
|
message = f"Wikidata API error (HTTP {r.status_code}): {detail}"
|
||||||
|
raise APIResponseError(message, r, detail)
|
||||||
|
|
||||||
|
if not r.ok or "error" in json_data:
|
||||||
|
detail = mediawiki_error_message(r, json_data)
|
||||||
|
message = f"Wikidata API error (HTTP {r.status_code}): {detail}"
|
||||||
|
raise APIResponseError(message, r, detail)
|
||||||
|
|
||||||
|
return json_data
|
||||||
|
|
||||||
|
|
||||||
def get_entity(qid: str) -> dict[str, typing.Any] | None:
|
def get_entity(qid: str) -> dict[str, typing.Any] | None:
|
||||||
|
|
|
||||||
27
lookup.py
27
lookup.py
|
|
@ -381,9 +381,26 @@ def index() -> str | Response:
|
||||||
except (wikidata.QueryError, wikidata.APIResponseError) as e:
|
except (wikidata.QueryError, wikidata.APIResponseError) as e:
|
||||||
r = e.r if isinstance(e, wikidata.QueryError) else e.response
|
r = e.r if isinstance(e, wikidata.QueryError) else e.response
|
||||||
if r.status_code == 429:
|
if r.status_code == 429:
|
||||||
extra = {"Retry-After": r.headers["Retry-After"]} if "Retry-After" in r.headers else {}
|
extra = (
|
||||||
return jsonify(error="Rate limited by Wikidata, please try again later"), 429, extra
|
{"Retry-After": r.headers["Retry-After"]}
|
||||||
return jsonify(error=f"Wikidata query failed (HTTP {r.status_code}), please try again later"), 503
|
if "Retry-After" in r.headers
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
error = (
|
||||||
|
e.detail
|
||||||
|
if isinstance(e, wikidata.APIResponseError)
|
||||||
|
else wikidata.mediawiki_error_message(r)
|
||||||
|
)
|
||||||
|
return jsonify(error=error), 429, extra
|
||||||
|
if isinstance(e, wikidata.APIResponseError):
|
||||||
|
return jsonify(error=e.message), 503
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
error=f"Wikidata query failed (HTTP {r.status_code}), "
|
||||||
|
+ "please try again later"
|
||||||
|
),
|
||||||
|
503,
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException:
|
except requests.exceptions.RequestException:
|
||||||
return jsonify(error="Could not connect to Wikidata, please try again later"), 503
|
return jsonify(error="Could not connect to Wikidata, please try again later"), 503
|
||||||
result.pop("element", None)
|
result.pop("element", None)
|
||||||
|
|
@ -457,6 +474,10 @@ def build_detail_page(lat: float, lon: float, needs_commons: bool = True) -> str
|
||||||
except wikidata.QueryError as e:
|
except wikidata.QueryError as e:
|
||||||
query, r = e.args
|
query, r = e.args
|
||||||
return render_template("query_error.html", lat=lat, lon=lon, query=query, r=r)
|
return render_template("query_error.html", lat=lat, lon=lon, query=query, r=r)
|
||||||
|
except wikidata.APIResponseError as e:
|
||||||
|
return render_template(
|
||||||
|
"query_error.html", lat=lat, lon=lon, error=e.message, r=e.response
|
||||||
|
)
|
||||||
|
|
||||||
element = reply["result"].pop("element", None)
|
element = reply["result"].pop("element", None)
|
||||||
geojson = reply["result"].pop("geojson", None)
|
geojson = reply["result"].pop("geojson", None)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ import pytest
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
import requests
|
import requests
|
||||||
import responses
|
import responses
|
||||||
from geocode.wikidata import APIResponseError, QueryError, api_call, wdqs
|
from geocode.wikidata import (
|
||||||
|
APIResponseError,
|
||||||
|
QueryError,
|
||||||
|
api_call,
|
||||||
|
mediawiki_error_message,
|
||||||
|
wdqs,
|
||||||
|
)
|
||||||
|
|
||||||
max_tries = 5
|
max_tries = 5
|
||||||
|
|
||||||
|
|
@ -53,6 +59,39 @@ def test_api_call_retries_on_connection_error(
|
||||||
assert mocked_sleep.call_count == max_tries - 1
|
assert mocked_sleep.call_count == max_tries - 1
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_call_uses_mediawiki_error_message(
|
||||||
|
mocker: pytest_mock.plugin.MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test MediaWiki API error messages are preserved."""
|
||||||
|
mocker.patch("time.sleep", return_value=None)
|
||||||
|
mocker.patch("geocode.mail.send_to_admin")
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.GET,
|
||||||
|
"https://www.wikidata.org/w/api.php",
|
||||||
|
json={"error": {"code": "ratelimited", "info": "Too many requests"}},
|
||||||
|
status=429,
|
||||||
|
headers={"Retry-After": "10"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(APIResponseError) as exc_info:
|
||||||
|
api_call({"action": "wbgetentities", "ids": "Q42"})
|
||||||
|
|
||||||
|
assert exc_info.value.detail == "Too many requests"
|
||||||
|
assert str(exc_info.value) == "Wikidata API error (HTTP 429): Too many requests"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mediawiki_error_message_falls_back_to_response_text() -> None:
|
||||||
|
"""Test plain-text MediaWiki API errors are preserved."""
|
||||||
|
response = requests.Response()
|
||||||
|
response.status_code = 429
|
||||||
|
response.reason = "Too Many Requests"
|
||||||
|
response._content = b"Please slow down"
|
||||||
|
|
||||||
|
assert mediawiki_error_message(response) == "Please slow down"
|
||||||
|
|
||||||
|
|
||||||
def test_wdqs_retry(mocker: pytest_mock.plugin.MockerFixture) -> None:
|
def test_wdqs_retry(mocker: pytest_mock.plugin.MockerFixture) -> None:
|
||||||
"""Test retry for WDQS API calls."""
|
"""Test retry for WDQS API calls."""
|
||||||
# Patch 'time.sleep' to instantly return, effectively skipping the sleep
|
# Patch 'time.sleep' to instantly return, effectively skipping the sleep
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue