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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any