• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SwissDataScienceCenter / renku-data-services / 29822724901

21 Jul 2026 10:34AM UTC coverage: 86.149% (-0.04%) from 86.188%
29822724901

Pull #1389

github

web-flow
Merge 2586664ce into 85e87a7d3
Pull Request #1389: fix: distinguish union for patching launchers envs

96 of 96 new or added lines in 3 files covered. (100.0%)

25 existing lines in 8 files now uncovered.

27223 of 31600 relevant lines covered (86.15%)

2.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.75
/components/renku_data_services/base_api/error_handler.py
1
"""The error handler for the application."""
2

3
import os
3✔
4
import sys
3✔
5
import traceback
3✔
6
from asyncio import CancelledError
3✔
7
from collections.abc import Mapping, Set
3✔
8
from sqlite3 import Error as SqliteError
3✔
9
from typing import Any, Optional, Protocol, TypeVar, Union
3✔
10

11
import httpx
3✔
12
import jwt
3✔
13
import sentry_sdk
3✔
14
from asyncpg import exceptions as postgres_exceptions
3✔
15
from pydantic import ValidationError as PydanticValidationError
3✔
16
from sanic import HTTPResponse, Request, SanicException, json
3✔
17
from sanic.errorpages import BaseRenderer, TextRenderer
3✔
18
from sanic.handlers import ErrorHandler
3✔
19
from sanic_ext.exceptions import ValidationError
3✔
20
from sqlalchemy.exc import SQLAlchemyError
3✔
21

22
from renku_data_services import errors
3✔
23

24

25
class BaseError(Protocol):
3✔
26
    """Protocol for the error type of apispec module."""
27

28
    code: int
3✔
29
    message: str
3✔
30
    detail: Optional[str]
3✔
31
    quiet: bool
3✔
32
    trace_id: Optional[str] = None
3✔
33

34

35
class BaseErrorResponse(Protocol):
3✔
36
    """Protocol for the error response class of an apispec module."""
37

38
    error: BaseError
3✔
39

40
    def dict(
3✔
41
        self,
42
        *,
43
        include: Optional[Union[Set[Union[int, str]], Mapping[Union[int, str], Any]]] = None,
44
        exclude: Optional[Union[Set[Union[int, str]], Mapping[Union[int, str], Any]]] = None,
45
        by_alias: bool = False,
46
        skip_defaults: Optional[bool] = None,
47
        exclude_unset: bool = False,
48
        exclude_defaults: bool = False,
49
        exclude_none: bool = False,
50
    ) -> dict[str, Any]:
51
        """Turn the response to dict."""
52
        ...
×
53

54

55
BError = TypeVar("BError", bound=BaseError)
3✔
56
BErrorResponse = TypeVar("BErrorResponse", bound=BaseErrorResponse)
3✔
57

58

59
class ApiSpec(Protocol[BErrorResponse, BError]):
3✔
60
    """Protocol for an apispec with error data."""
61

62
    ErrorResponse: BErrorResponse
3✔
63
    Error: BError
3✔
64

65

66
class CustomErrorHandler(ErrorHandler):
3✔
67
    """Central error handling."""
68

69
    def __init__(self, api_spec: ApiSpec, base: type[BaseRenderer] = TextRenderer) -> None:
3✔
70
        self.api_spec = api_spec
3✔
71
        super().__init__(base)
3✔
72

73
    @classmethod
3✔
74
    def _get_formatted_exception(cls, request: Request, exception: Exception) -> errors.BaseError | None:
3✔
75
        formatted_exception: errors.BaseError | None = None
3✔
76
        match exception:
3✔
77
            case errors.BaseError():
3✔
78
                formatted_exception = exception
3✔
79
            case ValidationError():
3✔
80
                extra_exception = None if exception.extra is None else exception.extra["exception"]
3✔
81
                match extra_exception:
3✔
82
                    case TypeError():
3✔
83
                        formatted_exception = errors.ValidationError(
1✔
84
                            message="The validation failed because the provided input has the wrong type"
85
                        )
86
                    case PydanticValidationError():
3✔
87
                        parts = [
3✔
88
                            ".".join(str(i) for i in field["loc"]) + ": " + field["msg"]
89
                            for field in extra_exception.errors()
90
                        ]
91
                        message = f"There are errors in the following fields, {', '.join(parts)}"
3✔
92
                        formatted_exception = errors.ValidationError(message=message)
3✔
93
            case SanicException():
3✔
94
                message = exception.message
3✔
95
                if message == "" or message is None:
3✔
96
                    message = ", ".join([str(i) for i in exception.args])
×
97
                formatted_exception = errors.BaseError(
3✔
98
                    message=message,
99
                    status_code=exception.status_code,
100
                    code=1000 + exception.status_code,
101
                    quiet=exception.quiet or False,
102
                )
103
            case SqliteError():
3✔
104
                formatted_exception = errors.BaseError(
1✔
105
                    message=f"Database error occurred: {exception.sqlite_errorname}",
106
                    detail=f"Error code: {exception.sqlite_errorcode}",
107
                )
108
            case postgres_exceptions.PostgresError():
3✔
109
                formatted_exception = errors.BaseError(
1✔
110
                    message=f"Database error occurred: {exception.msg}", detail=f"Error code: {exception.pgcode}"
111
                )
112
            case SQLAlchemyError():
3✔
113
                message = ", ".join([str(i) for i in exception.args])
2✔
114
                if "CharacterNotInRepertoireError" in message:
2✔
115
                    # NOTE: This message is usually triggered if a string field for the database contains
116
                    # NULL - i.e \u0000 or other invalid characters that are not UTF-8 compatible
UNCOV
117
                    formatted_exception = errors.ValidationError(
×
118
                        message="The payload contains characters that are incompatible with the database",
119
                        detail=message,
120
                    )
121
                elif "value out of int32 range" in message:
2✔
122
                    formatted_exception = errors.ValidationError(
1✔
123
                        message="The payload contains integers with values that are "
124
                        "too large or small for the database",
125
                        detail=message,
126
                    )
127
                else:
128
                    formatted_exception = errors.BaseError(message=f"Database error occurred: {message}")
1✔
129
            case PydanticValidationError():
2✔
130
                parts = [".".join(str(i) for i in field["loc"]) + ": " + field["msg"] for field in exception.errors()]
2✔
131
                message = f"There are errors in the following fields, {', '.join(parts)}"
2✔
132
                formatted_exception = errors.ValidationError(message=message)
2✔
133
            case OverflowError():
1✔
134
                formatted_exception = errors.ValidationError(
1✔
135
                    message="The provided input is too large to be stored in the database"
136
                )
137
            case jwt.exceptions.InvalidTokenError():
1✔
138
                formatted_exception = errors.InvalidTokenError()
1✔
139
            case CancelledError():
1✔
140
                quiet = request.transport.is_closing()
×
141
                formatted_exception = errors.RequestCancelledError(quiet=quiet)
×
142

143
            case httpx.RequestError():
1✔
144
                req_uri = "<unknown-uri>"
1✔
145
                req_method = "<unknown-method>"
1✔
146
                if exception._request:
1✔
147
                    req_uri = str(exception.request.url)
×
148
                    req_method = exception.request.method
×
149

150
                formatted_exception = errors.BaseError(
1✔
151
                    message=f"Error on remote connection {req_method} {req_uri}: {exception}"
152
                )
153

154
        return formatted_exception
3✔
155

156
    def default(self, request: Request, exception: Exception) -> HTTPResponse:
3✔
157
        """Overrides the default error handler."""
158
        formatted_exception = self._get_formatted_exception(request, exception) or errors.BaseError()
3✔
159

160
        span = sentry_sdk.get_current_span()
3✔
161
        if span and span.trace_id:
3✔
162
            formatted_exception.trace_id = formatted_exception.trace_id or span.trace_id
×
163

164
        self.log(request, formatted_exception)
3✔
165
        if formatted_exception.status_code == 500 and "PYTEST_CURRENT_TEST" in os.environ:
3✔
166
            # TODO: Figure out how to do logging properly in here, I could not get the sanic logs to show up from here
167
            # at all when running schemathesis. So 500 errors are hard to debug but print statements do show up.
168
            # The above log statement does not show up in the logs that pytest shows after a test is done.
169
            sys.stderr.write(f"A 500 error was raised because of {type(exception)} on request {request}\n")
1✔
170
            traceback.print_exception(exception)
1✔
171
        return json(
3✔
172
            self.api_spec.ErrorResponse(
173
                error=self.api_spec.Error(
174
                    code=formatted_exception.code,
175
                    message=formatted_exception.message,
176
                    detail=formatted_exception.detail,
177
                    trace_id=formatted_exception.trace_id,
178
                )
179
            ).model_dump(exclude_none=True),
180
            status=formatted_exception.status_code,
181
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc