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 1 commit
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
65 changes: 10 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 @@ -1093,19 +1061,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
1,078 changes: 595 additions & 483 deletions poetry.lock

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,33 @@ packages = [

[tool.poetry.dependencies]
python = "^3.11"
aiohttp = "^3.10.5"
pydantic = "^2.8.2"
polyfactory = "^2.15.0"
pydantic-settings = "^2.2.1"
watchdog = "^4.0.0"
aiohttp = "^3.10.10"
pydantic = "^2.9.2"
polyfactory = "^2.17.0"
pydantic-settings = "^2.6.0"
watchdog = "^4.0.2"
semver = "^3.0.2"
psutil = "^6.0.0"
jinja2 = "^3.1.3"
psutil = "^6.1.0"
jinja2 = "^3.1.4"

[tool.poetry.group.dev.dependencies]
types-psutil = "^6.0.0.20240621"
types-psutil = "^6.1.0.20241022"

[tool.poetry.group.test]
optional = true

[tool.poetry.group.test.dependencies]
pytest = "^8.1.1"
pytest = "^8.3.3"
pytest-aiohttp = "^1.0.5"
pytest-md = "^0.2.0"
coverage = { extras = ["toml"], version = "^7.4.4" }
coverage = { extras = ["toml"], version = "^7.6.4" }

[tool.poetry.group.linter]
dependencies = { ruff = "^0.6.3" }
optional = true

[tool.poetry.group.type-checker]
dependencies = { mypy = "^1.11.0" }
dependencies = { mypy = "^1.13.0" }
optional = true

[tool.ruff]
Expand Down
5 changes: 3 additions & 2 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 Down Expand Up @@ -56,12 +57,12 @@ 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 InvalidQuestionStateError(QPyBaseError):
"""Error to raise when your package cannot parse the question state it is given."""
18 changes: 18 additions & 0 deletions questionpy_common/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 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):
def __init__(self, *args: Any, reason: str | None = None, temporary: bool = False):
"""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.
"""
super().__init__(*args)
self.temporary = temporary
self.reason = reason
janbritz marked this conversation as resolved.
Show resolved Hide resolved
24 changes: 17 additions & 7 deletions questionpy_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,23 @@ 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_PACKAGE = "INVALID_PACKAGE"
INVALID_REQUEST = "INVALID_REQUEST"
PACKAGE_ERROR = "PACKAGE_ERROR"
CALLBACK_API_ERROR = "CALLBACK_API_ERROR"
SERVER_ERROR = "SERVER_ERROR"
janbritz marked this conversation as resolved.
Show resolved Hide resolved


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 +122,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
36 changes: 22 additions & 14 deletions questionpy_server/web/_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,19 @@

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,
)

_P = ParamSpec("_P")
_HandlerFunc: TypeAlias = Callable[Concatenate[web.Request, _P], Awaitable[web.StreamResponse]]
Expand Down Expand Up @@ -102,7 +98,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, temporary=False)

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

Expand Down Expand Up @@ -133,7 +130,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, temporary=False)

kwargs[param.name] = _validate_from_http(parts.main, param.annotation)
return await handler(request, *args, **kwargs)
Expand All @@ -148,7 +146,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, temporary=False)

package = None
if uri_package_hash:
Expand All @@ -162,8 +164,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 InvalidRequestError(reason=msg, temporary=False)
msg = "The package is required but was not provided."
raise InvalidRequestError(reason=msg, temporary=False)

return package

Expand Down Expand Up @@ -277,5 +284,6 @@ 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:
# TODO: Remove double logging? (Here and in the errors mixin.)
web_logger.info("JSON does not match model: %s", error)
janbritz marked this conversation as resolved.
Show resolved Hide resolved
raise HTTPBadRequest(reason="Invalid JSON Body") from error
raise InvalidRequestError(reason="Invalid JSON body", temporary=False) from error
Loading