Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions pyiceberg/catalog/rest/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from http import HTTPStatus
from json import JSONDecodeError
from typing import Literal

Expand Down Expand Up @@ -100,6 +101,13 @@ def _handle_non_200_response(exc: HTTPError, error_handler: dict[int, type[Excep
response += f": {description}"
if uri := error.error_uri:
response += f" ({uri})"
elif not exc.response.text:
try:
phrase = HTTPStatus(code).phrase
except ValueError:
phrase = exc.response.reason or f"HTTP {code}"
response = f"{exception.__name__}: RestError: {phrase}"
else:
Comment thread
kevinjqliu marked this conversation as resolved.
Outdated
Comment thread
kevinjqliu marked this conversation as resolved.
Outdated
else:
error = ErrorResponse.model_validate_json(exc.response.text).error
response = f"{error.type}: {error.message}"
Expand Down
155 changes: 155 additions & 0 deletions tests/catalog/test_rest_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

import json

import pytest
from requests import HTTPError, Response

from pyiceberg.catalog.rest.response import _handle_non_200_response
from pyiceberg.exceptions import (
AuthorizationExpiredError,
BadRequestError,
ForbiddenError,
NoSuchTableError,
OAuthError,
RESTError,
ServerError,
ServiceUnavailableError,
TooManyRequestsError,
UnauthorizedError,
)


def _make_http_error(status_code: int, body: str = "", reason: str | None = None) -> HTTPError:
response = Response()
response.status_code = status_code
response._content = body.encode("utf-8") if body else b""
if reason is not None:
response.reason = reason
return HTTPError(response=response)


def _error_body(message: str, error_type: str, code: int) -> str:
return json.dumps({"error": {"message": message, "type": error_type, "code": code}})


@pytest.mark.parametrize(
"status_code, expected_exception",
[
(400, BadRequestError),
(401, UnauthorizedError),
(403, ForbiddenError),
(419, AuthorizationExpiredError),
(422, RESTError),
(429, TooManyRequestsError),
(501, NotImplementedError),
(503, ServiceUnavailableError),
(500, ServerError),
(502, ServerError),
(504, ServerError),
(999, RESTError),
],
)
def test_status_code_maps_to_exception(status_code: int, expected_exception: type[Exception]) -> None:
body = _error_body("something went wrong", "SomeError", status_code)
exc = _make_http_error(status_code, body=body)

with pytest.raises(expected_exception, match="SomeError: something went wrong"):
_handle_non_200_response(exc, {})


def test_error_handler_overrides_default_mapping() -> None:
body = _error_body("Table does not exist: ns.tbl", "NoSuchTableException", 404)
exc = _make_http_error(404, body=body)

with pytest.raises(NoSuchTableError, match="NoSuchTableException: Table does not exist: ns.tbl"):
_handle_non_200_response(exc, {404: NoSuchTableError})


@pytest.mark.parametrize(
"status_code, body, expected_exception",
[
(500, "not json at all", ServerError),
(400, '{"unexpected": "structure"}', BadRequestError),
],
)
def test_unparseable_body_falls_back_to_validation_error(
status_code: int, body: str, expected_exception: type[Exception]
) -> None:
exc = _make_http_error(status_code, body=body)

with pytest.raises(expected_exception, match="Received unexpected JSON Payload"):
_handle_non_200_response(exc, {})


def test_empty_body_bypasses_pydantic() -> None:
exc = _make_http_error(403, body="", reason="Forbidden")

with pytest.raises(ForbiddenError, match="ForbiddenError: RestError: Forbidden"):
_handle_non_200_response(exc, {})


def test_empty_body_falls_back_to_http_status_phrase() -> None:
exc = _make_http_error(503, body="")
exc.response.reason = None

with pytest.raises(ServiceUnavailableError, match="ServiceUnavailableError: RestError: Service Unavailable"):
_handle_non_200_response(exc, {})


def test_oauth_error_with_description() -> None:
body = json.dumps(
{
"error": "invalid_client",
"error_description": "Client authentication failed",
}
)
exc = _make_http_error(401, body=body)

with pytest.raises(OAuthError, match="invalid_client: Client authentication failed"):
_handle_non_200_response(exc, {401: OAuthError})


def test_oauth_error_with_uri() -> None:
body = json.dumps(
{
"error": "invalid_scope",
"error_description": "scope not allowed",
"error_uri": "https://example.com/help",
}
)
exc = _make_http_error(400, body=body)

with pytest.raises(OAuthError, match=r"invalid_scope: scope not allowed \(https://example.com/help\)"):
_handle_non_200_response(exc, {400: OAuthError})


def test_oauth_error_without_description() -> None:
body = json.dumps({"error": "invalid_grant"})
exc = _make_http_error(401, body=body)

with pytest.raises(OAuthError, match="^invalid_grant$"):
_handle_non_200_response(exc, {401: OAuthError})


def test_none_response_raises_value_error() -> None:
exc = HTTPError()
exc.response = None

with pytest.raises(ValueError, match="Did not receive a response"):
_handle_non_200_response(exc, {})
Loading