Skip to content

Commit

Permalink
feat: implement RequestError
Browse files Browse the repository at this point in the history
  • Loading branch information
janbritz authored Dec 3, 2024
1 parent f1ba916 commit 9b8612f
Show file tree
Hide file tree
Showing 17 changed files with 479 additions and 154 deletions.
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

0 comments on commit 9b8612f

Please sign in to comment.