openframe-protocol-base 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.
- openframe/protocol/base/__init__.py +44 -0
- openframe/protocol/base/context.py +96 -0
- openframe/protocol/base/errors.py +244 -0
- openframe/protocol/base/testing.py +332 -0
- openframe_protocol_base-0.3.0.dist-info/METADATA +65 -0
- openframe_protocol_base-0.3.0.dist-info/RECORD +7 -0
- openframe_protocol_base-0.3.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe.protocol.base
|
|
3
|
+
==========================
|
|
4
|
+
Shared, transport-neutral mechanics every `openframe-protocol-*` binding
|
|
5
|
+
builds on: error categorisation (`errors.py`), `RequestContext`
|
|
6
|
+
construction (`context.py`), and the inbound conformance suite
|
|
7
|
+
(`testing.py`). No transport dependencies — a binding package depends on
|
|
8
|
+
this one, never the reverse.
|
|
9
|
+
|
|
10
|
+
.. stability: experimental
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from openframe.protocol.base.context import build_request_context
|
|
15
|
+
from openframe.protocol.base.errors import (
|
|
16
|
+
ErrorCategory,
|
|
17
|
+
ErrorEnvelope,
|
|
18
|
+
build_envelope,
|
|
19
|
+
categorize,
|
|
20
|
+
raise_for_envelope,
|
|
21
|
+
)
|
|
22
|
+
from openframe.protocol.base.testing import (
|
|
23
|
+
UNEXPECTED_ERROR_SECRET,
|
|
24
|
+
DriverResult,
|
|
25
|
+
RemoteClientConformanceTests,
|
|
26
|
+
RemoteClientDriver,
|
|
27
|
+
TransportConformanceTests,
|
|
28
|
+
TransportDriver,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ErrorCategory",
|
|
33
|
+
"ErrorEnvelope",
|
|
34
|
+
"categorize",
|
|
35
|
+
"build_envelope",
|
|
36
|
+
"raise_for_envelope",
|
|
37
|
+
"build_request_context",
|
|
38
|
+
"DriverResult",
|
|
39
|
+
"TransportDriver",
|
|
40
|
+
"TransportConformanceTests",
|
|
41
|
+
"UNEXPECTED_ERROR_SECRET",
|
|
42
|
+
"RemoteClientDriver",
|
|
43
|
+
"RemoteClientConformanceTests",
|
|
44
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/base/context.py
|
|
3
|
+
=====================================
|
|
4
|
+
Builds `openframe.core.inbound.RequestContext` for an inbound transport
|
|
5
|
+
binding — the one place that decides what `correlation_id` actually is,
|
|
6
|
+
so every transport does it the same way instead of each reinventing it.
|
|
7
|
+
|
|
8
|
+
`RequestContext` itself is a frozen dataclass in core with no factory
|
|
9
|
+
function (confirmed empty — nothing in core constructs one outside its
|
|
10
|
+
own tests). `principal`/`tenant` stay `None` here; wiring real auth
|
|
11
|
+
through is deferred to `openframe-infra`/security, per draft 8 section 7's
|
|
12
|
+
"reserve the hook, don't implement it yet" rule — `context_enricher`
|
|
13
|
+
below is that hook.
|
|
14
|
+
|
|
15
|
+
Dependency order:
|
|
16
|
+
protocol/base/context → openframe.core.inbound, opentelemetry (trace API)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import uuid
|
|
21
|
+
from typing import Callable, Mapping
|
|
22
|
+
|
|
23
|
+
from opentelemetry import propagate, trace
|
|
24
|
+
|
|
25
|
+
from openframe.core.inbound import RequestContext
|
|
26
|
+
|
|
27
|
+
__all__ = ["build_request_context"]
|
|
28
|
+
|
|
29
|
+
ContextEnricher = Callable[[RequestContext], RequestContext]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_request_context(
|
|
33
|
+
headers: Mapping[str, str],
|
|
34
|
+
*,
|
|
35
|
+
correlation_header: str = "x-correlation-id",
|
|
36
|
+
context_enricher: ContextEnricher | None = None,
|
|
37
|
+
) -> RequestContext:
|
|
38
|
+
"""
|
|
39
|
+
Build a `RequestContext` for one inbound request.
|
|
40
|
+
|
|
41
|
+
`correlation_id` resolution order (first match wins):
|
|
42
|
+
|
|
43
|
+
1. The trace id from an incoming W3C `traceparent` header, extracted
|
|
44
|
+
directly from *this request's own headers* — not from whatever
|
|
45
|
+
OTel span happens to be ambiently active. This distinction is
|
|
46
|
+
the fix for a real bug found while building this: `TelemetryMiddleware`
|
|
47
|
+
starts a span for *every* request regardless of whether one
|
|
48
|
+
actually propagated in from a caller, so checking
|
|
49
|
+
`trace.get_current_span()` (ambient state) can never tell "a
|
|
50
|
+
trace genuinely arrived" apart from "a fresh local span with no
|
|
51
|
+
upstream caller" — it would always win, making step 2 below
|
|
52
|
+
unreachable in practice. Extracting straight from `headers`
|
|
53
|
+
avoids that: no incoming `traceparent` means no trace id here,
|
|
54
|
+
full stop, regardless of what span is locally active.
|
|
55
|
+
2. The `correlation_header` request header (default
|
|
56
|
+
`X-Correlation-ID`), for a caller that sends its own id without
|
|
57
|
+
a trace context.
|
|
58
|
+
3. A freshly generated `uuid4`, so `correlation_id` is never empty.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
headers: The inbound request's headers. Lookup is
|
|
62
|
+
case-insensitive — pass whatever mapping the
|
|
63
|
+
transport already has; most ASGI frameworks'
|
|
64
|
+
header mappings already are.
|
|
65
|
+
correlation_header: Header name checked in step 2. Override only
|
|
66
|
+
if a deployment uses a non-default header.
|
|
67
|
+
context_enricher: Optional hook called with the built context
|
|
68
|
+
before it's returned, so a caller can attach
|
|
69
|
+
`principal`/`tenant` once real auth exists.
|
|
70
|
+
`None` (the default) leaves both unset — this
|
|
71
|
+
is the reserved-but-unimplemented auth seam
|
|
72
|
+
draft 8 asks for, not a finished feature.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A `RequestContext` with a non-empty `correlation_id`.
|
|
76
|
+
"""
|
|
77
|
+
extracted = propagate.extract(headers)
|
|
78
|
+
span_context = trace.get_current_span(extracted).get_span_context()
|
|
79
|
+
if span_context.is_valid:
|
|
80
|
+
correlation_id = f"{span_context.trace_id:032x}"
|
|
81
|
+
else:
|
|
82
|
+
correlation_id = _get_header(headers, correlation_header) or str(uuid.uuid4())
|
|
83
|
+
|
|
84
|
+
context = RequestContext(correlation_id=correlation_id)
|
|
85
|
+
if context_enricher is not None:
|
|
86
|
+
context = context_enricher(context)
|
|
87
|
+
return context
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _get_header(headers: Mapping[str, str], name: str) -> str | None:
|
|
91
|
+
"""Case-insensitive header lookup — ASGI header mappings vary on casing."""
|
|
92
|
+
lowered = name.lower()
|
|
93
|
+
for key, value in headers.items():
|
|
94
|
+
if key.lower() == lowered:
|
|
95
|
+
return value
|
|
96
|
+
return None
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/base/errors.py
|
|
3
|
+
====================================
|
|
4
|
+
Transport-neutral error categorisation — the single source of truth every
|
|
5
|
+
`openframe-protocol-*` binding maps through, instead of each transport
|
|
6
|
+
hand-writing its own isinstance ladder over `OpenFrameError` subclasses.
|
|
7
|
+
|
|
8
|
+
`categorize()` is keyed on `exc.code` (a plain string, e.g.
|
|
9
|
+
"adapter.not_found"), not on `isinstance` checks against concrete classes —
|
|
10
|
+
this means a *new* `AdapterError` subclass from any downstream package,
|
|
11
|
+
with a code this module has never seen, still gets a sane `INTERNAL`
|
|
12
|
+
fallback instead of silently escaping unmapped. `ErrorCategory` itself is
|
|
13
|
+
NOT added to `openframe-core` — draft 8's own rule-of-three discipline
|
|
14
|
+
applies here exactly as it did to `Capability`/`Communication`: promote to
|
|
15
|
+
core only once at least two transports (HTTP + gRPC) show real
|
|
16
|
+
isinstance-ladder pain from re-deriving this table themselves.
|
|
17
|
+
|
|
18
|
+
Dependency order:
|
|
19
|
+
protocol/base/errors → openframe.core.exceptions (read-only; never
|
|
20
|
+
raises or re-exports core exception classes itself)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import uuid
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from enum import Enum
|
|
27
|
+
|
|
28
|
+
from openframe.core.exceptions import (
|
|
29
|
+
AdapterConnectionError,
|
|
30
|
+
AdapterNotFoundError,
|
|
31
|
+
AdapterQueryError,
|
|
32
|
+
AdapterTimeoutError,
|
|
33
|
+
OpenFrameError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ErrorCategory",
|
|
38
|
+
"ErrorEnvelope",
|
|
39
|
+
"categorize",
|
|
40
|
+
"build_envelope",
|
|
41
|
+
"raise_for_envelope",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ErrorCategory(Enum):
|
|
46
|
+
"""
|
|
47
|
+
Closed, transport-neutral bucket an error falls into.
|
|
48
|
+
|
|
49
|
+
Every `openframe-protocol-*` binding maps exactly these five values to
|
|
50
|
+
its own wire representation (HTTP status, gRPC status code, an MCP
|
|
51
|
+
tool-error shape, ...) — see draft 8 section 5.2's table. Adding a
|
|
52
|
+
sixth value here is a decision with the same weight as adding a
|
|
53
|
+
`Capability` member: don't, until a real transport needs a
|
|
54
|
+
distinction none of these five already draw.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
NOT_FOUND = "not_found"
|
|
58
|
+
INVALID = "invalid"
|
|
59
|
+
TIMEOUT = "timeout"
|
|
60
|
+
UNAVAILABLE = "unavailable"
|
|
61
|
+
INTERNAL = "internal"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Code → category. Keyed on the plain `domain.kind` string `OpenFrameError.code`
|
|
65
|
+
# carries as data (see openframe.core.exceptions.codes.ErrorCode's own docstring)
|
|
66
|
+
# rather than on the exception *class*, so a downstream package's own
|
|
67
|
+
# `"ai.rate_limited"`-style code can be added here later without this module
|
|
68
|
+
# needing to import that package's exception classes at all.
|
|
69
|
+
_CODE_TO_CATEGORY: dict[str, ErrorCategory] = {
|
|
70
|
+
"adapter.not_found": ErrorCategory.NOT_FOUND,
|
|
71
|
+
"adapter.timeout": ErrorCategory.TIMEOUT,
|
|
72
|
+
"adapter.connection": ErrorCategory.UNAVAILABLE,
|
|
73
|
+
"plugin.not_found": ErrorCategory.NOT_FOUND,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Retryability fallback for codes this module doesn't recognise at all
|
|
77
|
+
# (a non-OpenFrameError, or an OpenFrameError whose code isn't in the table
|
|
78
|
+
# above) — checked by exception *type* only as a last resort, since an
|
|
79
|
+
# unrecognised code has no `retryable` semantics to trust.
|
|
80
|
+
_RETRYABLE_TYPES = (AdapterConnectionError, AdapterTimeoutError)
|
|
81
|
+
_NOT_FOUND_TYPES = (AdapterNotFoundError,)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def categorize(exc: BaseException) -> ErrorCategory:
|
|
85
|
+
"""
|
|
86
|
+
Map any exception to its transport-neutral category.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
exc: The exception a binding caught — an `OpenFrameError` subclass,
|
|
90
|
+
a `pydantic.ValidationError`, or anything else (a bare bug).
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The matching `ErrorCategory`. Falls back to `INTERNAL` for
|
|
94
|
+
anything this module has no specific mapping for — including a
|
|
95
|
+
brand-new `AdapterError` subclass from a package this module has
|
|
96
|
+
never heard of. Never raises.
|
|
97
|
+
"""
|
|
98
|
+
# pydantic's ValidationError (and FastAPI's RequestValidationError,
|
|
99
|
+
# which wraps one) is the one validation-error source core itself has
|
|
100
|
+
# none of — checked by class name string, not an isinstance import, so
|
|
101
|
+
# this module has zero pydantic/FastAPI dependency of its own.
|
|
102
|
+
if type(exc).__name__ in ("ValidationError", "RequestValidationError"):
|
|
103
|
+
return ErrorCategory.INVALID
|
|
104
|
+
|
|
105
|
+
if isinstance(exc, OpenFrameError):
|
|
106
|
+
category = _CODE_TO_CATEGORY.get(exc.code)
|
|
107
|
+
if category is not None:
|
|
108
|
+
return category
|
|
109
|
+
# Unrecognised OpenFrameError code — fall back to the exception's
|
|
110
|
+
# own `retryable` flag/type rather than guessing blindly.
|
|
111
|
+
if isinstance(exc, _NOT_FOUND_TYPES):
|
|
112
|
+
return ErrorCategory.NOT_FOUND
|
|
113
|
+
if isinstance(exc, _RETRYABLE_TYPES) or exc.retryable:
|
|
114
|
+
return ErrorCategory.UNAVAILABLE
|
|
115
|
+
return ErrorCategory.INTERNAL
|
|
116
|
+
|
|
117
|
+
return ErrorCategory.INTERNAL
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class ErrorEnvelope:
|
|
122
|
+
"""
|
|
123
|
+
The wire-level error body shape every transport serialises, in its own
|
|
124
|
+
format, from the same four fields — see draft 8 section 5.2.
|
|
125
|
+
|
|
126
|
+
Attributes:
|
|
127
|
+
code: `OpenFrameError.code` if the source was one,
|
|
128
|
+
else a generic `"internal.error"`.
|
|
129
|
+
message: Safe to show the caller. Never the raw `str(exc)`
|
|
130
|
+
for an `INTERNAL`-category error — see
|
|
131
|
+
`build_envelope`'s own docstring for why.
|
|
132
|
+
retryable: Drives `Retry-After` (HTTP) and equivalent retry
|
|
133
|
+
hints on other transports.
|
|
134
|
+
correlation_id: Threaded through so a caller can quote it back
|
|
135
|
+
when reporting an issue.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
code: str
|
|
139
|
+
message: str
|
|
140
|
+
retryable: bool
|
|
141
|
+
correlation_id: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_envelope(exc: BaseException, *, correlation_id: str | None = None) -> ErrorEnvelope:
|
|
145
|
+
"""
|
|
146
|
+
Build the wire-safe error envelope for *exc*.
|
|
147
|
+
|
|
148
|
+
Deliberately does **not** leak internals in the message for an
|
|
149
|
+
`INTERNAL`-category error (draft 8's own stated rule) — the real
|
|
150
|
+
`str(exc)` goes to logs and the current span via
|
|
151
|
+
`openframe.core.telemetry.record_error`, never into the response body,
|
|
152
|
+
since an `AdapterError.__str__` includes the driver cause and a stray
|
|
153
|
+
driver error message is exactly the kind of internal detail a 500
|
|
154
|
+
body should never carry.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
exc: The exception a binding caught.
|
|
158
|
+
correlation_id: The active request's correlation id. A fresh
|
|
159
|
+
`uuid4` is generated if the caller has none yet
|
|
160
|
+
(should not normally happen — bindings build
|
|
161
|
+
`RequestContext` before the handler can raise).
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
An `ErrorEnvelope` ready for the calling transport to serialise.
|
|
165
|
+
"""
|
|
166
|
+
category = categorize(exc)
|
|
167
|
+
cid = correlation_id or str(uuid.uuid4())
|
|
168
|
+
|
|
169
|
+
if isinstance(exc, OpenFrameError):
|
|
170
|
+
code = exc.code
|
|
171
|
+
retryable = exc.retryable
|
|
172
|
+
else:
|
|
173
|
+
code = "internal.error"
|
|
174
|
+
retryable = False
|
|
175
|
+
|
|
176
|
+
if category is ErrorCategory.INTERNAL:
|
|
177
|
+
message = "An internal error occurred."
|
|
178
|
+
elif isinstance(exc, OpenFrameError):
|
|
179
|
+
message = exc.message
|
|
180
|
+
else:
|
|
181
|
+
message = str(exc)
|
|
182
|
+
|
|
183
|
+
return ErrorEnvelope(code=code, message=message, retryable=retryable, correlation_id=cid)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# Client side: reconstruct an exception from a received envelope
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
_CATEGORY_TO_EXCEPTION_CLASS: dict[ErrorCategory, type[OpenFrameError]] = {
|
|
191
|
+
ErrorCategory.NOT_FOUND: AdapterNotFoundError,
|
|
192
|
+
ErrorCategory.TIMEOUT: AdapterTimeoutError,
|
|
193
|
+
ErrorCategory.UNAVAILABLE: AdapterConnectionError,
|
|
194
|
+
# INVALID and INTERNAL both reconstruct as AdapterQueryError — core's
|
|
195
|
+
# AdapterError family has no dedicated "the request itself was
|
|
196
|
+
# malformed" subclass (the server side used pydantic's ValidationError
|
|
197
|
+
# directly, which only exists on the server; there is nothing to
|
|
198
|
+
# reconstruct symmetrically on the client). AdapterQueryError is the
|
|
199
|
+
# closest existing fit for both: a failed operation, not retryable by
|
|
200
|
+
# default — documented here as a deliberate choice, not an oversight.
|
|
201
|
+
ErrorCategory.INVALID: AdapterQueryError,
|
|
202
|
+
ErrorCategory.INTERNAL: AdapterQueryError,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def raise_for_envelope(category: ErrorCategory, envelope: ErrorEnvelope, *, remote_name: str) -> None:
|
|
207
|
+
"""
|
|
208
|
+
Reconstruct and raise the `AdapterError` subclass matching a remote
|
|
209
|
+
call's error envelope — the client-side mirror of `build_envelope`.
|
|
210
|
+
|
|
211
|
+
A remote service call is, from the calling code's perspective,
|
|
212
|
+
exactly as fallible as a database call, and should surface through
|
|
213
|
+
the same `AdapterError` family so callers don't need a separate
|
|
214
|
+
catch clause for "this dependency happens to be another service"
|
|
215
|
+
versus "this dependency happens to be a database" — see
|
|
216
|
+
`openframe.core.remote`'s own docstring for the same reasoning
|
|
217
|
+
applied to the port contract itself.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
category: The category the response's status (or whatever the
|
|
221
|
+
calling transport uses to signal failure) mapped to.
|
|
222
|
+
envelope: The decoded error envelope from the response body.
|
|
223
|
+
remote_name: Identifies which remote call this was, in the raised
|
|
224
|
+
exception's own `adapter`/`operation` context — e.g.
|
|
225
|
+
`"http:GET /items/{item_id}"`. Not the envelope's own
|
|
226
|
+
`code`, which identifies the *server's* error, not
|
|
227
|
+
which call on the client side failed.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
Always raises — this function never returns normally. The
|
|
231
|
+
specific `AdapterError` subclass is chosen by `category`;
|
|
232
|
+
`retryable` on the raised instance reflects the envelope's own
|
|
233
|
+
`retryable` flag, which may differ from the subclass's default
|
|
234
|
+
(e.g. a `503` the server marked non-retryable for some reason).
|
|
235
|
+
"""
|
|
236
|
+
exc_class = _CATEGORY_TO_EXCEPTION_CLASS[category]
|
|
237
|
+
# AdapterError subclasses' own __init__ only forwards (message,
|
|
238
|
+
# adapter, operation, cause) — confirmed directly, not assumed:
|
|
239
|
+
# retryable/correlation_id are NOT constructor kwargs despite being
|
|
240
|
+
# real OpenFrameError attributes, so they're set after construction.
|
|
241
|
+
exc = exc_class(envelope.message, adapter=remote_name, operation="call")
|
|
242
|
+
exc.retryable = envelope.retryable
|
|
243
|
+
exc.correlation_id = envelope.correlation_id
|
|
244
|
+
raise exc
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/base/testing.py
|
|
3
|
+
=====================================
|
|
4
|
+
`TransportDriver` + `TransportConformanceTests` — the one conformance
|
|
5
|
+
suite every inbound `openframe-protocol-*` binding proves itself against.
|
|
6
|
+
|
|
7
|
+
Incubating here, not in `openframe-core`, per draft 8 section 4's own
|
|
8
|
+
rule: core's existing suites (`core.testing.contracts.inbound`) are
|
|
9
|
+
shape-only (does `execute()` have the right signature) — they know
|
|
10
|
+
nothing about transports, error mapping, or correlation ids. This suite
|
|
11
|
+
is promoted to `openframe.core.testing` only after HTTP *and* gRPC both
|
|
12
|
+
pass it for real (the same rule-of-three discipline already applied to
|
|
13
|
+
`SessionHandler`).
|
|
14
|
+
|
|
15
|
+
Design choice: every assertion goes through `DriverResult`, a normalised
|
|
16
|
+
shape every transport's own driver translates its real response into —
|
|
17
|
+
an HTTP status code and a gRPC status code mean nothing to each other,
|
|
18
|
+
but `ErrorCategory.NOT_FOUND` means the same thing regardless of which
|
|
19
|
+
wire format produced it. This is what actually keeps the suite
|
|
20
|
+
transport-agnostic rather than secretly HTTP-shaped.
|
|
21
|
+
|
|
22
|
+
No top-level pytest import — importable without pytest installed,
|
|
23
|
+
matching every `openframe.core.testing` contract module's own convention.
|
|
24
|
+
|
|
25
|
+
Dependency order:
|
|
26
|
+
protocol/base/testing → protocol/base/errors
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Mapping, Protocol, runtime_checkable
|
|
32
|
+
|
|
33
|
+
from openframe.protocol.base.errors import ErrorCategory
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DriverResult",
|
|
37
|
+
"TransportDriver",
|
|
38
|
+
"TransportConformanceTests",
|
|
39
|
+
"UNEXPECTED_ERROR_SECRET",
|
|
40
|
+
"RemoteClientDriver",
|
|
41
|
+
"RemoteClientConformanceTests",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Shared between this module's own assertions and every driver
|
|
45
|
+
# implementation's `call_unexpected_error()` — the driver rigs its
|
|
46
|
+
# handler to raise an exception carrying this exact string, and the
|
|
47
|
+
# conformance test asserts it is absent from the caller-visible body.
|
|
48
|
+
# A real driver-cause string, not a placeholder — if this ever shows up
|
|
49
|
+
# in a response body, the leak is real, not a test artefact.
|
|
50
|
+
UNEXPECTED_ERROR_SECRET = "driver-internal-cause-must-never-reach-a-caller"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class DriverResult:
|
|
55
|
+
"""
|
|
56
|
+
One call's outcome, normalised to a shape every transport can produce.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
category: `None` on success. An `ErrorCategory` member on
|
|
60
|
+
failure — the caller-facing classification, not
|
|
61
|
+
a transport-specific status code.
|
|
62
|
+
body: The decoded response body (a dict) on success,
|
|
63
|
+
or the decoded error envelope on failure.
|
|
64
|
+
correlation_id: Extracted from wherever the transport carries it
|
|
65
|
+
(an HTTP response header, a gRPC trailer, ...).
|
|
66
|
+
`None` only if the driver genuinely couldn't find
|
|
67
|
+
one — a real conformance failure if assertions
|
|
68
|
+
don't later catch it, not an expected state.
|
|
69
|
+
retryable: Only meaningful when `category` is not `None`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
category: ErrorCategory | None
|
|
73
|
+
body: Mapping[str, Any]
|
|
74
|
+
correlation_id: str | None
|
|
75
|
+
retryable: bool | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@runtime_checkable
|
|
79
|
+
class TransportDriver(Protocol):
|
|
80
|
+
"""
|
|
81
|
+
What a transport's own test module implements to run this suite.
|
|
82
|
+
|
|
83
|
+
Each `call_*` method exercises one bound handler scenario end-to-end
|
|
84
|
+
(through the real binder, not by calling a handler method directly)
|
|
85
|
+
and returns the outcome as a `DriverResult`. How a driver rigs its
|
|
86
|
+
handler to produce each scenario (which fake, which `raises=`) is the
|
|
87
|
+
driver's own business — `SpyCommandHandler(raises=...)`/
|
|
88
|
+
`SpyQueryHandler(raises=...)` from `openframe.core.testing` cover
|
|
89
|
+
every case below.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
async def call_create(self) -> DriverResult:
|
|
93
|
+
"""A command that succeeds — e.g. creating an entity."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
async def call_get_existing(self) -> DriverResult:
|
|
97
|
+
"""A query that finds its target and returns a non-empty result."""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
async def call_get_missing(self) -> DriverResult:
|
|
101
|
+
"""A query whose target doesn't exist — must categorise NOT_FOUND."""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
async def call_list(self) -> DriverResult:
|
|
105
|
+
"""A query returning a collection — proves the binder isn't single-entity-only."""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
async def call_invalid_input(self) -> DriverResult:
|
|
109
|
+
"""A request whose payload fails input validation — must categorise INVALID."""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
async def call_timeout(self) -> DriverResult:
|
|
113
|
+
"""The handler raises `AdapterTimeoutError` — must categorise TIMEOUT, retryable."""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
async def call_connection_error(self) -> DriverResult:
|
|
117
|
+
"""The handler raises `AdapterConnectionError` — must categorise UNAVAILABLE, retryable."""
|
|
118
|
+
...
|
|
119
|
+
|
|
120
|
+
async def call_unexpected_error(self) -> DriverResult:
|
|
121
|
+
"""
|
|
122
|
+
The handler raises a bare, non-`OpenFrameError` exception carrying
|
|
123
|
+
`UNEXPECTED_ERROR_SECRET` in its message — must categorise
|
|
124
|
+
INTERNAL, and the secret must not appear in `body`.
|
|
125
|
+
"""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
async def call_with_traceparent(self, traceparent: str) -> DriverResult:
|
|
129
|
+
"""A successful call carrying a real W3C `traceparent` header."""
|
|
130
|
+
...
|
|
131
|
+
|
|
132
|
+
async def call_with_correlation_header(self, value: str) -> DriverResult:
|
|
133
|
+
"""A successful call carrying a correlation-id header, no traceparent."""
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
async def call_with_no_trace_context(self) -> DriverResult:
|
|
137
|
+
"""A successful call with neither header — correlation_id must still be generated."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class TransportConformanceTests:
|
|
142
|
+
"""
|
|
143
|
+
Reusable pytest base class. Subclasses provide one fixture:
|
|
144
|
+
|
|
145
|
+
``driver``
|
|
146
|
+
A fresh instance satisfying `TransportDriver`, wired to a real
|
|
147
|
+
bound handler over the transport under test (e.g. `httpx` against
|
|
148
|
+
an in-process ASGI app for HTTP).
|
|
149
|
+
|
|
150
|
+
.. stability: experimental
|
|
151
|
+
New alongside `openframe-protocol-base` itself. Promotion to
|
|
152
|
+
`openframe.core.testing` is gated on two transports passing this
|
|
153
|
+
suite for real, per draft 8's rule-of-three discipline.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
async def test_create_succeeds(self, driver: TransportDriver) -> None:
|
|
157
|
+
result = await driver.call_create()
|
|
158
|
+
assert result.category is None
|
|
159
|
+
|
|
160
|
+
async def test_get_existing_succeeds(self, driver: TransportDriver) -> None:
|
|
161
|
+
result = await driver.call_get_existing()
|
|
162
|
+
assert result.category is None
|
|
163
|
+
assert result.body
|
|
164
|
+
|
|
165
|
+
async def test_get_missing_is_not_found(self, driver: TransportDriver) -> None:
|
|
166
|
+
result = await driver.call_get_missing()
|
|
167
|
+
assert result.category is ErrorCategory.NOT_FOUND
|
|
168
|
+
|
|
169
|
+
async def test_list_succeeds(self, driver: TransportDriver) -> None:
|
|
170
|
+
result = await driver.call_list()
|
|
171
|
+
assert result.category is None
|
|
172
|
+
|
|
173
|
+
async def test_invalid_input_is_invalid(self, driver: TransportDriver) -> None:
|
|
174
|
+
result = await driver.call_invalid_input()
|
|
175
|
+
assert result.category is ErrorCategory.INVALID
|
|
176
|
+
|
|
177
|
+
async def test_timeout_is_timeout_and_retryable(self, driver: TransportDriver) -> None:
|
|
178
|
+
result = await driver.call_timeout()
|
|
179
|
+
assert result.category is ErrorCategory.TIMEOUT
|
|
180
|
+
assert result.retryable is True
|
|
181
|
+
|
|
182
|
+
async def test_connection_error_is_unavailable_and_retryable(
|
|
183
|
+
self, driver: TransportDriver
|
|
184
|
+
) -> None:
|
|
185
|
+
result = await driver.call_connection_error()
|
|
186
|
+
assert result.category is ErrorCategory.UNAVAILABLE
|
|
187
|
+
assert result.retryable is True
|
|
188
|
+
|
|
189
|
+
async def test_unexpected_error_is_internal_and_does_not_leak(
|
|
190
|
+
self, driver: TransportDriver
|
|
191
|
+
) -> None:
|
|
192
|
+
result = await driver.call_unexpected_error()
|
|
193
|
+
assert result.category is ErrorCategory.INTERNAL
|
|
194
|
+
body_text = str(result.body)
|
|
195
|
+
assert UNEXPECTED_ERROR_SECRET not in body_text, (
|
|
196
|
+
"Driver-internal cause leaked into a caller-visible response body — "
|
|
197
|
+
"an INTERNAL-category error must never expose the real exception message."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
async def test_correlation_id_from_traceparent(self, driver: TransportDriver) -> None:
|
|
201
|
+
traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
|
|
202
|
+
result = await driver.call_with_traceparent(traceparent)
|
|
203
|
+
assert result.correlation_id is not None
|
|
204
|
+
assert result.correlation_id != ""
|
|
205
|
+
|
|
206
|
+
async def test_correlation_id_from_header(self, driver: TransportDriver) -> None:
|
|
207
|
+
result = await driver.call_with_correlation_header("test-corr-abc123")
|
|
208
|
+
assert result.correlation_id == "test-corr-abc123"
|
|
209
|
+
|
|
210
|
+
async def test_correlation_id_generated_when_absent(self, driver: TransportDriver) -> None:
|
|
211
|
+
result = await driver.call_with_no_trace_context()
|
|
212
|
+
assert result.correlation_id is not None
|
|
213
|
+
assert result.correlation_id != ""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Client-side conformance — the outbound counterpart, added for M1b
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
#
|
|
220
|
+
# Deliberately NOT built on DriverResult's normalise-into-data pattern. A
|
|
221
|
+
# remote client's execute() doesn't return a category-tagged result on
|
|
222
|
+
# failure — it raises a real AdapterError subclass directly (that's the
|
|
223
|
+
# whole point of raise_for_envelope: the caller doesn't need to know this
|
|
224
|
+
# dependency happens to be a remote service rather than a database). So
|
|
225
|
+
# the conformance test for the CLIENT side just calls the driver and
|
|
226
|
+
# asserts the real exception type, rather than introducing a second
|
|
227
|
+
# normalised-result type that would only exist to immediately throw an
|
|
228
|
+
# exception anyway.
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@runtime_checkable
|
|
232
|
+
class RemoteClientDriver(Protocol):
|
|
233
|
+
"""
|
|
234
|
+
What a remote-client implementation's own test module provides to
|
|
235
|
+
run `RemoteClientConformanceTests`. Each `call_*` method exercises
|
|
236
|
+
one real client call end to end (through the real client class, not
|
|
237
|
+
by calling a lower-level method directly) against a real bound
|
|
238
|
+
server on the other end — same rigor as `TransportDriver`, just for
|
|
239
|
+
the opposite direction.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
async def call_success(self) -> Any:
|
|
243
|
+
"""A call that succeeds — returns the decoded result."""
|
|
244
|
+
...
|
|
245
|
+
|
|
246
|
+
async def call_not_found(self) -> Any:
|
|
247
|
+
"""A call whose target doesn't exist server-side. Must raise, not return."""
|
|
248
|
+
...
|
|
249
|
+
|
|
250
|
+
async def call_timeout(self) -> Any:
|
|
251
|
+
"""The server raises `AdapterTimeoutError`. Must raise the same type client-side."""
|
|
252
|
+
...
|
|
253
|
+
|
|
254
|
+
async def call_connection_refused(self) -> Any:
|
|
255
|
+
"""A genuine network-level connection failure — not a simulated server error."""
|
|
256
|
+
...
|
|
257
|
+
|
|
258
|
+
async def call_unexpected_error(self) -> Any:
|
|
259
|
+
"""
|
|
260
|
+
The server raises a bare, non-`OpenFrameError` exception carrying
|
|
261
|
+
`UNEXPECTED_ERROR_SECRET`. Must raise `AdapterQueryError`
|
|
262
|
+
client-side, with the secret absent from the exception's message.
|
|
263
|
+
"""
|
|
264
|
+
...
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class RemoteClientConformanceTests:
|
|
268
|
+
"""
|
|
269
|
+
Reusable pytest base class. Subclasses provide one fixture:
|
|
270
|
+
|
|
271
|
+
``driver``
|
|
272
|
+
A fresh instance satisfying `RemoteClientDriver`, wired to a real
|
|
273
|
+
client calling a real bound server over the transport under test.
|
|
274
|
+
|
|
275
|
+
.. stability: experimental
|
|
276
|
+
New alongside `openframe-protocol-http`'s `HttpRemoteQueryClient`/
|
|
277
|
+
`HttpRemoteCommandClient`. Promotion to `openframe.core.testing` is
|
|
278
|
+
gated on two transports (HTTP, gRPC) passing this suite for real,
|
|
279
|
+
same rule as `TransportConformanceTests`.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
async def test_call_success_returns_a_value(self, driver: "RemoteClientDriver") -> None:
|
|
283
|
+
result = await driver.call_success()
|
|
284
|
+
assert result is not None
|
|
285
|
+
|
|
286
|
+
async def test_call_not_found_raises_adapter_not_found_error(
|
|
287
|
+
self, driver: "RemoteClientDriver"
|
|
288
|
+
) -> None:
|
|
289
|
+
from openframe.core.exceptions import AdapterNotFoundError
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
await driver.call_not_found()
|
|
293
|
+
except AdapterNotFoundError:
|
|
294
|
+
pass
|
|
295
|
+
else:
|
|
296
|
+
raise AssertionError("expected AdapterNotFoundError to be raised")
|
|
297
|
+
|
|
298
|
+
async def test_call_timeout_raises_adapter_timeout_error(
|
|
299
|
+
self, driver: "RemoteClientDriver"
|
|
300
|
+
) -> None:
|
|
301
|
+
from openframe.core.exceptions import AdapterTimeoutError
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
await driver.call_timeout()
|
|
305
|
+
except AdapterTimeoutError as exc:
|
|
306
|
+
assert exc.retryable is True
|
|
307
|
+
else:
|
|
308
|
+
raise AssertionError("expected AdapterTimeoutError to be raised")
|
|
309
|
+
|
|
310
|
+
async def test_call_connection_refused_raises_adapter_connection_error(
|
|
311
|
+
self, driver: "RemoteClientDriver"
|
|
312
|
+
) -> None:
|
|
313
|
+
from openframe.core.exceptions import AdapterConnectionError
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
await driver.call_connection_refused()
|
|
317
|
+
except AdapterConnectionError:
|
|
318
|
+
pass
|
|
319
|
+
else:
|
|
320
|
+
raise AssertionError("expected AdapterConnectionError to be raised")
|
|
321
|
+
|
|
322
|
+
async def test_call_unexpected_error_raises_adapter_query_error_and_does_not_leak(
|
|
323
|
+
self, driver: "RemoteClientDriver"
|
|
324
|
+
) -> None:
|
|
325
|
+
from openframe.core.exceptions import AdapterQueryError
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
await driver.call_unexpected_error()
|
|
329
|
+
except AdapterQueryError as exc:
|
|
330
|
+
assert UNEXPECTED_ERROR_SECRET not in exc.message
|
|
331
|
+
else:
|
|
332
|
+
raise AssertionError("expected AdapterQueryError to be raised")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openframe-protocol-base
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: OpenFrame Microservice Suite - shared transport-neutral protocol mechanics (error mapping, RequestContext construction, inbound conformance suite).
|
|
5
|
+
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-protocol
|
|
6
|
+
Project-URL: Documentation, https://furious-meteors.github.io/openframe-protocol/
|
|
7
|
+
Project-URL: Repository, https://github.com/Furious-Meteors/openframe-protocol
|
|
8
|
+
Project-URL: Changelog, https://github.com/Furious-Meteors/openframe-protocol/blob/production/.github/CHANGELOG.md
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Furious-Meteors/openframe-protocol/issues
|
|
10
|
+
Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
11
|
+
Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
12
|
+
License: MIT
|
|
13
|
+
Keywords: hexagonal,microservice,openframe,protocol
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: openframe-core<4,>=3.2.1
|
|
26
|
+
Requires-Dist: opentelemetry-api>=1.24
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# openframe-protocol-base
|
|
34
|
+
|
|
35
|
+
Shared, transport-neutral mechanics every `openframe-protocol-*` binding
|
|
36
|
+
package builds on. No transport dependencies of its own.
|
|
37
|
+
|
|
38
|
+
- `errors.py` — `categorize(exc) -> ErrorCategory` and `build_envelope(exc)`,
|
|
39
|
+
the one place every transport maps `OpenFrameError` (and anything else)
|
|
40
|
+
to a caller-safe error shape.
|
|
41
|
+
- `context.py` — `build_request_context(headers)`, building
|
|
42
|
+
`openframe.core.inbound.RequestContext` consistently across transports
|
|
43
|
+
(trace id, then a correlation header, then a generated uuid4).
|
|
44
|
+
- `testing.py` — `TransportConformanceTests` + `TransportDriver`, the
|
|
45
|
+
suite every binding proves itself against.
|
|
46
|
+
|
|
47
|
+
## Wiring example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from openframe.protocol.base import build_envelope, build_request_context
|
|
51
|
+
|
|
52
|
+
context = build_request_context(request.headers)
|
|
53
|
+
try:
|
|
54
|
+
result = await handler.execute(command, context)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
envelope = build_envelope(exc, correlation_id=context.correlation_id)
|
|
57
|
+
# serialise envelope in whatever shape this transport uses
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Status
|
|
61
|
+
|
|
62
|
+
Experimental — incubating the inbound conformance suite here until two
|
|
63
|
+
transports (HTTP, gRPC) both pass it for real, per the project's own
|
|
64
|
+
rule-of-three discipline for promoting shared infrastructure into
|
|
65
|
+
`openframe-core`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
openframe/protocol/base/__init__.py,sha256=ZDRoe87Ge04hGohxAtt1mEWy02kPPYenu5JcxUR2iVY,1164
|
|
2
|
+
openframe/protocol/base/context.py,sha256=EkixS6VYZg_Kk54hDP8O6_qjWfc9jqh6eEW6l5Ir6Zc,4119
|
|
3
|
+
openframe/protocol/base/errors.py,sha256=VFQACZwAL_AsXRlVnS6fq0zDKxIxzgOcjIP4u0C0G_w,10337
|
|
4
|
+
openframe/protocol/base/testing.py,sha256=vZ4Q0FKKaDfG0fn_eEn0k5qqf10z1EeNQCo1aJLur68,13525
|
|
5
|
+
openframe_protocol_base-0.3.0.dist-info/METADATA,sha256=TuKLyjlxYM-bVe5-yKg6SApzYoDqgA1R39G4w2gymfM,2956
|
|
6
|
+
openframe_protocol_base-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
openframe_protocol_base-0.3.0.dist-info/RECORD,,
|