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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openframe-protocol-base
3
- Version: 0.3.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/
@@ -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`), and the inbound conformance suite
7
- (`testing.py`). No transport dependencies — a binding package depends on
8
- this one, never the reverse.
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.3.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)