Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement RequestError #119

Merged
merged 4 commits into from
Dec 3, 2024
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
71 changes: 16 additions & 55 deletions docs/qppe-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,7 @@ paths:
$ref: "#/components/schemas/FormData"
required: [ definition, form_data ]
404:
description: Package or question_state not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
description: Package not found.
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -120,11 +116,7 @@ paths:
schema:
type: object
404:
description: Package or question_state not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
description: Package not found.
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -164,11 +156,7 @@ paths:
schema:
$ref: "#/components/schemas/QuestionStateMigrationError"
404:
description: Package or question_state not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
description: Package not found.
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -202,11 +190,7 @@ paths:
schema:
$ref: "#/components/schemas/Question"
404:
description: Package or question_state not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
description: Package not found.
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -239,11 +223,7 @@ paths:
schema:
$ref: "#/components/schemas/AttemptStarted"
404:
description: Package or question_state not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
description: Package not found.
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -276,11 +256,7 @@ paths:
schema:
$ref: "#/components/schemas/Attempt"
404:
description: Package or question_state not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
description: Package not found.
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -325,11 +301,7 @@ paths:
description: Async scoring job uuid.
required: [ scoring_job_uuid ]
404:
description: Package or question_state not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
description: Package not found.
500:
description: Error occurred.
content:
Expand Down Expand Up @@ -396,17 +368,13 @@ paths:
schema:
$ref: "#/components/schemas/RequestBaseData"
responses:
"200":
200:
description: Static file data
content:
"*/*": {}
"404":
404:
description: Package or its static file not found.
content:
application/json:
schema:
$ref: "#/components/schemas/NotFoundStatus"
"500":
500:
description: Error occurred.
content:
application/json:
Expand Down Expand Up @@ -1069,18 +1037,24 @@ components:
- QUEUE_WAITING_TIMEOUT
- WORKER_TIMEOUT
- OUT_OF_MEMORY
- INVALID_ATTEMPT_STATE
- INVALID_QUESTION_STATE
- INVALID_PACKAGE
- INVALID_REQUEST
- PACKAGE_ERROR
- PACKAGE_NOT_FOUND
- CALLBACK_API_ERROR
- SERVER_ERROR
description: >
* `QUEUE_WAITING_TIMEOUT` - The request has been waiting too long in a job queue. Try again later.
* `WORKER_TIMEOUT` - Question package did not answer in a reasonable amount of time.
* `OUT_OF_MEMORY` - Question package reached its memory limit.
* `INVALID_ATTEMPT_STATE` - Invalid attempt state.
* `INVALID_QUESTION_STATE` - Invalid question state.
* `INVALID_PACKAGE` - The package file is corrupt, the manifest is invalid or there is a checksum mismatch.
* `INVALID_REQUEST` - Invalid request body.
* `PACKAGE_ERROR` - An error occurred within the package.
* `PACKAGE_NOT_FOUND` - The package was not found.
* `CALLBACK_API_ERROR` - An error occurred while contacting the LMS Callback API.
* `SERVER_ERROR` - Some other server error has occurred.
temporary:
Expand All @@ -1093,19 +1067,6 @@ components:
description: Optional human-readable reason for the error.
required: [ error_code, temporary ]

NotFoundStatus:
type: object
properties:
what:
type: string
enum:
- PACKAGE
- QUESTION_STATE
description: >
* `PACKAGE` - Could not find the requested package.
* `QUESTION_STATE` - Could not find the question_state.
required: [ what ]

QuestionStateMigrationError:
type: object
properties:
Expand Down
16 changes: 13 additions & 3 deletions questionpy_common/api/qtype.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import TYPE_CHECKING, Protocol

from questionpy_common.api.package import BasePackageInterface
from questionpy_common.error import QPyBaseError

if TYPE_CHECKING:
from pydantic import JsonValue
Expand All @@ -15,7 +16,12 @@

from .question import QuestionInterface

__all__ = ["InvalidQuestionStateError", "OptionsFormValidationError", "QuestionTypeInterface"]
__all__ = [
"InvalidAttemptStateError",
"InvalidQuestionStateError",
"OptionsFormValidationError",
"QuestionTypeInterface",
]


class QuestionTypeInterface(BasePackageInterface, Protocol):
Expand Down Expand Up @@ -56,12 +62,16 @@ def create_question_from_state(self, question_state: str) -> QuestionInterface:
"""


class OptionsFormValidationError(Exception):
class OptionsFormValidationError(QPyBaseError):
def __init__(self, errors: dict[str, str]):
"""There was at least one validation error."""
self.errors = errors # input element name -> error description
super().__init__("Form input data could not be validated successfully.")


class InvalidQuestionStateError(Exception):
class InvalidAttemptStateError(QPyBaseError):
"""Error to raise when your package cannot parse the attempt state it is given."""


class InvalidQuestionStateError(QPyBaseError):
"""Error to raise when your package cannot parse the question state it is given."""
19 changes: 19 additions & 0 deletions questionpy_common/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# This file is part of QuestionPy. (https://questionpy.org)
# QuestionPy is free software released under terms of the MIT license. See LICENSE.md.
# (c) Technische Universität Berlin, innoCampus <[email protected]>
from typing import Any


class QPyBaseError(Exception):
"""QuestionPy errors should inherit this class as the webserver transforms these into better http errors.

Args:
args: Any other arguments.
reason: A human-readable reason which can be exposed to a third party.
temporary: Whether this exception is temporary.
"""

def __init__(self, *args: Any, reason: str | None = None, temporary: bool = False):
super().__init__(*args)
self.temporary = temporary
self.reason = reason
27 changes: 20 additions & 7 deletions questionpy_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,26 @@ class AttemptScoreArguments(AttemptViewArguments):
generate_hint: bool


class NotFoundStatusWhat(Enum):
PACKAGE = "PACKAGE"
QUESTION_STATE = "QUESTION_STATE"

class RequestErrorCode(Enum):
QUEUE_WAITING_TIMEOUT = "QUEUE_WAITING_TIMEOUT"
WORKER_TIMEOUT = "WORKER_TIMEOUT"
OUT_OF_MEMORY = "OUT_OF_MEMORY"
INVALID_ATTEMPT_STATE = "INVALID_ATTEMPT_STATE"
INVALID_QUESTION_STATE = "INVALID_QUESTION_STATE"
INVALID_PACKAGE = "INVALID_PACKAGE"
INVALID_REQUEST = "INVALID_REQUEST"
PACKAGE_ERROR = "PACKAGE_ERROR"
PACKAGE_NOT_FOUND = "PACKAGE_NOT_FOUND"
CALLBACK_API_ERROR = "CALLBACK_API_ERROR"
SERVER_ERROR = "SERVER_ERROR"


class RequestError(BaseModel):
model_config = ConfigDict(use_enum_values=True)

class NotFoundStatus(BaseModel):
what: NotFoundStatusWhat
error_code: RequestErrorCode
temporary: bool
reason: str | None = None


class QuestionStateMigrationErrorCode(Enum):
Expand All @@ -112,7 +125,7 @@ class QuestionStateMigrationErrorCode(Enum):
class QuestionStateMigrationError(BaseModel):
model_config = ConfigDict(use_enum_values=True)

code: QuestionStateMigrationErrorCode
error_code: QuestionStateMigrationErrorCode
reason: str | None = None


Expand Down
38 changes: 23 additions & 15 deletions questionpy_server/web/_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,20 @@

from aiohttp import BodyPartReader, web
from aiohttp.log import web_logger
from aiohttp.web_exceptions import HTTPBadRequest
from pydantic import BaseModel, ValidationError

from questionpy_common import constants
from questionpy_server.cache import CacheItemTooLargeError
from questionpy_server.hash import HashContainer
from questionpy_server.models import MainBaseModel
from questionpy_server.package import Package
from questionpy_server.web._errors import (
MainBodyMissingError,
PackageHashMismatchError,
PackageMissingByHashError,
PackageMissingWithoutHashError,
QuestionStateMissingError,
)
from questionpy_server.web._utils import read_part
from questionpy_server.web.app import QPyServer
from questionpy_server.web.errors import (
InvalidPackageError,
InvalidRequestError,
PackageNotFoundError,
)

_P = ParamSpec("_P")
_HandlerFunc: TypeAlias = Callable[Concatenate[web.Request, _P], Awaitable[web.StreamResponse]]
Expand Down Expand Up @@ -102,7 +99,8 @@ async def wrapper(request: web.Request, *args: _P.args, **kwargs: _P.kwargs) ->
if parts.question_state is not None:
kwargs[param.name] = parts.question_state
elif param.default is Parameter.empty:
raise QuestionStateMissingError
_msg = "A question state part is required but was not provided."
raise InvalidRequestError(reason=_msg)

return await handler(request, *args, **kwargs)

Expand Down Expand Up @@ -133,7 +131,8 @@ async def wrapper(request: web.Request, *args: _P.args, **kwargs: _P.kwargs) ->
parts = await _read_body_parts(request)

if parts.main is None:
raise MainBodyMissingError
_msg = "The main body is required but was not provided."
raise InvalidRequestError(reason=_msg)

kwargs[param.name] = _validate_from_http(parts.main, param.annotation)
return await handler(request, *args, **kwargs)
Expand All @@ -148,7 +147,11 @@ async def _get_package_from_request(request: web.Request) -> Package:
parts = await _read_body_parts(request)

if parts.package and uri_package_hash and uri_package_hash != parts.package.hash:
raise PackageHashMismatchError(uri_package_hash, parts.package.hash)
msg = (
f"The request URI specifies a package with hash '{uri_package_hash}', but the sent package has a hash of"
f" '{parts.package.hash}'."
)
raise InvalidPackageError(reason=msg)

package = None
if uri_package_hash:
Expand All @@ -162,8 +165,13 @@ async def _get_package_from_request(request: web.Request) -> Package:

if not package:
if uri_package_hash:
raise PackageMissingByHashError(uri_package_hash)
raise PackageMissingWithoutHashError
msg = (
f"The package was not provided, is not cached and could not be found by its hash. "
f"('{uri_package_hash}')"
)
raise PackageNotFoundError(reason=msg, temporary=False)
msg = "The package is required but was not provided."
raise InvalidRequestError(reason=msg)

return package

Expand Down Expand Up @@ -277,5 +285,5 @@ def _validate_from_http(raw_body: str | bytes, param_class: type[_M]) -> _M:
try:
return param_class.model_validate_json(raw_body)
except ValidationError as error:
web_logger.info("JSON does not match model: %s", error)
raise HTTPBadRequest(reason="Invalid JSON Body") from error
msg = "Invalid JSON body"
raise InvalidRequestError(reason=msg) from error
Loading