diff --git a/src/urlscan/client.py b/src/urlscan/client.py index 5c2297a..2cf1085 100644 --- a/src/urlscan/client.py +++ b/src/urlscan/client.py @@ -14,7 +14,7 @@ from httpx._types import QueryParamTypes, RequestData, TimeoutTypes from ._version import version -from .error import APIError, RateLimitError, RateLimitRemainingError +from .error import APIError, ItemError, RateLimitError, RateLimitRemainingError from .iterator import SearchIterator from .types import ActionType, SearchDataSource, VisibilityType from .utils import _compact, parse_datetime @@ -377,7 +377,10 @@ def _get_error(self, res: ClientResponse) -> APIError | None: data: dict = exc.response.json() message: str = data["message"] description: str | None = data.get("description") - status: int = data["status"] + code: str | None = data.get("code") + type_: str | None = data.get("type") + # fallback to HTTP status code if "status" is missing + status: int = data.get("status") or exc.response.status_code # ref. https://urlscan.io/docs/api/#ratelimit if status == 429: @@ -391,7 +394,32 @@ def _get_error(self, res: ClientResponse) -> APIError | None: rate_limit_reset_after=rate_limit_reset_after, ) - return APIError(message, description=description, status=status) + def mapper(d: dict) -> ItemError: + title: str = d["title"] + status: int = d["status"] + code: str | None = d.get("code") + description: str | None = d.get("description") + detail: str | None = d.get("detail") + return ItemError( + title=title, + description=description, + detail=detail, + status=status, + code=code, + ) + + errors: list[ItemError] | None = None + if "errors" in data: + errors = [mapper(item) for item in data["errors"]] + + return APIError( + message, + description=description, + status=status, + code=code, + type_=type_, + errors=errors, + ) return None diff --git a/src/urlscan/error.py b/src/urlscan/error.py index 74b1a30..af09888 100644 --- a/src/urlscan/error.py +++ b/src/urlscan/error.py @@ -5,21 +5,66 @@ class URLScanError(Exception): """Base exception for urlscan.io API errors.""" +class ItemError(URLScanError): + """Error item.""" + + def __init__( + self, + title: str, + status: int, + code: str | None = None, + description: str | None = None, + detail: str | None = None, + ): + """Initialize the error item. + + Args: + title (str): error title. + status (int): error status. + code (str | None, optional): error code. Defaults to None. + description (str | None, optional): error description. Defaults to None. + detail (str | None, optional): error detail. Defaults to None. + + """ + self.title = title + self.status = status + self.code = code + self.description = description + self.detail = detail + + super().__init__(title) + + class APIError(URLScanError): """Exception raised for API errors.""" - def __init__(self, message: str, *, status: int, description: str | None = None): + def __init__( + self, + message: str, + *, + status: int, + description: str | None = None, + errors: list[ItemError] | None = None, + code: str | None = None, + type_: str | None = None, + ): """Initialize the API error. Args: - message: Error message. - status: HTTP status code. - description: Optional error description. + message (str): error message. + status (int): error status. + description (str | None, optional): error description. Defaults to None. + errors (list[ErrorItem] | None, optional): error items. Defaults to None. + code (str | None, optional): error code. Defaults to None. + type_ (str | None, optional): error type. Defaults to None. """ self.message = message self.description = description self.status = status + self.errors = errors + self.code = code + self.type = type_ super().__init__(message) diff --git a/tests/test_client.py b/tests/test_client.py index 92e5a03..9d2cd40 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,7 @@ from werkzeug import Request, Response from urlscan import Client -from urlscan.error import RateLimitError, RateLimitRemainingError +from urlscan.error import APIError, RateLimitError, RateLimitRemainingError def test_get(client: Client, httpserver: HTTPServer): @@ -417,3 +417,116 @@ def test_get_quotas(client: Client, httpserver: HTTPServer): got = client.get_quotas() assert got == data + + +def test_error_1(client: Client, httpserver: HTTPServer): + # basic error + httpserver.expect_request( + "/error", + method="GET", + ).respond_with_json( + { + "message": "DNS Error - Could not resolve domain", + "description": "The domain foo.bar could not be resolved to a valid IPv4/IPv6 address. We won't try to load it in the browser.", + "status": 400, + "errors": [ + { + "title": "DNS Error - Could not resolve domain", + "detail": "The domain foo.bar could not be resolved to a valid IPv4/IPv6 address. We won't try to load it in the browser.", + "status": 400, + } + ], + }, + status=400, + ) + with pytest.raises(APIError) as exc_info: + client.get_json("/error") + + exc = exc_info.value + assert exc.status == 400 + assert ( + exc.description + == "The domain foo.bar could not be resolved to a valid IPv4/IPv6 address. We won't try to load it in the browser." + ) + assert len(exc.errors or []) == 1 + + error_item = (exc.errors or [])[0] + assert error_item.title == "DNS Error - Could not resolve domain" + assert error_item.status == 400 + assert ( + error_item.detail + == "The domain foo.bar could not be resolved to a valid IPv4/IPv6 address. We won't try to load it in the browser." + ) + + +def test_error_2(client: Client, httpserver: HTTPServer): + # validation error + httpserver.expect_request( + "/error", + method="GET", + ).respond_with_json( + { + "code": "validationerror", + "type": "body", + "message": 'ValidationError: "url" is required. "foo" is not allowed', + "errors": [ + { + "code": "validationerror", + "title": "Field Validation Error", + "description": 'ValidationError: "url" is required. "foo" is not allowed', + "status": 400, + } + ], + }, + status=400, + ) + with pytest.raises(APIError) as exc_info: + client.get_json("/error") + + exc = exc_info.value + assert exc.status == 400 + assert exc.code == "validationerror" + assert exc.type == "body" + assert exc.message == 'ValidationError: "url" is required. "foo" is not allowed' + assert len(exc.errors or []) == 1 + error_item = (exc.errors or [])[0] + assert error_item.code == "validationerror" + assert error_item.title == "Field Validation Error" + assert ( + error_item.description + == 'ValidationError: "url" is required. "foo" is not allowed' + ) + + +def test_error_3(client: Client, httpserver: HTTPServer): + # basic error without description + httpserver.expect_request( + "/error", + method="GET", + ).respond_with_json( + { + "message": 'No API key supplied. Please supply a valid API key in the "api-key" HTTP header.', + "status": 401, + "errors": [ + { + "title": 'No API key supplied. Please supply a valid API key in the "api-key" HTTP header.', + "detail": 'No API key supplied. Please supply a valid API key in the "api-key" HTTP header.', + "status": 401, + } + ], + }, + status=401, + ) + with pytest.raises(APIError) as exc_info: + client.get_json("/error") + + exc = exc_info.value + assert exc.status == 401 + assert ( + exc.message + == 'No API key supplied. Please supply a valid API key in the "api-key" HTTP header.' + ) + assert exc.description is None + assert exc.code is None + assert exc.type is None + assert exc.errors is not None