from __future__ import annotations
import enum
import logging
import typing as t
from globus_sdk import _guards
from .base import GlobusError
from .err_info import ErrorInfoContainer
from .warnings import warn_deprecated
if t.TYPE_CHECKING:
import requests
log = logging.getLogger(__name__)
_CACHE_SENTINEL = object()
class _ErrorFormat(enum.Enum):
undefined = enum.auto()
jsonapi = enum.auto()
type_zero = enum.auto()
[docs]
class GlobusAPIError(GlobusError):
"""
Wraps errors returned by a REST API.
:ivar int http_status: HTTP status code
:ivar str code: Error code from the API or "Error" for unclassified errors
:ivar str request_id: The 'request_id' included in the error data, if any.
:ivar list[str] messages: A list of error messages, extracted from the response
data. If the data cannot be parsed or does not contain any clear message fields,
this list may be empty.
:ivar list[GlobusSubError] errors: A list of sub-error documents, as would be
presented by JSON:API APIs and similar interfaces.
"""
MESSAGE_FIELDS = ["message", "detail", "title"]
RECOGNIZED_AUTHZ_SCHEMES = ["bearer", "basic", "globus-goauthtoken"]
def __init__(self, r: requests.Response, *args: t.Any, **kwargs: t.Any) -> None:
self._cached_raw_json: t.Any = _CACHE_SENTINEL
self.http_status = r.status_code
# defaults, may be rewritten during parsing
self.code: str | None = "Error"
self.request_id: str | None = None
self.messages: list[str] = []
self.errors: list[ErrorSubdocument] = []
self._info: ErrorInfoContainer | None = None
self._underlying_response = r
self._parse_response()
super().__init__(*self._get_args())
@property
def message(self) -> str | None:
"""
An error message from the API.
If there are multiple messages available, this will contain all messages joined
with semicolons. If there is no message available, this will be ``None``.
"""
if self.messages:
return "; ".join(self.messages)
return None
@message.setter
def message(self, value: str) -> None:
warn_deprecated(
"Setting a message on GlobusAPIError objects is deprecated. "
"This overwrites any parsed messages. Append to 'messages' instead."
)
self.messages = [value]
@property
def http_reason(self) -> str:
"""
The HTTP reason string from the response.
This is the part of the status line after the status code, and typically is a
string description of the status. If the status line is
``HTTP/1.1 404 Not Found``, then this is the string ``"Not Found"``.
"""
return self._underlying_response.reason
@property
def headers(self) -> t.Mapping[str, str]:
"""
The HTTP response headers as a case-insensitive mapping.
For example, ``headers["Content-Length"]`` and ``headers["content-length"]`` are
treated as equivalent.
"""
return self._underlying_response.headers
@property
def content_type(self) -> str | None:
return self.headers.get("Content-Type")
def _get_mimetype(self, content_type: str) -> str:
return content_type.split(";")[0].strip()
def _jsonapi_mimetype(self) -> bool:
if self.content_type is None:
return False
return self._get_mimetype(self.content_type) == "application/vnd.api+json"
def _json_mimetype(self) -> bool:
if self.content_type is None:
return False
mimetype = self._get_mimetype(self.content_type)
if mimetype == "application/json":
return True
if mimetype.startswith("application/") and mimetype.endswith("+json"):
return True
return False
@property
def raw_json(self) -> dict[str, t.Any] | None:
"""
Get the verbatim error message received from a Globus API, interpreted
as JSON data
If the body cannot be loaded as JSON, this is None
"""
if self._cached_raw_json == _CACHE_SENTINEL:
self._cached_raw_json = None
if self._json_mimetype():
try:
# technically, this could be a non-dict JSON type, like a list or
# string but in those cases the user can just cast -- the "normal"
# case is a dict
self._cached_raw_json = self._underlying_response.json()
except ValueError:
log.error(
"Error body could not be JSON decoded! "
"This means the Content-Type is wrong, or the "
"body is malformed!"
)
return t.cast("dict[str, t.Any]", self._cached_raw_json)
@property
def _dict_data(self) -> dict[str, t.Any]:
"""
A "type asserting" wrapper over raw_json which errors if the type is not dict.
"""
if not isinstance(self.raw_json, dict):
raise ValueError("cannot use _dict_data when data is non-dict type")
return self.raw_json
@property
def text(self) -> str:
"""
Get the verbatim error message received from a Globus API as a *string*
"""
return self._underlying_response.text
@property
def raw_text(self) -> str:
"""
Deprecated alias of the ``text`` property.
"""
warn_deprecated(
"The 'raw_text' property of GlobusAPIError objects is deprecated. "
"Use the 'text' property instead."
)
return self.text
@property
def binary_content(self) -> bytes:
"""
The error message received from a Globus API in bytes.
"""
return self._underlying_response.content
@property
def info(self) -> ErrorInfoContainer:
"""
An ``ErrorInfoContainer`` with parsed error data. The ``info`` of an error is
guaranteed to be present, but all of its contents may be falsey if the error
could not be parsed.
"""
if self._info is None:
json_data = self.raw_json if isinstance(self.raw_json, dict) else None
self._info = ErrorInfoContainer(json_data)
return self._info
def _get_request_authorization_scheme(self) -> str | None:
try:
authz_h = self._underlying_response.request.headers["Authorization"]
authz_scheme = authz_h.split()[0]
if authz_scheme.lower() in self.RECOGNIZED_AUTHZ_SCHEMES:
return authz_scheme
except (IndexError, KeyError):
pass
return None
def _get_args(self) -> list[t.Any]:
"""
Get arguments to pass to the Exception base class. These args are
displayed in stack traces.
"""
args = [
self._underlying_response.request.method,
self._underlying_response.url,
self._get_request_authorization_scheme(),
self.http_status,
self.code,
# if the message is "", try using response reason
# for details on these, and some examples, see
# https://datatracker.ietf.org/doc/html/rfc7231#section-6.1
self.message or self._underlying_response.reason,
]
if self.request_id:
args.append(self.request_id)
return args
def _parse_response(self) -> bool:
"""
This is an intermediate step between 'raw_json' (loading bare JSON data)
and the "real" parsing methods.
In order to better support subclassing with short-circuiting behaviors if
parsing goes awry, all of the parsing methods return True if parsing should
continue or False if it was aborted.
_parse_response() pulls the JSON body and does the following:
- on non-dict JSON data, log and abort early. Don't error since this
is already part of exception construction, just stop parsing.
- Attempt to detect the error format in use, then dispatch to the relevant
subparser: JSON:API, Type Zero, or Undefined
- if subparsing succeeded, call the `_post_parse_hook` hook to allow
subclasses to trivially add more computed attributes. This could also be
done by altering `__init__` but it's nicer to have a dedicated hook because
it is guaranteed to only be called if the rest of parsing succeeded
"""
if self.raw_json is None:
log.debug("Error body was not parsed as JSON")
return False
if not isinstance(self.raw_json, dict):
log.warning( # type: ignore[unreachable]
"Error body could not be parsed as JSON because it was not a dict"
)
return False
error_format = self._detect_error_format()
subparse_result: bool
if error_format == _ErrorFormat.jsonapi:
subparse_result = self._parse_jsonapi_error_format()
elif error_format == _ErrorFormat.type_zero:
subparse_result = self._parse_type_zero_error_format()
else:
subparse_result = self._parse_undefined_error_format()
if not subparse_result:
return False
return self._post_parse_hook()
def _post_parse_hook(self) -> bool:
"""
An internal callback for extra customizations after fully successful parsing.
By default, does nothing.
"""
return True
def _detect_error_format(self) -> _ErrorFormat:
# if the JSON:API mimetype was used, inspect the data to make sure it is
# well-formed
if self._jsonapi_mimetype():
errors = self._dict_data.get("errors")
if not _guards.is_list_of(errors, dict):
return _ErrorFormat.undefined
elif len(errors) < 1:
return _ErrorFormat.undefined
elif not all(isinstance(error_doc, dict) for error_doc in errors):
return _ErrorFormat.undefined
# only use 'jsonapi' if everything checked out
return _ErrorFormat.jsonapi
# now evaluate attributes for Type Zero errors under the same paradigm
# check each attribute and only return 'type_zero' if nothing failed
if not isinstance(self._dict_data.get("code"), str):
return _ErrorFormat.undefined
elif not isinstance(self._dict_data.get("message"), str):
return _ErrorFormat.undefined
# request_id is not required, but must be a string if present
elif "request_id" in self._dict_data and not isinstance(
self._dict_data["request_id"], str
):
return _ErrorFormat.undefined
return _ErrorFormat.type_zero
def _parse_jsonapi_error_format(self) -> bool:
"""
Parsing a JSON:API Error
This is only called after the field type for 'errors' has been checked.
However, the nested/underlying fields will not have been checked yet.
"""
self.errors = [ErrorSubdocument(e) for e in self._dict_data["errors"]]
self.code = self._extract_code_from_error_array(self.errors)
self.messages = self._extract_messages_from_error_array(self.errors)
return True
def _parse_type_zero_error_format(self) -> bool:
"""
Parsing a Type Zero Error
This is only called after Type Zero has been detected. Therefore, we already
have assurances about the values in 'code' and 'message'.
Note that 'request_id' could be absent but *must* be a string if present.
"""
self.code = self._dict_data["code"]
self.messages = [self._dict_data["message"]]
self.request_id = self._dict_data.get("request_id")
if _guards.is_list_of(self._dict_data.get("errors"), dict):
raw_errors = self._dict_data["errors"]
else:
raw_errors = [self._dict_data]
self.errors = [
ErrorSubdocument(e, message_fields=self.MESSAGE_FIELDS) for e in raw_errors
]
return True
def _parse_undefined_error_format(self) -> bool:
"""
Undefined Parsing: best effort support for unknown data shapes
This is also a great place for custom parsing to hook in for different APIs
if we know that there's an unusual format in use
"""
# attempt to pull out errors if possible and valid
if _guards.is_list_of(self._dict_data.get("errors"), dict):
raw_errors = self._dict_data["errors"]
# if no 'errors' were found, or 'errors' is invalid, then
# 'errors' should be set to contain the root document
else:
raw_errors = [self._dict_data]
self.errors = [
ErrorSubdocument(e, message_fields=self.MESSAGE_FIELDS) for e in raw_errors
]
# use 'code' if present and correct type
if isinstance(self._dict_data.get("code"), str):
self.code = t.cast(str, self._dict_data["code"])
# otherwise, pull 'code' from the sub-errors array
elif self.errors:
# in undefined parse cases, the code will be left as `"Error"` for
# a higher degree of backwards compatibility
maybe_code = self._extract_code_from_error_array(self.errors)
if maybe_code is not None:
self.code = maybe_code
# either there is an array of subdocument errors or there is not,
# in which case we load only from the root doc
self.messages = self._extract_messages_from_error_array(
self.errors
or [ErrorSubdocument(self._dict_data, message_fields=self.MESSAGE_FIELDS)]
)
return True
def _extract_messages_from_error_array(
self, errors: list[ErrorSubdocument]
) -> list[str]:
"""
Extract 'messages' from an array of errors (JSON:API or otherwise)
Each subdocument *may* define its messages, so this is the aggregate of messages
from those documents which had messages.
Note that subdocuments may be instructed about their `message_fields` by the
error class and parsing path which they take. Therefore, this may be extracting
`"message"`, `"detail"`, or `"title"` in the base implementation and other
fields if a subclass customizes this further.
"""
ret: list[str] = []
for doc in errors:
if doc.message is not None:
ret.append(doc.message)
return ret
def _extract_code_from_error_array(
self, errors: list[ErrorSubdocument]
) -> str | None:
"""
Extract a 'code' field from an array of errors (JSON:API or otherwise)
This is done by checking if each error document has the same 'code'
"""
codes: set[str] = set()
for error in errors:
if error.code is not None:
codes.add(error.code)
if len(codes) == 1:
return codes.pop()
return None
[docs]
class ErrorSubdocument:
"""
Error subdocuments as returned by Globus APIs.
:ivar dict raw: The unparsed error subdocument
"""
# the default set of fields to use for message extraction, in order
# selected to match the fields defined by JSON:API by default
DEFAULT_MESSAGE_FIELDS: tuple[str, ...] = ("detail", "title")
def __init__(
self, data: dict[str, t.Any], *, message_fields: t.Sequence[str] | None = None
) -> None:
self.raw = data
self._message_fields: tuple[str, ...]
if message_fields is not None:
self._message_fields = tuple(message_fields)
else:
self._message_fields = self.DEFAULT_MESSAGE_FIELDS
@property
def message(self) -> str | None:
"""
The 'message' string of this subdocument, derived from its data based on the
parsing context.
May be `None` if no message is defined.
"""
return _extract_message_from_dict(self.raw, self._message_fields)
@property
def code(self) -> str | None:
"""
The 'code' string of this subdocument, derived from its data based on the
parsing context.
May be `None` if no code is defined.
"""
if isinstance(self.raw.get("code"), str):
return t.cast(str, self.raw["code"])
return None
[docs]
def get(self, key: str, default: t.Any = None) -> t.Any | None:
"""
A dict-like getter for the raw data.
:param key: The string key to use for lookup
:param default: The default value to use
"""
return self.raw.get(key, default)
def _extract_message_from_dict(
data: dict[str, t.Any], message_fields: tuple[str, ...]
) -> str | None:
"""
Extract a single message string from a dict if one is present.
"""
for f in message_fields:
if isinstance(data.get(f), str):
return t.cast(str, data[f])
return None