openframe-protocol-base 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → openframe_protocol_base-0.4.0}/PKG-INFO +1 -1
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/__init__.py +22 -3
- openframe_protocol_base-0.4.0/openframe/protocol/base/session.py +212 -0
- openframe_protocol_base-0.4.0/openframe/protocol/base/session_testing.py +158 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/pyproject.toml +1 -1
- openframe_protocol_base-0.4.0/tests/test_session.py +219 -0
- openframe_protocol_base-0.4.0/tests/test_session_testing.py +143 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/.gitignore +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/README.md +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/context.py +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/errors.py +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/testing.py +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/tests/conftest.py +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/tests/test_context.py +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/tests/test_errors.py +0 -0
- {openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/tests/test_testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openframe-protocol-base
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: OpenFrame Microservice Suite - shared transport-neutral protocol mechanics (error mapping, RequestContext construction, inbound conformance suite).
|
|
5
5
|
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-protocol
|
|
6
6
|
Project-URL: Documentation, https://furious-meteors.github.io/openframe-protocol/
|
{openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/__init__.py
RENAMED
|
@@ -3,9 +3,10 @@ openframe.protocol.base
|
|
|
3
3
|
==========================
|
|
4
4
|
Shared, transport-neutral mechanics every `openframe-protocol-*` binding
|
|
5
5
|
builds on: error categorisation (`errors.py`), `RequestContext`
|
|
6
|
-
construction (`context.py`),
|
|
7
|
-
(`testing.py`)
|
|
8
|
-
|
|
6
|
+
construction (`context.py`), the inbound request/response conformance
|
|
7
|
+
suite (`testing.py`), the session-lifecycle loop (`session.py`), and its
|
|
8
|
+
own conformance suite (`session_testing.py`). No transport dependencies —
|
|
9
|
+
a binding package depends on this one, never the reverse.
|
|
9
10
|
|
|
10
11
|
.. stability: experimental
|
|
11
12
|
"""
|
|
@@ -19,6 +20,17 @@ from openframe.protocol.base.errors import (
|
|
|
19
20
|
categorize,
|
|
20
21
|
raise_for_envelope,
|
|
21
22
|
)
|
|
23
|
+
from openframe.protocol.base.session import (
|
|
24
|
+
open_session,
|
|
25
|
+
run_opened_session,
|
|
26
|
+
run_session,
|
|
27
|
+
send_error_event,
|
|
28
|
+
)
|
|
29
|
+
from openframe.protocol.base.session_testing import (
|
|
30
|
+
SessionConformanceTests,
|
|
31
|
+
SessionDriver,
|
|
32
|
+
SessionResult,
|
|
33
|
+
)
|
|
22
34
|
from openframe.protocol.base.testing import (
|
|
23
35
|
UNEXPECTED_ERROR_SECRET,
|
|
24
36
|
DriverResult,
|
|
@@ -41,4 +53,11 @@ __all__ = [
|
|
|
41
53
|
"UNEXPECTED_ERROR_SECRET",
|
|
42
54
|
"RemoteClientDriver",
|
|
43
55
|
"RemoteClientConformanceTests",
|
|
56
|
+
"open_session",
|
|
57
|
+
"run_opened_session",
|
|
58
|
+
"run_session",
|
|
59
|
+
"send_error_event",
|
|
60
|
+
"SessionResult",
|
|
61
|
+
"SessionDriver",
|
|
62
|
+
"SessionConformanceTests",
|
|
44
63
|
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/base/session.py
|
|
3
|
+
=====================================
|
|
4
|
+
`open_session` + `run_opened_session` (and `run_session`, the convenience
|
|
5
|
+
wrapper combining both) — the one open/dispatch/error/close state machine
|
|
6
|
+
every `SessionHandler`-based transport's `server.py` reuses, instead of
|
|
7
|
+
hand-rolling it per transport. The session-lifecycle counterpart to
|
|
8
|
+
`create_app`/`create_server` (M1c) — same motivation (real, demonstrated
|
|
9
|
+
duplication avoided before it happens, not a hypothetical abstraction),
|
|
10
|
+
applied one layer lower.
|
|
11
|
+
|
|
12
|
+
**Why `open_session` is a separate function, not just the first half of
|
|
13
|
+
`run_session` inline** — found by actually tracing through Starlette's
|
|
14
|
+
`StreamingResponse`, not assumed: `StreamingResponse.stream_response`
|
|
15
|
+
sends the `http.response.start` ASGI message (status code + headers)
|
|
16
|
+
*before* it ever requests the body generator's first item. If
|
|
17
|
+
`on_open`'s accept/reject decision ran *inside* that generator, a reject
|
|
18
|
+
would arrive too late — the 200 status would already be on the wire,
|
|
19
|
+
with no way to turn it into the 4xx/5xx `install_error_handlers` would
|
|
20
|
+
otherwise produce. The same timing issue applies to a WebSocket's own
|
|
21
|
+
`accept()`/handshake. So a transport must call `open_session` *before*
|
|
22
|
+
constructing its `StreamingResponse`/completing its handshake, and only
|
|
23
|
+
proceed to `run_opened_session` (inside the generator / after the
|
|
24
|
+
handshake) once `open_session` has confirmed the handler accepted.
|
|
25
|
+
|
|
26
|
+
Two different failure points, two different wire treatments — found
|
|
27
|
+
while designing this, not assumed:
|
|
28
|
+
|
|
29
|
+
- **`on_open` rejecting** happens *before* anything is committed — no
|
|
30
|
+
response headers sent yet (SSE), no handshake accepted yet (WebSocket).
|
|
31
|
+
`run_session` therefore lets that exception **propagate out** to the
|
|
32
|
+
caller (after still firing `on_close`/`session.close()` for contract
|
|
33
|
+
compliance — see `SessionHandler.on_close`'s own docstring: it fires
|
|
34
|
+
"however the session ended", including an `on_open` failure) so the
|
|
35
|
+
transport's own `server.py` maps it through the exact same
|
|
36
|
+
`categorize`/`build_envelope` → status-code path `install_error_handlers`
|
|
37
|
+
already uses for a plain request/response failure — a reject should
|
|
38
|
+
look like any other mapped pre-stream error, not a different shape.
|
|
39
|
+
- **`on_event` raising mid-session** happens *after* the stream/handshake
|
|
40
|
+
already committed — there is no status code left to change. This is
|
|
41
|
+
sent **in-band**, via `session.send()`, as a plain dict shaped
|
|
42
|
+
`{"error": {"code", "message", "retryable", "correlation_id"}}` —
|
|
43
|
+
`build_envelope`'s own fields — regardless of the session's `TOut`,
|
|
44
|
+
since a transport's wire format (JSON, for both transports this repo
|
|
45
|
+
builds today) already serialises a plain dict alongside whatever `TOut`
|
|
46
|
+
normally produces.
|
|
47
|
+
|
|
48
|
+
`asyncio.CancelledError` (not `Exception` — it subclasses `BaseException`
|
|
49
|
+
and is deliberately not caught by the `except Exception` branch below) is
|
|
50
|
+
treated as `CloseReason.CLIENT_DISCONNECT`, then re-raised — standard
|
|
51
|
+
asyncio cancellation hygiene: a cancellation is reported, never swallowed.
|
|
52
|
+
A transport that needs to distinguish `SERVER_SHUTDOWN` from a genuine
|
|
53
|
+
client disconnect (both commonly surface as cancellation to the task
|
|
54
|
+
running `run_session`) does so by catching the re-raised
|
|
55
|
+
`CancelledError` in its own shutdown path and calling
|
|
56
|
+
`handler.on_close(session, CloseReason.SERVER_SHUTDOWN)` itself — outside
|
|
57
|
+
`run_session`, which only knows about one connection, never server-wide
|
|
58
|
+
shutdown.
|
|
59
|
+
|
|
60
|
+
Dependency order:
|
|
61
|
+
protocol/base/session → protocol/base/errors, openframe.core.inbound
|
|
62
|
+
"""
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import asyncio
|
|
66
|
+
import logging
|
|
67
|
+
from typing import AsyncIterator, TypeVar
|
|
68
|
+
|
|
69
|
+
from openframe.core.inbound import CloseReason, Session, SessionHandler
|
|
70
|
+
from openframe.protocol.base.errors import build_envelope
|
|
71
|
+
|
|
72
|
+
__all__ = ["open_session", "run_opened_session", "run_session", "send_error_event"]
|
|
73
|
+
|
|
74
|
+
_logger = logging.getLogger(__name__)
|
|
75
|
+
|
|
76
|
+
TIn = TypeVar("TIn")
|
|
77
|
+
TOut = TypeVar("TOut")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def open_session(handler: SessionHandler[TIn, TOut], session: Session[TOut]) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Call `on_open(session)` — the accept/reject half of a session's
|
|
83
|
+
lifecycle, meant to run *before* a transport commits anything to the
|
|
84
|
+
wire (before constructing a `StreamingResponse`, before completing a
|
|
85
|
+
WebSocket handshake).
|
|
86
|
+
|
|
87
|
+
If `on_open` raises (a reject, or a genuine failure), `on_close`/
|
|
88
|
+
`session.close()` still fire — `on_close`'s own contract says
|
|
89
|
+
"however the session ended", including an `on_open` failure — but the
|
|
90
|
+
exception then **propagates to the caller**, since nothing has been
|
|
91
|
+
committed to the wire yet. The caller (the transport's own
|
|
92
|
+
`server.py`) is expected to let this propagate into its normal
|
|
93
|
+
request/response error handling (`install_error_handlers`'s
|
|
94
|
+
`categorize`/`build_envelope` path), exactly as any other
|
|
95
|
+
request/response failure.
|
|
96
|
+
|
|
97
|
+
On success, returns normally — the session is open and ready for
|
|
98
|
+
`run_opened_session`.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
await handler.on_open(session)
|
|
102
|
+
except asyncio.CancelledError:
|
|
103
|
+
await handler.on_close(session, CloseReason.CLIENT_DISCONNECT)
|
|
104
|
+
await session.close(CloseReason.CLIENT_DISCONNECT)
|
|
105
|
+
raise
|
|
106
|
+
except Exception:
|
|
107
|
+
await handler.on_close(session, CloseReason.ERROR)
|
|
108
|
+
await session.close(CloseReason.ERROR)
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def run_opened_session(
|
|
113
|
+
handler: SessionHandler[TIn, TOut],
|
|
114
|
+
session: Session[TOut],
|
|
115
|
+
inbound_events: AsyncIterator[TIn],
|
|
116
|
+
) -> None:
|
|
117
|
+
"""
|
|
118
|
+
Run the dispatch/error/close half of a session's lifecycle, assuming
|
|
119
|
+
`open_session` has already succeeded for *session*.
|
|
120
|
+
|
|
121
|
+
Iterates `inbound_events`, calling `on_event(session, event)` for
|
|
122
|
+
each, in order. `inbound_events` simply never yields for a
|
|
123
|
+
one-directional transport (SSE) — the loop executes zero times, a
|
|
124
|
+
valid lifecycle, not a special case this function needs to detect.
|
|
125
|
+
|
|
126
|
+
Any exception from `on_event` (other than `CancelledError`) is sent
|
|
127
|
+
**in-band** to the client via `session.send()` as a mapped error
|
|
128
|
+
envelope — the stream already committed by this point (that's the
|
|
129
|
+
whole reason `open_session` ran first), so there is no status code
|
|
130
|
+
left to change — and the session closes with `CloseReason.ERROR`.
|
|
131
|
+
This exception does NOT propagate — the session itself absorbs it and
|
|
132
|
+
closes gracefully.
|
|
133
|
+
|
|
134
|
+
`asyncio.CancelledError` is reported as `CloseReason.CLIENT_DISCONNECT`
|
|
135
|
+
and always re-raised — standard cancellation hygiene, never
|
|
136
|
+
swallowed. A transport that needs to distinguish `SERVER_SHUTDOWN`
|
|
137
|
+
from a genuine client disconnect (both commonly surface as
|
|
138
|
+
cancellation to the task running this function) does so by catching
|
|
139
|
+
the re-raised `CancelledError` in its own shutdown path and calling
|
|
140
|
+
`handler.on_close(session, CloseReason.SERVER_SHUTDOWN)` itself —
|
|
141
|
+
outside this function, which only knows about one connection, never
|
|
142
|
+
server-wide shutdown.
|
|
143
|
+
|
|
144
|
+
`on_close(session, reason)` and `session.close(reason)` are always
|
|
145
|
+
called exactly once, in a `finally` block, however the loop ended.
|
|
146
|
+
"""
|
|
147
|
+
reason = CloseReason.COMPLETED
|
|
148
|
+
try:
|
|
149
|
+
async for event in inbound_events:
|
|
150
|
+
await handler.on_event(session, event)
|
|
151
|
+
except asyncio.CancelledError:
|
|
152
|
+
reason = CloseReason.CLIENT_DISCONNECT
|
|
153
|
+
raise
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
reason = CloseReason.ERROR
|
|
156
|
+
await send_error_event(session, exc)
|
|
157
|
+
finally:
|
|
158
|
+
await handler.on_close(session, reason)
|
|
159
|
+
await session.close(reason)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def run_session(
|
|
163
|
+
handler: SessionHandler[TIn, TOut],
|
|
164
|
+
session: Session[TOut],
|
|
165
|
+
inbound_events: AsyncIterator[TIn],
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Convenience wrapper: `open_session` followed by `run_opened_session`.
|
|
169
|
+
|
|
170
|
+
Correct for a transport that has no "commit point" distinction to
|
|
171
|
+
worry about (e.g. an in-process caller, as every test in this
|
|
172
|
+
package's own `tests/test_session.py` is), or where the handshake has
|
|
173
|
+
already been fully committed by the time this is called. A transport
|
|
174
|
+
like SSE or WebSocket, which must decide accept/reject *before*
|
|
175
|
+
committing to the wire, calls `open_session` and `run_opened_session`
|
|
176
|
+
separately instead — see this module's own docstring for why.
|
|
177
|
+
"""
|
|
178
|
+
await open_session(handler, session)
|
|
179
|
+
await run_opened_session(handler, session, inbound_events)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def send_error_event(session: Session, exc: BaseException) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Map *exc* through `build_envelope` and send it in-band as a plain
|
|
185
|
+
dict. Never raises — a session already failing is not made worse by
|
|
186
|
+
a failure to report the failure; logged instead.
|
|
187
|
+
|
|
188
|
+
Public (not `run_opened_session`-only) since a push-only transport
|
|
189
|
+
(SSE) whose handler drives its own background production — rather
|
|
190
|
+
than responding to dispatched inbound events — has no `on_event` call
|
|
191
|
+
for `run_opened_session` to wrap, and so maps its own background
|
|
192
|
+
task's failures through this directly instead. See
|
|
193
|
+
`openframe.protocol.http.sse`'s own module docstring for why it
|
|
194
|
+
doesn't route through `run_opened_session` at all.
|
|
195
|
+
"""
|
|
196
|
+
envelope = build_envelope(exc, correlation_id=session.context.correlation_id)
|
|
197
|
+
try:
|
|
198
|
+
await session.send(
|
|
199
|
+
{
|
|
200
|
+
"error": {
|
|
201
|
+
"code": envelope.code,
|
|
202
|
+
"message": envelope.message,
|
|
203
|
+
"retryable": envelope.retryable,
|
|
204
|
+
"correlation_id": envelope.correlation_id,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
except Exception:
|
|
209
|
+
_logger.warning(
|
|
210
|
+
"failed to send error event to session %s", session.context.correlation_id,
|
|
211
|
+
exc_info=True,
|
|
212
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/base/session_testing.py
|
|
3
|
+
=============================================
|
|
4
|
+
`SessionDriver` + `SessionConformanceTests` — the one conformance suite
|
|
5
|
+
every inbound `SessionHandler`-based `openframe-protocol-*` binding
|
|
6
|
+
proves itself against. The session-lifecycle counterpart to
|
|
7
|
+
`testing.py`'s `TransportDriver`/`TransportConformanceTests`, in its own
|
|
8
|
+
module (not added to `testing.py`) since the two suites share no
|
|
9
|
+
scenarios — a session's `SessionDriver` methods set up an open
|
|
10
|
+
connection and drive its lifecycle, not one call-and-respond exchange.
|
|
11
|
+
|
|
12
|
+
Incubating here, not in `openframe-core`, mirroring `testing.py`'s own
|
|
13
|
+
precedent: `openframe.core.testing.contracts.inbound`'s new
|
|
14
|
+
`SessionHandlerContractTests` is shape-only (does the handler have the
|
|
15
|
+
right methods) — it knows nothing about transports, in-band error
|
|
16
|
+
mapping, or session-lifetime correlation ids. This suite is promoted to
|
|
17
|
+
`openframe.core.testing` only after two session transports (SSE,
|
|
18
|
+
WebSocket) both pass it for real — the same rule-of-three discipline
|
|
19
|
+
`TransportConformanceTests`'s own docstring already applies.
|
|
20
|
+
|
|
21
|
+
Covers exactly what `openframe-protocol-design.md` section 7 specifies
|
|
22
|
+
for sessions: open/reject, ordered delivery, clean close, abrupt
|
|
23
|
+
disconnect, and correlation id stability across the session's lifetime.
|
|
24
|
+
Back-pressure is deliberately not covered here — it has no
|
|
25
|
+
transport-neutral normalised shape the way open/close/delivery/
|
|
26
|
+
correlation-id do; a transport-specific back-pressure test belongs in
|
|
27
|
+
that transport's own test suite, not this generic one.
|
|
28
|
+
|
|
29
|
+
No top-level pytest import — importable without pytest installed,
|
|
30
|
+
matching `testing.py`'s own convention.
|
|
31
|
+
|
|
32
|
+
Dependency order:
|
|
33
|
+
protocol/base/session_testing → protocol/base/errors, openframe.core.inbound
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Any, Protocol, runtime_checkable
|
|
39
|
+
|
|
40
|
+
from openframe.core.inbound import CloseReason
|
|
41
|
+
from openframe.protocol.base.errors import ErrorCategory
|
|
42
|
+
|
|
43
|
+
__all__ = ["SessionResult", "SessionDriver", "SessionConformanceTests"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SessionResult:
|
|
48
|
+
"""
|
|
49
|
+
One session's outcome, normalised to a shape every session transport
|
|
50
|
+
can produce.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
opened: Whether `on_open` accepted the session (returned
|
|
54
|
+
normally) rather than rejecting it (raised).
|
|
55
|
+
close_reason: The `CloseReason` the session ended with.
|
|
56
|
+
received_events: Events the client actually received, via
|
|
57
|
+
`session.send()`, **in the order they arrived**
|
|
58
|
+
— the thing `test_ordered_delivery` checks.
|
|
59
|
+
correlation_ids: The correlation id associated with each received
|
|
60
|
+
event — same length as `received_events`. All
|
|
61
|
+
entries must be identical for a well-behaved
|
|
62
|
+
session: one `RequestContext` for the whole
|
|
63
|
+
session, not one per event.
|
|
64
|
+
error_category: `None` unless the session ended via `on_open`
|
|
65
|
+
rejecting or an `on_event` exception — the
|
|
66
|
+
`ErrorCategory` the error mapped to.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
opened: bool
|
|
70
|
+
close_reason: CloseReason | None
|
|
71
|
+
received_events: tuple[Any, ...]
|
|
72
|
+
correlation_ids: tuple[str, ...]
|
|
73
|
+
error_category: ErrorCategory | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@runtime_checkable
|
|
77
|
+
class SessionDriver(Protocol):
|
|
78
|
+
"""
|
|
79
|
+
What a session transport's own test module implements to run this
|
|
80
|
+
suite. Each method exercises one bound `SessionHandler` scenario end
|
|
81
|
+
to end (through the real session binding, not by calling handler
|
|
82
|
+
methods directly) and returns the outcome as a `SessionResult`.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
async def open_accepts(self) -> SessionResult:
|
|
86
|
+
"""A session whose handler accepts, sends nothing, and closes cleanly."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
async def open_rejects(self) -> SessionResult:
|
|
90
|
+
"""A session whose handler's `on_open` raises — must never open or deliver events."""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
async def ordered_delivery(self) -> SessionResult:
|
|
94
|
+
"""
|
|
95
|
+
A session whose handler sends exactly three events, in order:
|
|
96
|
+
`"event-1"`, `"event-2"`, `"event-3"`. Must arrive in that exact
|
|
97
|
+
order — proves the binding doesn't reorder or buffer out of
|
|
98
|
+
sequence.
|
|
99
|
+
"""
|
|
100
|
+
...
|
|
101
|
+
|
|
102
|
+
async def abrupt_disconnect(self) -> SessionResult:
|
|
103
|
+
"""
|
|
104
|
+
A session where the client disconnects mid-session (not a clean,
|
|
105
|
+
handler-initiated close). Must close with
|
|
106
|
+
`CloseReason.CLIENT_DISCONNECT`.
|
|
107
|
+
"""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
async def correlation_id_stable(self) -> SessionResult:
|
|
111
|
+
"""
|
|
112
|
+
A session whose handler sends at least two events. The
|
|
113
|
+
correlation id must be identical across all of them.
|
|
114
|
+
"""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SessionConformanceTests:
|
|
119
|
+
"""
|
|
120
|
+
Reusable pytest base class. Subclasses provide one fixture:
|
|
121
|
+
|
|
122
|
+
``driver``
|
|
123
|
+
A fresh instance satisfying `SessionDriver`, wired to a real
|
|
124
|
+
bound `SessionHandler` over the session transport under test.
|
|
125
|
+
|
|
126
|
+
.. stability: experimental
|
|
127
|
+
New alongside `openframe-protocol-base` itself. Promotion to
|
|
128
|
+
`openframe.core.testing` is gated on two session transports (SSE,
|
|
129
|
+
WebSocket) passing this suite for real.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
async def test_open_accepts(self, driver: SessionDriver) -> None:
|
|
133
|
+
result = await driver.open_accepts()
|
|
134
|
+
assert result.opened
|
|
135
|
+
assert result.close_reason is CloseReason.COMPLETED
|
|
136
|
+
|
|
137
|
+
async def test_open_rejects(self, driver: SessionDriver) -> None:
|
|
138
|
+
result = await driver.open_rejects()
|
|
139
|
+
assert not result.opened
|
|
140
|
+
assert result.close_reason is CloseReason.ERROR
|
|
141
|
+
assert result.error_category is not None
|
|
142
|
+
assert not result.received_events, "a rejected session must never deliver events"
|
|
143
|
+
|
|
144
|
+
async def test_ordered_delivery(self, driver: SessionDriver) -> None:
|
|
145
|
+
result = await driver.ordered_delivery()
|
|
146
|
+
assert result.received_events == ("event-1", "event-2", "event-3")
|
|
147
|
+
|
|
148
|
+
async def test_abrupt_disconnect_closes_with_client_disconnect(self, driver: SessionDriver) -> None:
|
|
149
|
+
result = await driver.abrupt_disconnect()
|
|
150
|
+
assert result.close_reason is CloseReason.CLIENT_DISCONNECT
|
|
151
|
+
|
|
152
|
+
async def test_correlation_id_stable_across_session(self, driver: SessionDriver) -> None:
|
|
153
|
+
result = await driver.correlation_id_stable()
|
|
154
|
+
assert len(result.correlation_ids) >= 2
|
|
155
|
+
assert len(set(result.correlation_ids)) == 1, (
|
|
156
|
+
"correlation_id must be identical across every event in one session — "
|
|
157
|
+
"one RequestContext for the session's whole lifetime, not one per event"
|
|
158
|
+
)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "openframe-protocol-base"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "OpenFrame Microservice Suite - shared transport-neutral protocol mechanics (error mapping, RequestContext construction, inbound conformance suite)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for openframe.protocol.base.session's run_session — the
|
|
3
|
+
open/dispatch/error/close state machine every SessionHandler-based
|
|
4
|
+
transport reuses.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from openframe.core.exceptions import AdapterConnectionError
|
|
13
|
+
from openframe.core.inbound import CloseReason, RequestContext
|
|
14
|
+
from openframe.core.testing import InMemorySession
|
|
15
|
+
from openframe.protocol.base.session import open_session, run_opened_session, run_session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _empty_events():
|
|
19
|
+
return
|
|
20
|
+
yield # pragma: no cover — makes this a generator that never yields
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _events(*items):
|
|
24
|
+
for item in items:
|
|
25
|
+
yield item
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Handler:
|
|
29
|
+
"""Configurable SessionHandler for testing run_session's state machine."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, reject_with: BaseException | None = None, fail_on_event: BaseException | None = None):
|
|
32
|
+
self._reject_with = reject_with
|
|
33
|
+
self._fail_on_event = fail_on_event
|
|
34
|
+
self.opened = False
|
|
35
|
+
self.events: list = []
|
|
36
|
+
self.closed_with: CloseReason | None = None
|
|
37
|
+
self.close_calls = 0
|
|
38
|
+
|
|
39
|
+
async def on_open(self, session) -> None:
|
|
40
|
+
if self._reject_with is not None:
|
|
41
|
+
raise self._reject_with
|
|
42
|
+
self.opened = True
|
|
43
|
+
|
|
44
|
+
async def on_event(self, session, event) -> None:
|
|
45
|
+
if self._fail_on_event is not None:
|
|
46
|
+
raise self._fail_on_event
|
|
47
|
+
self.events.append(event)
|
|
48
|
+
await session.send(event)
|
|
49
|
+
|
|
50
|
+
async def on_close(self, session, reason: CloseReason) -> None:
|
|
51
|
+
self.closed_with = reason
|
|
52
|
+
self.close_calls += 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _session() -> InMemorySession:
|
|
56
|
+
return InMemorySession(context=RequestContext(correlation_id="corr-1"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Happy path
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def test_run_session_accepts_and_closes_cleanly() -> None:
|
|
65
|
+
handler = _Handler()
|
|
66
|
+
session = _session()
|
|
67
|
+
await run_session(handler, session, _empty_events())
|
|
68
|
+
assert handler.opened
|
|
69
|
+
assert session.closed
|
|
70
|
+
assert session.close_reason is CloseReason.COMPLETED
|
|
71
|
+
assert handler.closed_with is CloseReason.COMPLETED
|
|
72
|
+
assert handler.close_calls == 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_run_session_delivers_events_in_order() -> None:
|
|
76
|
+
handler = _Handler()
|
|
77
|
+
session = _session()
|
|
78
|
+
await run_session(handler, session, _events("a", "b", "c"))
|
|
79
|
+
assert handler.events == ["a", "b", "c"]
|
|
80
|
+
assert session.sent == ["a", "b", "c"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# on_open rejecting — propagates, no in-band send, on_close still fires
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_run_session_reject_propagates_the_exception() -> None:
|
|
89
|
+
handler = _Handler(reject_with=PermissionError("nope"))
|
|
90
|
+
session = _session()
|
|
91
|
+
with pytest.raises(PermissionError):
|
|
92
|
+
await run_session(handler, session, _empty_events())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def test_run_session_reject_still_calls_on_close() -> None:
|
|
96
|
+
handler = _Handler(reject_with=PermissionError("nope"))
|
|
97
|
+
session = _session()
|
|
98
|
+
with pytest.raises(PermissionError):
|
|
99
|
+
await run_session(handler, session, _empty_events())
|
|
100
|
+
assert handler.closed_with is CloseReason.ERROR
|
|
101
|
+
assert handler.close_calls == 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_run_session_reject_closes_the_session() -> None:
|
|
105
|
+
handler = _Handler(reject_with=PermissionError("nope"))
|
|
106
|
+
session = _session()
|
|
107
|
+
with pytest.raises(PermissionError):
|
|
108
|
+
await run_session(handler, session, _empty_events())
|
|
109
|
+
assert session.closed
|
|
110
|
+
assert session.close_reason is CloseReason.ERROR
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_run_session_reject_sends_nothing_in_band() -> None:
|
|
114
|
+
"""A reject never committed to the wire — nothing should be sent."""
|
|
115
|
+
handler = _Handler(reject_with=PermissionError("nope"))
|
|
116
|
+
session = _session()
|
|
117
|
+
with pytest.raises(PermissionError):
|
|
118
|
+
await run_session(handler, session, _empty_events())
|
|
119
|
+
assert session.sent == []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# on_event failing mid-session — in-band, does NOT propagate
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_run_session_on_event_failure_does_not_propagate() -> None:
|
|
128
|
+
handler = _Handler(fail_on_event=AdapterConnectionError("down", adapter="x", operation="get"))
|
|
129
|
+
session = _session()
|
|
130
|
+
await run_session(handler, session, _events("trigger")) # must not raise
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def test_run_session_on_event_failure_sends_error_envelope_in_band() -> None:
|
|
134
|
+
handler = _Handler(fail_on_event=AdapterConnectionError("down", adapter="x", operation="get"))
|
|
135
|
+
session = _session()
|
|
136
|
+
await run_session(handler, session, _events("trigger"))
|
|
137
|
+
assert len(session.sent) == 1
|
|
138
|
+
envelope = session.sent[0]["error"]
|
|
139
|
+
assert envelope["code"] == "adapter.connection"
|
|
140
|
+
assert envelope["correlation_id"] == "corr-1"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_run_session_on_event_failure_closes_with_error() -> None:
|
|
144
|
+
handler = _Handler(fail_on_event=RuntimeError("boom"))
|
|
145
|
+
session = _session()
|
|
146
|
+
await run_session(handler, session, _events("trigger"))
|
|
147
|
+
assert session.close_reason is CloseReason.ERROR
|
|
148
|
+
assert handler.closed_with is CloseReason.ERROR
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_run_session_on_event_failure_unexpected_error_does_not_leak_message() -> None:
|
|
152
|
+
handler = _Handler(fail_on_event=RuntimeError("driver-internal-secret"))
|
|
153
|
+
session = _session()
|
|
154
|
+
await run_session(handler, session, _events("trigger"))
|
|
155
|
+
envelope = session.sent[0]["error"]
|
|
156
|
+
assert "driver-internal-secret" not in envelope["message"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Cancellation — reported as CLIENT_DISCONNECT, never swallowed
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_run_session_cancellation_during_events_is_client_disconnect_and_reraises() -> None:
|
|
165
|
+
class _CancellingHandler(_Handler):
|
|
166
|
+
async def on_event(self, session, event) -> None:
|
|
167
|
+
raise asyncio.CancelledError()
|
|
168
|
+
|
|
169
|
+
handler = _CancellingHandler()
|
|
170
|
+
session = _session()
|
|
171
|
+
with pytest.raises(asyncio.CancelledError):
|
|
172
|
+
await run_session(handler, session, _events("trigger"))
|
|
173
|
+
assert handler.closed_with is CloseReason.CLIENT_DISCONNECT
|
|
174
|
+
assert session.close_reason is CloseReason.CLIENT_DISCONNECT
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def test_run_session_cancellation_during_open_is_client_disconnect_and_reraises() -> None:
|
|
178
|
+
handler = _Handler(reject_with=asyncio.CancelledError())
|
|
179
|
+
session = _session()
|
|
180
|
+
with pytest.raises(asyncio.CancelledError):
|
|
181
|
+
await run_session(handler, session, _empty_events())
|
|
182
|
+
assert handler.closed_with is CloseReason.CLIENT_DISCONNECT
|
|
183
|
+
assert session.close_reason is CloseReason.CLIENT_DISCONNECT
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# open_session / run_opened_session called separately — the shape a
|
|
188
|
+
# transport that must decide accept/reject before committing to the wire
|
|
189
|
+
# (SSE, WebSocket) actually uses, rather than the combined run_session().
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def test_open_session_succeeds_without_running_event_loop() -> None:
|
|
194
|
+
handler = _Handler()
|
|
195
|
+
session = _session()
|
|
196
|
+
await open_session(handler, session)
|
|
197
|
+
assert handler.opened
|
|
198
|
+
assert not session.closed, "open_session alone must not close the session"
|
|
199
|
+
assert handler.close_calls == 0, "on_close belongs to run_opened_session, not open_session"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def test_open_session_reject_propagates_before_any_commit() -> None:
|
|
203
|
+
handler = _Handler(reject_with=PermissionError("nope"))
|
|
204
|
+
session = _session()
|
|
205
|
+
with pytest.raises(PermissionError):
|
|
206
|
+
await open_session(handler, session)
|
|
207
|
+
assert session.closed
|
|
208
|
+
assert session.close_reason is CloseReason.ERROR
|
|
209
|
+
assert handler.close_calls == 1
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def test_run_opened_session_after_successful_open_session() -> None:
|
|
213
|
+
handler = _Handler()
|
|
214
|
+
session = _session()
|
|
215
|
+
await open_session(handler, session)
|
|
216
|
+
await run_opened_session(handler, session, _events("a", "b"))
|
|
217
|
+
assert session.sent == ["a", "b"]
|
|
218
|
+
assert session.close_reason is CloseReason.COMPLETED
|
|
219
|
+
assert handler.close_calls == 1
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proves SessionConformanceTests itself is correct, against a real
|
|
3
|
+
in-process driver that runs real SessionHandler instances through the
|
|
4
|
+
real run_session()/InMemorySession — not just hand-built SessionResult
|
|
5
|
+
objects — so this also doubles as an integration test of run_session
|
|
6
|
+
itself, before any real session transport (SSE) exists to run the suite
|
|
7
|
+
against.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from openframe.core.exceptions import AdapterTimeoutError
|
|
16
|
+
from openframe.core.inbound import CloseReason, RequestContext
|
|
17
|
+
from openframe.core.testing import InMemorySession
|
|
18
|
+
from openframe.protocol.base.errors import categorize
|
|
19
|
+
from openframe.protocol.base.session import run_session
|
|
20
|
+
from openframe.protocol.base.session_testing import (
|
|
21
|
+
SessionConformanceTests,
|
|
22
|
+
SessionDriver,
|
|
23
|
+
SessionResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _AcceptingHandler:
|
|
28
|
+
async def on_open(self, session) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
async def on_event(self, session, event) -> None:
|
|
32
|
+
await session.send(event)
|
|
33
|
+
|
|
34
|
+
async def on_close(self, session, reason) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _RejectingHandler:
|
|
39
|
+
async def on_open(self, session) -> None:
|
|
40
|
+
raise AdapterTimeoutError("too slow", adapter="x", operation="connect")
|
|
41
|
+
|
|
42
|
+
async def on_event(self, session, event) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
async def on_close(self, session, reason) -> None:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _DisconnectingHandler:
|
|
50
|
+
async def on_open(self, session) -> None:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
async def on_event(self, session, event) -> None:
|
|
54
|
+
raise asyncio.CancelledError()
|
|
55
|
+
|
|
56
|
+
async def on_close(self, session, reason) -> None:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _empty_events():
|
|
61
|
+
return
|
|
62
|
+
yield # pragma: no cover
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _events(*items):
|
|
66
|
+
for item in items:
|
|
67
|
+
yield item
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _new_session() -> InMemorySession:
|
|
71
|
+
return InMemorySession(context=RequestContext(correlation_id="corr-driver-1"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _InProcessSessionDriver:
|
|
75
|
+
"""Runs real handlers through the real run_session()/InMemorySession."""
|
|
76
|
+
|
|
77
|
+
async def open_accepts(self) -> SessionResult:
|
|
78
|
+
session = _new_session()
|
|
79
|
+
await run_session(_AcceptingHandler(), session, _empty_events())
|
|
80
|
+
return SessionResult(
|
|
81
|
+
opened=True,
|
|
82
|
+
close_reason=session.close_reason,
|
|
83
|
+
received_events=tuple(session.sent),
|
|
84
|
+
correlation_ids=(session.context.correlation_id,) if session.sent else (),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def open_rejects(self) -> SessionResult:
|
|
88
|
+
session = _new_session()
|
|
89
|
+
exc = AdapterTimeoutError("too slow", adapter="x", operation="connect")
|
|
90
|
+
try:
|
|
91
|
+
await run_session(_RejectingHandler(), session, _empty_events())
|
|
92
|
+
except AdapterTimeoutError:
|
|
93
|
+
pass
|
|
94
|
+
return SessionResult(
|
|
95
|
+
opened=False,
|
|
96
|
+
close_reason=session.close_reason,
|
|
97
|
+
received_events=tuple(session.sent),
|
|
98
|
+
correlation_ids=(),
|
|
99
|
+
error_category=categorize(exc),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def ordered_delivery(self) -> SessionResult:
|
|
103
|
+
session = _new_session()
|
|
104
|
+
await run_session(_AcceptingHandler(), session, _events("event-1", "event-2", "event-3"))
|
|
105
|
+
return SessionResult(
|
|
106
|
+
opened=True,
|
|
107
|
+
close_reason=session.close_reason,
|
|
108
|
+
received_events=tuple(session.sent),
|
|
109
|
+
correlation_ids=(session.context.correlation_id,) * len(session.sent),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def abrupt_disconnect(self) -> SessionResult:
|
|
113
|
+
session = _new_session()
|
|
114
|
+
try:
|
|
115
|
+
await run_session(_DisconnectingHandler(), session, _events("trigger"))
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
pass
|
|
118
|
+
return SessionResult(
|
|
119
|
+
opened=True,
|
|
120
|
+
close_reason=session.close_reason,
|
|
121
|
+
received_events=tuple(session.sent),
|
|
122
|
+
correlation_ids=(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def correlation_id_stable(self) -> SessionResult:
|
|
126
|
+
session = _new_session()
|
|
127
|
+
await run_session(_AcceptingHandler(), session, _events("x", "y"))
|
|
128
|
+
return SessionResult(
|
|
129
|
+
opened=True,
|
|
130
|
+
close_reason=session.close_reason,
|
|
131
|
+
received_events=tuple(session.sent),
|
|
132
|
+
correlation_ids=(session.context.correlation_id,) * len(session.sent),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestInProcessSessionConformance(SessionConformanceTests):
|
|
137
|
+
@pytest.fixture
|
|
138
|
+
def driver(self) -> _InProcessSessionDriver:
|
|
139
|
+
return _InProcessSessionDriver()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_driver_satisfies_session_driver_protocol() -> None:
|
|
143
|
+
assert isinstance(_InProcessSessionDriver(), SessionDriver)
|
|
File without changes
|
|
File without changes
|
{openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/context.py
RENAMED
|
File without changes
|
{openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/errors.py
RENAMED
|
File without changes
|
{openframe_protocol_base-0.3.0 → openframe_protocol_base-0.4.0}/openframe/protocol/base/testing.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|