Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions src/urlscan/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
53 changes: 49 additions & 4 deletions src/urlscan/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
115 changes: 114 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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