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

SwissDataScienceCenter / renku-data-services / 27699979017

17 Jun 2026 03:22PM UTC coverage: 86.321% (-0.05%) from 86.374%
27699979017

push

github

web-flow
feat: add feature flag for private repo builds (#1355)

* refactor: add feature flag for private repo builds

In order to make the code simpler as well, start moving
default value handling in the configuration class.

22 of 28 new or added lines in 3 files covered. (78.57%)

24 existing lines in 9 files now uncovered.

27041 of 31326 relevant lines covered (86.32%)

1.5 hits per line

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

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

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

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

22
from renku_data_services import errors
2✔
23

24

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

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

34

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

38
    error: BaseError
2✔
39

40
    def dict(
2✔
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)
2✔
56
BErrorResponse = TypeVar("BErrorResponse", bound=BaseErrorResponse)
2✔
57

58

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

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

65

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

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

73
    @classmethod
2✔
74
    def _get_formatted_exception(cls, request: Request, exception: Exception) -> errors.BaseError | None:
2✔
75
        formatted_exception: errors.BaseError | None = None
2✔
76
        match exception:
2✔
77
            case errors.BaseError():
2✔
78
                formatted_exception = exception
2✔
79
            case ValidationError():
2✔
80
                extra_exception = None if exception.extra is None else exception.extra["exception"]
2✔
81
                match extra_exception:
2✔
82
                    case TypeError():
2✔
83
                        formatted_exception = errors.ValidationError(
1✔
84
                            message="The validation failed because the provided input has the wrong type"
85
                        )
86
                    case PydanticValidationError():
2✔
87
                        parts = [
2✔
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)}"
2✔
92
                        formatted_exception = errors.ValidationError(message=message)
2✔
93
            case SanicException():
2✔
94
                message = exception.message
2✔
95
                if message == "" or message is None:
2✔
96
                    message = ", ".join([str(i) for i in exception.args])
×
97
                formatted_exception = errors.BaseError(
2✔
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():
2✔
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():
2✔
109
                formatted_exception = errors.BaseError(
1✔
110
                    message=f"Database error occurred: {exception.msg}", detail=f"Error code: {exception.pgcode}"
111
                )
112
            case SQLAlchemyError():
2✔
113
                message = ", ".join([str(i) for i in exception.args])
1✔
114
                if "CharacterNotInRepertoireError" in message:
1✔
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
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:
1✔
UNCOV
122
                    formatted_exception = errors.ValidationError(
×
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
2✔
155

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

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

164
        self.log(request, formatted_exception)
2✔
165
        if formatted_exception.status_code == 500 and "PYTEST_CURRENT_TEST" in os.environ:
2✔
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(
2✔
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 · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc