python-corekit 0.1.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- corekit/api/__init__.py +18 -3
- corekit/api/application.py +275 -0
- corekit/api/lifespan.py +233 -0
- corekit/api/middleware.py +93 -0
- corekit/api/routers.py +109 -1
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +74 -65
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +33 -4
- corekit/connections/sql/connection.py +56 -3
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +9 -5
- corekit/connections/sql/migration/base.py +3 -3
- corekit/connections/sql/migration/operations.py +135 -44
- corekit/connections/sql/migration/registry.py +2 -2
- corekit/connections/sql/operations/__init__.py +24 -0
- corekit/connections/sql/operations/base.py +111 -0
- corekit/connections/sql/operations/statements.py +170 -0
- corekit/connections/sql/query.py +4 -62
- corekit/connections/sql/table.py +33 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/__init__.py +8 -0
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +10 -2
- corekit/data/expressions/comparison.py +142 -123
- corekit/data/expressions/expression.py +71 -98
- corekit/data/expressions/operator.py +39 -0
- corekit/data/expressions/target.py +21 -0
- corekit/data/record.py +147 -147
- corekit/data/stats.py +162 -157
- corekit/decorators/__init__.py +2 -2
- corekit/decorators/exception_handling.py +38 -9
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +46 -44
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +27 -13
- corekit/exceptions/__init__.py +33 -0
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +51 -0
- corekit/http/api.py +24 -0
- corekit/http/client.py +100 -73
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/jobs/__init__.py +26 -0
- corekit/jobs/registry.py +87 -0
- corekit/jobs/runner.py +80 -0
- corekit/jobs/task.py +173 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -3
- corekit/observability/benchmarkable.py +23 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +188 -0
- corekit/observability/timing/timer.py +4 -2
- corekit/registry/__init__.py +12 -7
- corekit/registry/ordered.py +86 -0
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +10 -0
- corekit/schemas/enum.py +70 -49
- corekit/schemas/models/arbitrary.py +11 -11
- corekit/schemas/pydantic/fields.py +35 -35
- corekit/schemas/types.py +45 -40
- corekit/serialization/__init__.py +24 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +10 -3
- corekit/utils/__init__.py +59 -5
- corekit/utils/coercion.py +118 -0
- corekit/utils/collections.py +124 -0
- corekit/utils/ids.py +61 -5
- corekit/utils/payload.py +112 -0
- corekit/utils/raise_exc.py +8 -8
- corekit/utils/text.py +56 -0
- corekit/utils/time.py +74 -21
- corekit/utils/validators.py +15 -15
- corekit/utils/void.py +8 -8
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/METADATA +103 -97
- python_corekit-0.3.0.dist-info/RECORD +145 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.1.1.dist-info/RECORD +0 -125
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.1.1.dist-info → python_corekit-0.3.0.dist-info}/top_level.txt +0 -0
corekit/exceptions/base.py
CHANGED
|
@@ -1,26 +1,104 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Base exception types.
|
|
3
|
+
|
|
4
|
+
Every corekit exception is one of two shapes:
|
|
5
|
+
|
|
6
|
+
* ``InternalCoreException`` -- backend-only. ``message``/``error`` are for
|
|
7
|
+
logs and debugging and must never reach an end user; there is no field to
|
|
8
|
+
put user-facing copy in, so there is nothing to leak by accident.
|
|
9
|
+
* ``PublicCoreException`` -- also carries ``user_message``, pre-written copy
|
|
10
|
+
that *is* safe to show externally. ``message``/``error`` stay backend-only
|
|
11
|
+
even here; only ``user_message`` should ever be serialized to a client.
|
|
12
|
+
|
|
13
|
+
Both carry ``retryable``, so a caller (or whatever eventually surfaces the
|
|
14
|
+
error) can tell whether trying again is worth it without re-deriving that
|
|
15
|
+
from a status code every time.
|
|
16
|
+
|
|
17
|
+
``CoreException`` itself is abstract -- it exists to hold the shared fields,
|
|
18
|
+
not to be raised. Raise one of the two subclasses above (or a subclass of
|
|
19
|
+
those, like ``CoreHTTPException`` and the typed HTTP errors in ``corekit.http``).
|
|
3
20
|
"""
|
|
4
21
|
|
|
22
|
+
from abc import ABC
|
|
5
23
|
from typing import Any
|
|
6
24
|
|
|
7
25
|
from fastapi import HTTPException
|
|
8
26
|
|
|
9
|
-
|
|
27
|
+
from corekit.exceptions.enum import Retryability
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CoreException",
|
|
31
|
+
"CoreHTTPException",
|
|
32
|
+
"RetryableCoreHTTPException",
|
|
33
|
+
"NonRetryableCoreHTTPException",
|
|
34
|
+
"ExponentialBackoffTimeoutException",
|
|
35
|
+
"InternalCoreException",
|
|
36
|
+
"PublicCoreException",
|
|
37
|
+
]
|
|
10
38
|
|
|
11
39
|
|
|
12
|
-
class
|
|
40
|
+
class CoreException(Exception, ABC):
|
|
13
41
|
"""
|
|
14
|
-
|
|
42
|
+
Abstract base carrying the fields every corekit exception shares.
|
|
15
43
|
"""
|
|
16
44
|
|
|
17
|
-
def __init__(
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
message: str,
|
|
48
|
+
*,
|
|
49
|
+
retryable: Retryability = Retryability.UNKNOWN,
|
|
50
|
+
error: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
if type(self) is CoreException:
|
|
53
|
+
raise TypeError("CoreException is abstract -- raise InternalCoreException or PublicCoreException")
|
|
18
54
|
super().__init__(message)
|
|
19
55
|
self.message = message
|
|
20
56
|
self.error = error
|
|
57
|
+
self.retryable = retryable
|
|
58
|
+
|
|
59
|
+
def for_log(self) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Backend text for logs.
|
|
62
|
+
|
|
63
|
+
``str(self)`` on a public exception is the user-facing copy, so a log
|
|
64
|
+
line of ``str(exc)`` drops ``message``. This always returns the
|
|
65
|
+
backend message, and the separate error detail when one was given.
|
|
66
|
+
"""
|
|
67
|
+
if self.error:
|
|
68
|
+
return f"{self.message} ({self.error})"
|
|
69
|
+
return self.message
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InternalCoreException(CoreException):
|
|
73
|
+
"""
|
|
74
|
+
Backend-only exception. Never expose ``message`` or ``error`` to a user.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PublicCoreException(CoreException):
|
|
79
|
+
"""
|
|
80
|
+
Exception with pre-written copy that is safe to show an end user.
|
|
81
|
+
|
|
82
|
+
``message``/``error`` remain backend-only, for logs; ``user_message`` is
|
|
83
|
+
the only field on this exception meant to leave the backend.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
message: str,
|
|
89
|
+
user_message: str,
|
|
90
|
+
*,
|
|
91
|
+
retryable: Retryability = Retryability.UNKNOWN,
|
|
92
|
+
error: str | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
super().__init__(message, retryable=retryable, error=error)
|
|
95
|
+
self.user_message = user_message
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str:
|
|
98
|
+
return self.user_message
|
|
21
99
|
|
|
22
100
|
|
|
23
|
-
class ExponentialBackoffTimeoutException(
|
|
101
|
+
class ExponentialBackoffTimeoutException(InternalCoreException):
|
|
24
102
|
"""
|
|
25
103
|
Raised when a retry loop exhausts its attempts without succeeding.
|
|
26
104
|
"""
|
|
@@ -28,18 +106,69 @@ class ExponentialBackoffTimeoutException(CustomException):
|
|
|
28
106
|
def __init__(self, attempts: int, error: str | None = None) -> None:
|
|
29
107
|
super().__init__(
|
|
30
108
|
message=f"Exponential Backoff timed out after {attempts} attempts",
|
|
109
|
+
retryable=Retryability.NON_RETRYABLE,
|
|
31
110
|
error=error,
|
|
32
111
|
)
|
|
33
112
|
self.attempts = attempts
|
|
34
113
|
|
|
35
114
|
|
|
36
|
-
class
|
|
115
|
+
class CoreHTTPException(HTTPException, PublicCoreException):
|
|
116
|
+
"""
|
|
117
|
+
FastAPI HTTPException that is also a PublicCoreException.
|
|
118
|
+
|
|
119
|
+
``detail`` (FastAPI's client-facing field) and ``user_message`` are kept
|
|
120
|
+
in sync -- pass either one. ``message``/``error`` default to ``detail``
|
|
121
|
+
when omitted, since most call sites raising this directly have nothing
|
|
122
|
+
more specific to log.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
status_code: int,
|
|
129
|
+
detail: str | None = None,
|
|
130
|
+
message: str | None = None,
|
|
131
|
+
user_message: str | None = None,
|
|
132
|
+
retryable: Retryability = Retryability.UNKNOWN,
|
|
133
|
+
error: str | None = None,
|
|
134
|
+
**kwargs: Any,
|
|
135
|
+
) -> None:
|
|
136
|
+
resolved_user_message = user_message or detail
|
|
137
|
+
if not resolved_user_message:
|
|
138
|
+
raise ValueError("CoreHTTPException requires detail= or user_message=")
|
|
139
|
+
|
|
140
|
+
resolved_message = message or resolved_user_message
|
|
141
|
+
HTTPException.__init__(self, status_code=status_code, detail=resolved_user_message, **kwargs)
|
|
142
|
+
PublicCoreException.__init__(
|
|
143
|
+
self,
|
|
144
|
+
resolved_message,
|
|
145
|
+
resolved_user_message,
|
|
146
|
+
retryable=retryable,
|
|
147
|
+
error=error,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def __str__(self) -> str:
|
|
151
|
+
# HTTPException.__str__ is "404: detail" and sits ahead of
|
|
152
|
+
# PublicCoreException in the MRO. The public contract is that only
|
|
153
|
+
# user_message leaves the backend.
|
|
154
|
+
return self.user_message
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class RetryableCoreHTTPException(CoreHTTPException):
|
|
158
|
+
"""
|
|
159
|
+
HTTP error where trying the same request again may succeed.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
163
|
+
kwargs.setdefault("retryable", Retryability.RETRYABLE)
|
|
164
|
+
super().__init__(**kwargs)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class NonRetryableCoreHTTPException(CoreHTTPException):
|
|
37
168
|
"""
|
|
38
|
-
|
|
169
|
+
HTTP error where the same request will fail again until something else changes.
|
|
39
170
|
"""
|
|
40
171
|
|
|
41
172
|
def __init__(self, **kwargs: Any) -> None:
|
|
42
|
-
|
|
43
|
-
if message:
|
|
44
|
-
kwargs["detail"] = message
|
|
173
|
+
kwargs.setdefault("retryable", Retryability.NON_RETRYABLE)
|
|
45
174
|
super().__init__(**kwargs)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enums used by corekit exceptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from corekit.schemas.enum import StringEnum
|
|
6
|
+
|
|
7
|
+
__all__ = ["Retryability"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Retryability(StringEnum):
|
|
11
|
+
"""
|
|
12
|
+
Whether retrying the operation that raised an exception can help.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
RETRYABLE = "retryable"
|
|
16
|
+
NON_RETRYABLE = "non_retryable"
|
|
17
|
+
UNKNOWN = "unknown"
|
corekit/exceptions/types.py
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
Type aliases for exception classes.
|
|
3
3
|
|
|
4
4
|
These name the *class*, for signatures that accept an exception type rather than
|
|
5
|
-
an instance -- ``def handle(exc:
|
|
5
|
+
an instance -- ``def handle(exc: CoreExceptionType) -> None``.
|
|
6
6
|
|
|
7
7
|
``type(X)`` returns X's metaclass, which is ``type`` for an ordinary class and
|
|
8
8
|
carries no information about X. ``type[X]`` is the subscript form these need.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from corekit.exceptions.base import
|
|
11
|
+
from corekit.exceptions.base import CoreException, CoreHTTPException
|
|
12
12
|
|
|
13
|
-
__all__ = ["
|
|
13
|
+
__all__ = ["ArbitraryCoreExceptionType", "CoreExceptionType", "CoreHTTPExceptionType"]
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
CoreExceptionType = type[CoreException]
|
|
16
|
+
CoreHTTPExceptionType = type[CoreHTTPException]
|
|
17
|
+
ArbitraryCoreExceptionType = CoreExceptionType | CoreHTTPExceptionType
|
corekit/files/__init__.py
CHANGED
|
@@ -8,10 +8,9 @@ File reading and writing, with format handled by the manager.
|
|
|
8
8
|
``_serialize`` and ``_deserialize`` for its format.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from corekit.files.base import FileContent, FileManager
|
|
12
|
-
from corekit.files.enum import
|
|
11
|
+
from corekit.files.base import FileContent, FileError, FileManager
|
|
12
|
+
from corekit.files.enum import FileMode
|
|
13
13
|
from corekit.files.json import JsonFileManager
|
|
14
|
-
from corekit.files.pickle import PickleFileManager
|
|
15
14
|
from corekit.files.toml import TomlFileManager
|
|
16
15
|
|
|
17
16
|
__all__ = [
|
|
@@ -20,6 +19,5 @@ __all__ = [
|
|
|
20
19
|
"FileManager",
|
|
21
20
|
"FileMode",
|
|
22
21
|
"JsonFileManager",
|
|
23
|
-
"PickleFileManager",
|
|
24
22
|
"TomlFileManager",
|
|
25
23
|
]
|
corekit/files/base.py
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
from typing import Any, BinaryIO, Iterator, TextIO, Union
|
|
2
2
|
|
|
3
|
-
from corekit.
|
|
3
|
+
from corekit.exceptions import InternalCoreException, Retryability
|
|
4
|
+
from corekit.files.enum import FileMode
|
|
4
5
|
|
|
5
6
|
FileContent: type[str | bytes] = Union[str, bytes]
|
|
6
7
|
|
|
7
8
|
|
|
9
|
+
class FileError(InternalCoreException):
|
|
10
|
+
"""
|
|
11
|
+
Raised when a file manager is used in the wrong open/closed state.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
NOT_OPEN = "File is not open"
|
|
15
|
+
OPEN = "File is open"
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, *, error: str | None = None) -> None:
|
|
18
|
+
super().__init__(message, retryable=Retryability.NON_RETRYABLE, error=error)
|
|
19
|
+
|
|
20
|
+
|
|
8
21
|
class FileManager:
|
|
9
22
|
def __init__(
|
|
10
23
|
self,
|
|
@@ -36,7 +49,7 @@ class FileManager:
|
|
|
36
49
|
|
|
37
50
|
def _verify_file_status(self, should_be_open: bool) -> None:
|
|
38
51
|
if self.is_open != should_be_open:
|
|
39
|
-
raise
|
|
52
|
+
raise FileError(FileError.NOT_OPEN if should_be_open else FileError.OPEN)
|
|
40
53
|
|
|
41
54
|
# ===== File Opening/Closing Methods =====
|
|
42
55
|
def _open(self) -> None:
|
corekit/files/enum.py
CHANGED
corekit/files/json.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json as _json
|
|
2
|
-
from typing import Any
|
|
2
|
+
from typing import Any, Iterator
|
|
3
3
|
|
|
4
|
-
from corekit.files.base import FileContent, FileManager
|
|
4
|
+
from corekit.files.base import FileContent, FileError, FileManager
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class JsonFileManager(FileManager):
|
|
@@ -10,3 +10,17 @@ class JsonFileManager(FileManager):
|
|
|
10
10
|
|
|
11
11
|
def _deserialize(self, content: FileContent) -> Any:
|
|
12
12
|
return _json.loads(content)
|
|
13
|
+
|
|
14
|
+
def read(self, size: int = -1) -> Any:
|
|
15
|
+
"""
|
|
16
|
+
Read and parse the whole document. Partial reads are refused.
|
|
17
|
+
"""
|
|
18
|
+
if size != -1:
|
|
19
|
+
raise FileError("JsonFileManager refuses partial reads; JSON must be parsed as a whole document.")
|
|
20
|
+
return super().read(size)
|
|
21
|
+
|
|
22
|
+
def readline(self) -> Any:
|
|
23
|
+
raise FileError("JsonFileManager cannot readline; JSON is not line-oriented.")
|
|
24
|
+
|
|
25
|
+
def stream(self) -> Iterator[Any]:
|
|
26
|
+
raise FileError("JsonFileManager cannot stream; JSON must be parsed as a whole document.")
|
corekit/http/__init__.py
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client building blocks.
|
|
3
|
+
|
|
4
|
+
``BaseHttpClient`` is the transport; ``BaseApiClient`` is the strict API
|
|
5
|
+
wrapper. ``ExponentialBackoff`` is the retry policy behind the client and is
|
|
6
|
+
useful on its own for any operation that should back off rather than hammer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from corekit.http.api import BaseApiClient
|
|
10
|
+
from corekit.http.client import BaseHttpClient
|
|
11
|
+
from corekit.http.exceptions import (
|
|
12
|
+
BadGatewayException,
|
|
13
|
+
BadRequestException,
|
|
14
|
+
ConflictErrorException,
|
|
15
|
+
ForbiddenException,
|
|
16
|
+
GatewayTimeoutException,
|
|
17
|
+
InternalServerErrorException,
|
|
18
|
+
NotFoundException,
|
|
19
|
+
ServiceUnavailableException,
|
|
20
|
+
TooManyRequestsException,
|
|
21
|
+
UnauthorizedException,
|
|
22
|
+
UnprocessableEntityException,
|
|
23
|
+
UnsupportedMediaTypeException,
|
|
24
|
+
UnsupportedMethodError,
|
|
25
|
+
URLMismatchError,
|
|
26
|
+
)
|
|
27
|
+
from corekit.http.exponential_backoff import ExponentialBackoff
|
|
28
|
+
from corekit.http.response import BaseApiResponse
|
|
29
|
+
from corekit.http.status import HTTPStatusCode
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BadGatewayException",
|
|
33
|
+
"BadRequestException",
|
|
34
|
+
"BaseApiClient",
|
|
35
|
+
"BaseApiResponse",
|
|
36
|
+
"BaseHttpClient",
|
|
37
|
+
"ConflictErrorException",
|
|
38
|
+
"ExponentialBackoff",
|
|
39
|
+
"ForbiddenException",
|
|
40
|
+
"GatewayTimeoutException",
|
|
41
|
+
"HTTPStatusCode",
|
|
42
|
+
"InternalServerErrorException",
|
|
43
|
+
"NotFoundException",
|
|
44
|
+
"ServiceUnavailableException",
|
|
45
|
+
"TooManyRequestsException",
|
|
46
|
+
"UnauthorizedException",
|
|
47
|
+
"UnprocessableEntityException",
|
|
48
|
+
"UnsupportedMediaTypeException",
|
|
49
|
+
"UnsupportedMethodError",
|
|
50
|
+
"URLMismatchError",
|
|
51
|
+
]
|
corekit/http/api.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API clients: a ``BaseHttpClient`` that always talks to one origin.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from corekit.http.client import BaseHttpClient
|
|
8
|
+
|
|
9
|
+
__all__ = ["BaseApiClient"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BaseApiClient(BaseHttpClient):
|
|
13
|
+
"""
|
|
14
|
+
Base for API clients. Override ``base_url`` and, usually, ``headers``.
|
|
15
|
+
|
|
16
|
+
Strict by default, so an absolute URL that does not share ``base_url`` is
|
|
17
|
+
a bug rather than a request to somewhere else. ``base_url`` must be
|
|
18
|
+
available during ``__init__`` — a property on the subclass is the usual way.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args: Any, strict: bool = True, **kwargs: Any) -> None:
|
|
22
|
+
super().__init__(*args, strict=strict, **kwargs)
|
|
23
|
+
if not self.base_url:
|
|
24
|
+
raise ValueError("BaseApiClient requires a non-empty base_url")
|
corekit/http/client.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""
|
|
2
2
|
A small HTTP client with retries.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
``BaseHttpClient`` is the transport: relative paths, optional strict URL
|
|
5
|
+
checking, and retries for statuses the typed HTTP exceptions mark as
|
|
6
|
+
retryable. Subclass ``BaseApiClient`` when the client always talks to one
|
|
7
|
+
API and a foreign absolute URL should be a bug.
|
|
5
8
|
|
|
6
9
|
class GithubClient(BaseApiClient):
|
|
7
10
|
'''
|
|
@@ -17,46 +20,79 @@ Subclass and give it a base URL::
|
|
|
17
20
|
return {"Authorization": f"Bearer {self.token}"}
|
|
18
21
|
|
|
19
22
|
client = GithubClient()
|
|
20
|
-
response = client.get("/user") # or await client.
|
|
23
|
+
response = client.get("/user") # or await client.get("/user")
|
|
21
24
|
response.data["login"]
|
|
22
25
|
|
|
23
26
|
Every response comes back as a ``BaseApiResponse``, so callers see one shape
|
|
24
|
-
regardless of what the endpoint returned.
|
|
25
|
-
status are retried with exponential backoff.
|
|
27
|
+
regardless of what the endpoint returned.
|
|
26
28
|
"""
|
|
27
29
|
|
|
28
30
|
from http import HTTPMethod
|
|
29
31
|
from typing import Any
|
|
30
|
-
from urllib.parse import urljoin
|
|
32
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
31
33
|
|
|
32
34
|
import httpx
|
|
33
35
|
|
|
36
|
+
from corekit.concurrency.decorators import allow_sync
|
|
37
|
+
from corekit.http.exceptions import RETRYABLE_STATUS_CODES, UnsupportedMethodError, URLMismatchError
|
|
34
38
|
from corekit.http.exponential_backoff import ExponentialBackoff
|
|
35
39
|
from corekit.http.response import BaseApiResponse
|
|
40
|
+
from corekit.http.status import HTTPStatusCode
|
|
36
41
|
from corekit.observability.benchmarkable import Benchmarkable
|
|
37
42
|
|
|
38
|
-
__all__ = ["
|
|
43
|
+
__all__ = ["BaseHttpClient"]
|
|
39
44
|
|
|
40
|
-
# Statuses worth retrying: the server is busy or briefly unavailable, so the
|
|
41
|
-
# same request may well succeed shortly. A 4xx other than 429 will not.
|
|
42
|
-
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
class URLMismatchError(Exception):
|
|
46
|
+
def _without_query(url: str) -> str:
|
|
46
47
|
"""
|
|
47
|
-
|
|
48
|
+
Drop the query string before a retry warning is logged.
|
|
48
49
|
"""
|
|
50
|
+
parts = urlsplit(url)
|
|
51
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
52
|
+
|
|
49
53
|
|
|
54
|
+
_DEFAULT_METHODS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
HTTPMethod.GET,
|
|
57
|
+
HTTPMethod.POST,
|
|
58
|
+
HTTPMethod.PUT,
|
|
59
|
+
HTTPMethod.PATCH,
|
|
60
|
+
HTTPMethod.DELETE,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
50
63
|
|
|
51
|
-
|
|
64
|
+
|
|
65
|
+
class BaseHttpClient(Benchmarkable):
|
|
52
66
|
"""
|
|
53
|
-
Base for
|
|
67
|
+
Base for HTTP clients. Override ``base_url`` and, usually, ``headers``.
|
|
68
|
+
|
|
69
|
+
``strict=False`` accepts a foreign absolute URL as-is. ``BaseApiClient``
|
|
70
|
+
turns that into an error.
|
|
54
71
|
"""
|
|
55
72
|
|
|
56
|
-
def __init__(
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
retries: int = 3,
|
|
76
|
+
timeout: float = 30.0,
|
|
77
|
+
strict: bool = False,
|
|
78
|
+
base_delay: float = 1,
|
|
79
|
+
http_client: httpx.AsyncClient | None = None,
|
|
80
|
+
) -> None:
|
|
57
81
|
super().__init__()
|
|
58
82
|
self.retries = retries
|
|
59
83
|
self.timeout = timeout
|
|
84
|
+
self.strict = strict
|
|
85
|
+
self.base_delay = base_delay
|
|
86
|
+
# Optional pool. Left unset by default: @allow_sync runs sync calls on a
|
|
87
|
+
# throwaway loop, and an AsyncClient cannot move between loops.
|
|
88
|
+
self._http_client = http_client
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def supported_methods(self) -> frozenset[HTTPMethod]:
|
|
92
|
+
"""
|
|
93
|
+
HTTP methods this client will send. Override to narrow or extend the set.
|
|
94
|
+
"""
|
|
95
|
+
return _DEFAULT_METHODS
|
|
60
96
|
|
|
61
97
|
@property
|
|
62
98
|
def headers(self) -> dict[str, str]:
|
|
@@ -77,16 +113,18 @@ class BaseApiClient(Benchmarkable):
|
|
|
77
113
|
Resolve a path against the base URL, accepting an absolute URL that
|
|
78
114
|
already matches it.
|
|
79
115
|
"""
|
|
80
|
-
if url.startswith(self.base_url):
|
|
116
|
+
if self.base_url and url.startswith(self.base_url):
|
|
81
117
|
return url
|
|
82
118
|
if url.startswith("http"):
|
|
83
|
-
|
|
119
|
+
if self.strict:
|
|
120
|
+
raise URLMismatchError(url, self.base_url)
|
|
121
|
+
return url
|
|
84
122
|
return urljoin(self.base_url, url)
|
|
85
123
|
|
|
86
124
|
@staticmethod
|
|
87
|
-
def _to_response(raw:
|
|
125
|
+
def _to_response(raw: httpx.Response) -> BaseApiResponse:
|
|
88
126
|
"""
|
|
89
|
-
Normalize
|
|
127
|
+
Normalize a httpx response into a BaseApiResponse.
|
|
90
128
|
|
|
91
129
|
The body is decoded as JSON when it parses, and left as text otherwise,
|
|
92
130
|
so a caller never has to guard against a non-JSON error page.
|
|
@@ -99,78 +137,67 @@ class BaseApiClient(Benchmarkable):
|
|
|
99
137
|
data = {"data": data}
|
|
100
138
|
|
|
101
139
|
return BaseApiResponse(
|
|
102
|
-
status_code=raw.status_code,
|
|
140
|
+
status_code=HTTPStatusCode.coerce(raw.status_code),
|
|
103
141
|
text=raw.text,
|
|
104
142
|
data=data,
|
|
105
143
|
headers=dict(raw.headers),
|
|
106
144
|
cookies=dict(raw.cookies),
|
|
107
145
|
)
|
|
108
146
|
|
|
109
|
-
def
|
|
110
|
-
return ExponentialBackoff(retries=self.retries)
|
|
111
|
-
|
|
112
|
-
def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
113
|
-
"""
|
|
114
|
-
Send a request, retrying retryable statuses.
|
|
115
|
-
"""
|
|
116
|
-
|
|
117
|
-
target = self._build_url(url)
|
|
118
|
-
backoff = self._new_backoff()
|
|
119
|
-
|
|
120
|
-
while True:
|
|
121
|
-
with httpx.Client(timeout=self.timeout) as client:
|
|
122
|
-
raw = client.request(str(method), target, headers=self.headers, **kwargs)
|
|
123
|
-
|
|
124
|
-
if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
|
|
125
|
-
return self._to_response(raw)
|
|
126
|
-
|
|
127
|
-
self.warning(f"{method} {target} returned {raw.status_code}; retrying")
|
|
128
|
-
backoff.wait()
|
|
129
|
-
|
|
130
|
-
async def async_request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
147
|
+
async def request(self, method: HTTPMethod, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
131
148
|
"""
|
|
132
149
|
Send a request asynchronously, retrying retryable statuses.
|
|
133
150
|
"""
|
|
151
|
+
if method not in self.supported_methods:
|
|
152
|
+
raise UnsupportedMethodError(method)
|
|
134
153
|
|
|
135
154
|
target = self._build_url(url)
|
|
136
|
-
|
|
137
|
-
|
|
155
|
+
headers = dict(self.headers)
|
|
156
|
+
extra_headers = kwargs.pop("headers", None)
|
|
157
|
+
if extra_headers:
|
|
158
|
+
headers.update(extra_headers)
|
|
159
|
+
# Fresh each request: ExponentialBackoff keeps its own attempt count.
|
|
160
|
+
backoff = ExponentialBackoff(retries=self.retries, base_delay=self.base_delay)
|
|
161
|
+
|
|
162
|
+
if self._http_client is not None:
|
|
163
|
+
return await self._exchange(self._http_client, method, target, headers, backoff, kwargs)
|
|
164
|
+
|
|
165
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
166
|
+
return await self._exchange(client, method, target, headers, backoff, kwargs)
|
|
167
|
+
|
|
168
|
+
async def _exchange(
|
|
169
|
+
self,
|
|
170
|
+
client: httpx.AsyncClient,
|
|
171
|
+
method: HTTPMethod,
|
|
172
|
+
target: str,
|
|
173
|
+
headers: dict[str, str],
|
|
174
|
+
backoff: ExponentialBackoff,
|
|
175
|
+
kwargs: dict[str, Any],
|
|
176
|
+
) -> BaseApiResponse:
|
|
138
177
|
while True:
|
|
139
|
-
|
|
140
|
-
raw = await client.request(str(method), target, headers=self.headers, **kwargs)
|
|
141
|
-
|
|
178
|
+
raw = await client.request(str(method), target, headers=headers, **kwargs)
|
|
142
179
|
if raw.status_code not in RETRYABLE_STATUS_CODES or backoff.did_timeout():
|
|
143
180
|
return self._to_response(raw)
|
|
144
181
|
|
|
145
|
-
self.warning(f"{method} {target} returned {raw.status_code}; retrying")
|
|
182
|
+
self.warning(f"{method} {_without_query(target)} returned {raw.status_code}; retrying")
|
|
146
183
|
await backoff.async_wait()
|
|
147
184
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
152
|
-
return self.request(HTTPMethod.POST, url, **kwargs)
|
|
153
|
-
|
|
154
|
-
def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
155
|
-
return self.request(HTTPMethod.PUT, url, **kwargs)
|
|
156
|
-
|
|
157
|
-
def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
158
|
-
return self.request(HTTPMethod.PATCH, url, **kwargs)
|
|
159
|
-
|
|
160
|
-
def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
161
|
-
return self.request(HTTPMethod.DELETE, url, **kwargs)
|
|
162
|
-
|
|
163
|
-
async def async_get(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
164
|
-
return await self.async_request(HTTPMethod.GET, url, **kwargs)
|
|
185
|
+
@allow_sync
|
|
186
|
+
async def get(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
187
|
+
return await self.request(HTTPMethod.GET, url, **kwargs)
|
|
165
188
|
|
|
166
|
-
|
|
167
|
-
|
|
189
|
+
@allow_sync
|
|
190
|
+
async def post(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
191
|
+
return await self.request(HTTPMethod.POST, url, **kwargs)
|
|
168
192
|
|
|
169
|
-
|
|
170
|
-
|
|
193
|
+
@allow_sync
|
|
194
|
+
async def put(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
195
|
+
return await self.request(HTTPMethod.PUT, url, **kwargs)
|
|
171
196
|
|
|
172
|
-
|
|
173
|
-
|
|
197
|
+
@allow_sync
|
|
198
|
+
async def patch(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
199
|
+
return await self.request(HTTPMethod.PATCH, url, **kwargs)
|
|
174
200
|
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
@allow_sync
|
|
202
|
+
async def delete(self, url: str, **kwargs: Any) -> BaseApiResponse:
|
|
203
|
+
return await self.request(HTTPMethod.DELETE, url, **kwargs)
|