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
1 change: 1 addition & 0 deletions changes/10601.fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add missing HTTP status classes to AppProxy error types, fixing -1 status codes in error responses.
3 changes: 2 additions & 1 deletion src/ai/backend/account_manager/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ async def exception_middleware(
if ex.status_code == 405:
concrete_ex = cast(web.HTTPMethodNotAllowed, ex)
raise MethodNotAllowed(
method=concrete_ex.method, allowed_methods=concrete_ex.allowed_methods
extra_msg=f"Method {concrete_ex.method} not allowed",
extra_data={"allowed_methods": list(concrete_ex.allowed_methods)},
) from ex
log.warning("Bad request: {0!r}", ex)
raise GenericBadRequest from ex
Expand Down
42 changes: 25 additions & 17 deletions src/ai/backend/appproxy/common/errors/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from typing import Any

from aiohttp import web

from ai.backend.common.exception import (
BackendAIError,
ErrorCode,
Expand All @@ -14,7 +16,7 @@
from ai.backend.common.plugin.hook import HookResult


class URLNotFound(BackendAIError):
class URLNotFound(BackendAIError, web.HTTPNotFound):
"""Raised when URL path is not found."""

error_type = "https://api.backend.ai/probs/url-not-found"
Expand All @@ -28,7 +30,7 @@ def error_code(self) -> ErrorCode:
)


class ObjectNotFound(BackendAIError):
class ObjectNotFound(BackendAIError, web.HTTPNotFound):
"""Raised when requested object is not found."""

error_type = "https://api.backend.ai/probs/object-not-found"
Expand All @@ -53,7 +55,7 @@ def error_code(self) -> ErrorCode:
)


class GenericBadRequest(BackendAIError):
class GenericBadRequest(BackendAIError, web.HTTPBadRequest):
"""Raised for generic bad request errors."""

error_type = "https://api.backend.ai/probs/generic-bad-request"
Expand All @@ -67,7 +69,7 @@ def error_code(self) -> ErrorCode:
)


class RejectedByHook(BackendAIError):
class RejectedByHook(BackendAIError, web.HTTPForbidden):
"""Raised when operation is rejected by a hook plugin."""

error_type = "https://api.backend.ai/probs/rejected-by-hook"
Expand All @@ -90,7 +92,7 @@ def from_hook_result(cls, result: HookResult) -> RejectedByHook:
)


class InvalidCredentials(BackendAIError):
class InvalidCredentials(BackendAIError, web.HTTPUnauthorized):
"""Raised when authentication credentials are not valid."""

error_type = "https://api.backend.ai/probs/invalid-credentials"
Expand All @@ -104,7 +106,7 @@ def error_code(self) -> ErrorCode:
)


class GenericForbidden(BackendAIError):
class GenericForbidden(BackendAIError, web.HTTPForbidden):
"""Raised for generic forbidden operation errors."""

error_type = "https://api.backend.ai/probs/generic-forbidden"
Expand All @@ -118,7 +120,7 @@ def error_code(self) -> ErrorCode:
)


class InsufficientPrivilege(BackendAIError):
class InsufficientPrivilege(BackendAIError, web.HTTPForbidden):
"""Raised when user has insufficient privileges."""

error_type = "https://api.backend.ai/probs/insufficient-privilege"
Expand All @@ -133,8 +135,14 @@ def error_code(self) -> ErrorCode:


class MethodNotAllowed(BackendAIError):
"""Raised when HTTP method is not allowed."""
"""Raised when HTTP method is not allowed.

Note: Cannot inherit web.HTTPMethodNotAllowed because it requires
positional args (method, allowed_methods) that conflict with
BackendAIError.__init__. Status code is set explicitly instead.
"""

status_code = 405
error_type = "https://api.backend.ai/probs/method-not-allowed"
error_title = "HTTP Method Not Allowed."

Expand All @@ -146,7 +154,7 @@ def error_code(self) -> ErrorCode:
)


class InternalServerError(BackendAIError):
class InternalServerError(BackendAIError, web.HTTPInternalServerError):
"""Raised for internal server errors."""

error_type = "https://api.backend.ai/probs/internal-server-error"
Expand All @@ -160,7 +168,7 @@ def error_code(self) -> ErrorCode:
)


class ServerMisconfiguredError(BackendAIError):
class ServerMisconfiguredError(BackendAIError, web.HTTPInternalServerError):
"""Raised when server is misconfigured."""

error_type = "https://api.backend.ai/probs/server-misconfigured"
Expand All @@ -174,7 +182,7 @@ def error_code(self) -> ErrorCode:
)


class ServiceUnavailable(BackendAIError):
class ServiceUnavailable(BackendAIError, web.HTTPServiceUnavailable):
"""Raised when service is unavailable."""

error_type = "https://api.backend.ai/probs/service-unavailable"
Expand All @@ -188,7 +196,7 @@ def error_code(self) -> ErrorCode:
)


class QueryNotImplemented(BackendAIError):
class QueryNotImplemented(BackendAIError, web.HTTPNotImplemented):
"""Raised when API query is not implemented."""

error_type = "https://api.backend.ai/probs/not-implemented"
Expand All @@ -202,7 +210,7 @@ def error_code(self) -> ErrorCode:
)


class InvalidAuthParameters(BackendAIError):
class InvalidAuthParameters(BackendAIError, web.HTTPBadRequest):
"""Raised when authorization parameters are missing or invalid."""

error_type = "https://api.backend.ai/probs/invalid-auth-params"
Expand All @@ -216,7 +224,7 @@ def error_code(self) -> ErrorCode:
)


class AuthorizationFailed(BackendAIError):
class AuthorizationFailed(BackendAIError, web.HTTPUnauthorized):
"""Raised when credential/signature mismatch occurs."""

error_type = "https://api.backend.ai/probs/auth-failed"
Expand All @@ -230,7 +238,7 @@ def error_code(self) -> ErrorCode:
)


class PasswordExpired(BackendAIError):
class PasswordExpired(BackendAIError, web.HTTPUnauthorized):
"""Raised when password has expired."""

error_type = "https://api.backend.ai/probs/password-expired"
Expand All @@ -244,7 +252,7 @@ def error_code(self) -> ErrorCode:
)


class InvalidAPIParameters(BackendAIError):
class InvalidAPIParameters(BackendAIError, web.HTTPBadRequest):
"""Raised when API parameters are missing or invalid."""

error_type = "https://api.backend.ai/probs/invalid-api-params"
Expand All @@ -258,7 +266,7 @@ def error_code(self) -> ErrorCode:
)


class GraphQLError(BackendAIError):
class GraphQLError(BackendAIError, web.HTTPBadRequest):
"""Raised for GraphQL-generated errors."""

error_type = "https://api.backend.ai/probs/graphql-error"
Expand Down
3 changes: 2 additions & 1 deletion src/ai/backend/appproxy/coordinator/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,8 @@ async def exception_middleware(
if ex.status_code == 405:
concrete_ex = cast(web.HTTPMethodNotAllowed, ex)
raise MethodNotAllowed(
method=concrete_ex.method, allowed_methods=concrete_ex.allowed_methods
extra_msg=f"Method {concrete_ex.method} not allowed",
extra_data={"allowed_methods": list(concrete_ex.allowed_methods)},
) from ex
log.warning("Bad request: {0!r}", ex)
raise GenericBadRequest from ex
Expand Down
3 changes: 2 additions & 1 deletion src/ai/backend/appproxy/worker/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ async def exception_middleware(
if ex.status_code == 405:
concrete_ex = cast(web.HTTPMethodNotAllowed, ex)
raise MethodNotAllowed(
method=concrete_ex.method, allowed_methods=concrete_ex.allowed_methods
extra_msg=f"Method {concrete_ex.method} not allowed",
extra_data={"allowed_methods": list(concrete_ex.allowed_methods)},
) from ex
log.warning("Bad request: {0!r}", ex)
raise GenericBadRequest from ex
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/appproxy/test_error_status_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
Verify that AppProxy error classes return correct HTTP status codes.

BackendAIError inherits from aiohttp.web.HTTPException which defaults
to status_code = -1. Each error class must also inherit from a concrete
HTTP exception (e.g. web.HTTPNotFound) to get a valid status code.
"""

import pytest

from ai.backend.appproxy.common.errors import (
AuthorizationFailed,
GenericBadRequest,
GenericForbidden,
InsufficientPrivilege,
InternalServerError,
InvalidAPIParameters,
InvalidAuthParameters,
InvalidCredentials,
MethodNotAllowed,
ObjectNotFound,
PasswordExpired,
QueryNotImplemented,
ServerMisconfiguredError,
ServiceUnavailable,
URLNotFound,
)


@pytest.mark.parametrize(
"error_cls, expected_status",
[
(URLNotFound, 404),
(ObjectNotFound, 404),
(GenericBadRequest, 400),
(InvalidCredentials, 401),
(AuthorizationFailed, 401),
(PasswordExpired, 401),
(GenericForbidden, 403),
(InsufficientPrivilege, 403),
(InvalidAuthParameters, 400),
(InvalidAPIParameters, 400),
(InternalServerError, 500),
(ServerMisconfiguredError, 500),
(ServiceUnavailable, 503),
(QueryNotImplemented, 501),
],
)
def test_error_status_codes(error_cls: type, expected_status: int) -> None:
err = error_cls()
assert err.status_code == expected_status, (
f"{error_cls.__name__} has status_code={err.status_code}, expected {expected_status}"
)


def test_error_status_code_not_negative_one() -> None:
"""No AppProxy error should have the default -1 status code."""
err = URLNotFound()
assert err.status_code != -1


def test_method_not_allowed_with_extra_msg() -> None:
err = MethodNotAllowed(
extra_msg="Method GET not allowed",
extra_data={"allowed_methods": ["POST"]},
)
assert err.status_code == 405
Loading