centive-sdk 2.2.0.dev6__tar.gz → 2.2.0.dev8__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.
Files changed (48) hide show
  1. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/CHANGELOG.md +9 -0
  2. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/PKG-INFO +1 -1
  3. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/pyproject.toml +1 -1
  4. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/config.py +6 -0
  5. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/websocket_server.py +102 -4
  6. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_websocket_server.py +142 -37
  7. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/.gitignore +0 -0
  8. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/LICENSE +0 -0
  9. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/README.md +0 -0
  10. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/blackbox/README.md +0 -0
  11. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/blackbox/customer_app.py +0 -0
  12. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/blackbox/harness.py +0 -0
  13. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/blackbox/mock_centive_api.py +0 -0
  14. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/blackbox/test_blackbox.py +0 -0
  15. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/blackbox/test_blackbox_telemetry.py +0 -0
  16. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/examples/async_example.py +0 -0
  17. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/examples/fastapi_integration.py +0 -0
  18. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/examples/sync_example.py +0 -0
  19. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/examples/websocket_example.py +0 -0
  20. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/__init__.py +0 -0
  21. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/_http.py +0 -0
  22. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/_logging.py +0 -0
  23. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/_retry.py +0 -0
  24. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/async_client.py +0 -0
  25. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/client.py +0 -0
  26. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/exceptions.py +0 -0
  27. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/models/__init__.py +0 -0
  28. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/models/requests.py +0 -0
  29. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/models/responses.py +0 -0
  30. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/py.typed +0 -0
  31. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/__init__.py +0 -0
  32. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/async_sessions.py +0 -0
  33. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/async_telemetry.py +0 -0
  34. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/message_accumulator.py +0 -0
  35. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/sessions.py +0 -0
  36. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/telemetry_batcher.py +0 -0
  37. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/__init__.py +0 -0
  38. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/conftest.py +0 -0
  39. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_client_async.py +0 -0
  40. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_client_sync.py +0 -0
  41. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_config.py +0 -0
  42. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_error_mapping.py +0 -0
  43. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_logging_redaction.py +0 -0
  44. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_message_accumulator.py +0 -0
  45. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_retry_logic.py +0 -0
  46. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_telemetry_batcher.py +0 -0
  47. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_websocket_security.py +0 -0
  48. {centive_sdk-2.2.0.dev6 → centive_sdk-2.2.0.dev8}/tests/test_websocket_telemetry.py +0 -0
@@ -40,6 +40,15 @@ Centive batcher and the host callback).
40
40
 
41
41
  ### Behavior
42
42
 
43
+ - **One trigger-session call per connection start.** The connect-time
44
+ automatic trigger now waits `session_trigger_grace_seconds` (default 0.5)
45
+ and stands down when the browser SDK has asked for a session itself in the
46
+ meantime, which it does the moment its socket opens, with the page it has
47
+ open. Before, both reached Centive within milliseconds: two Anam tokens per
48
+ connection and an opening prompt built from the page-less request. A request
49
+ that lands while another trigger for the same connection is in flight waits
50
+ for it and receives the same frame. Set the grace to 0 for the old behaviour.
51
+
43
52
  - The telemetry queue has its own circuit breaker, so a failing events
44
53
  endpoint cannot stop transcript saves (and vice versa).
45
54
  - The queue is bounded (`telemetry_max_queue_events`, default 5000); when full
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: centive-sdk
3
- Version: 2.2.0.dev6
3
+ Version: 2.2.0.dev8
4
4
  Summary: Python SDK for Centive backend integration
5
5
  Project-URL: Homepage, https://github.com/TheAgenticAI/centive-backend-sdk
6
6
  Project-URL: Repository, https://github.com/TheAgenticAI/centive-backend-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "centive-sdk"
7
- version = "2.2.0.dev6"
7
+ version = "2.2.0.dev8"
8
8
  description = "Python SDK for Centive backend integration"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -83,6 +83,12 @@ class ClientConfig(BaseModel):
83
83
  max_session_id_chars: int = 200
84
84
  # Concurrent websocket connections accepted per user.
85
85
  max_connections_per_user: int = 5
86
+ # How long the connect-time session trigger waits for the browser SDK to
87
+ # ask for a session itself before firing. Browser SDKs since 1.0 ask the
88
+ # moment the socket opens, and their request is the one that carries the
89
+ # page the user has open; firing first meant two Centive calls and two Anam
90
+ # tokens per connection. 0 restores the old immediate trigger.
91
+ session_trigger_grace_seconds: float = 0.5
86
92
 
87
93
  # Page-telemetry frames (type="telemetry") from the FE SDK. These mirror the
88
94
  # aria-sdk producer's own caps (MAX_EVENTS_PER_FRAME, MAX_FRAME_BYTES,
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import inspect
2
3
  import json
3
4
  import secrets
@@ -82,6 +83,14 @@ class WebSocketServer:
82
83
  # current_page when the FE's user_trigger frame carries no page of its
83
84
  # own (older browser SDKs). Bounded by the number of live connections.
84
85
  self._connection_last_page: Dict[WebSocketServerProtocol, dict] = {}
86
+ # One Centive trigger-session call per connection start, not two: the
87
+ # connect-time automatic trigger is held back for a grace period and
88
+ # stands down when the client asked itself (_auto_trigger_after_grace);
89
+ # a request that lands while another trigger for the same connection is
90
+ # in flight waits for it and receives the same frame.
91
+ self._connection_trigger_tasks: Dict[WebSocketServerProtocol, asyncio.Task] = {}
92
+ self._connection_triggered: set = set()
93
+ self._connection_trigger_inflight: Dict[WebSocketServerProtocol, asyncio.Future] = {}
85
94
  # Every session key touched by a connection, so a disconnect saves all of
86
95
  # them rather than only the most recent one.
87
96
  self._connection_sessions: Dict[WebSocketServerProtocol, Set[str]] = {}
@@ -569,7 +578,9 @@ class WebSocketServer:
569
578
  {"client": client_info, "user_id": user_id},
570
579
  )
571
580
 
572
- await self._trigger_and_emit(websocket, user_id, None, client_info)
581
+ self._connection_trigger_tasks[websocket] = asyncio.create_task(
582
+ self._auto_trigger_after_grace(websocket, user_id, client_info)
583
+ )
573
584
 
574
585
  try:
575
586
  async for message in websocket:
@@ -599,6 +610,11 @@ class WebSocketServer:
599
610
  self._clients.discard(websocket)
600
611
  self._connection_users.pop(websocket, None)
601
612
  self._connection_last_page.pop(websocket, None)
613
+ self._connection_triggered.discard(websocket)
614
+ self._connection_trigger_inflight.pop(websocket, None)
615
+ pending_trigger = self._connection_trigger_tasks.pop(websocket, None)
616
+ if pending_trigger is not None:
617
+ pending_trigger.cancel()
602
618
  self._telemetry_rate.forget(websocket)
603
619
  session_keys = self._connection_sessions.pop(websocket, set())
604
620
 
@@ -1408,10 +1424,47 @@ class WebSocketServer:
1408
1424
  # batcher. The frame's own page wins; the last page_view seen on this
1409
1425
  # connection stands in for browser SDKs that predate the field.
1410
1426
  current_page = self._coerce_page(data.get("page")) or self._connection_last_page.get(websocket)
1427
+ # The client asked itself, so the connect-time automatic trigger, if it
1428
+ # is still waiting out its grace period, has nothing left to do.
1429
+ self._connection_triggered.add(websocket)
1411
1430
  await self._trigger_and_emit(
1412
1431
  websocket, user_id, user_trigger, client_info, current_page=current_page
1413
1432
  )
1414
1433
 
1434
+ async def _auto_trigger_after_grace(
1435
+ self, websocket: WebSocketServerProtocol, user_id: str, client_info: str
1436
+ ) -> None:
1437
+ """The connect-time session trigger, held back briefly.
1438
+
1439
+ Browser SDKs since 1.0 ask for a token themselves the moment the socket
1440
+ opens, and that request is the one that carries the page the user has
1441
+ open. Firing the automatic trigger first (and before the message loop
1442
+ even started reading) meant two Centive calls and two Anam tokens per
1443
+ connection, with the opening prompt built from the page-less one. Now
1444
+ the automatic trigger waits config.session_trigger_grace_seconds and
1445
+ stands down when the client has asked in the meantime. A client that
1446
+ never asks (older SDKs, custom frontends) still gets its session, half
1447
+ a second later.
1448
+ """
1449
+ try:
1450
+ grace = max(0.0, float(self._config.session_trigger_grace_seconds or 0))
1451
+ if grace:
1452
+ await asyncio.sleep(grace)
1453
+ if websocket in self._connection_triggered or not self._is_live(websocket):
1454
+ safe_log(
1455
+ self._logger,
1456
+ "info",
1457
+ "automatic session trigger stood down",
1458
+ {"client": client_info, "user_id": user_id,
1459
+ "reason": "client asked" if websocket in self._connection_triggered else "connection gone"},
1460
+ )
1461
+ return
1462
+ await self._trigger_and_emit(websocket, user_id, None, client_info)
1463
+ except asyncio.CancelledError:
1464
+ return
1465
+ finally:
1466
+ self._connection_trigger_tasks.pop(websocket, None)
1467
+
1415
1468
  def _remember_page(self, websocket: WebSocketServerProtocol, events: Any) -> None:
1416
1469
  """Keeps the newest page_view's page for this connection (see _handle_user_trigger)."""
1417
1470
  if not isinstance(events, list):
@@ -1444,7 +1497,50 @@ class WebSocketServer:
1444
1497
  client_info: str,
1445
1498
  current_page: Optional[dict] = None,
1446
1499
  ) -> None:
1447
- """calls trigger_session API and emits response to FE."""
1500
+ """calls trigger_session API and emits response to FE.
1501
+
1502
+ Two triggers for the same connection at once (the automatic one mid
1503
+ call when the client's own request lands) share one Centive call: the
1504
+ second waits for the first and re-emits its frame.
1505
+ """
1506
+ inflight = self._connection_trigger_inflight.get(websocket)
1507
+ if inflight is not None:
1508
+ safe_log(
1509
+ self._logger,
1510
+ "info",
1511
+ "session trigger coalesced with one in flight",
1512
+ {"client": client_info, "user_id": user_id},
1513
+ )
1514
+ try:
1515
+ frame = await asyncio.shield(inflight)
1516
+ except Exception: # noqa: BLE001 - the first caller already reported it
1517
+ frame = None
1518
+ if frame is not None:
1519
+ try:
1520
+ await websocket.send(frame)
1521
+ except Exception: # noqa: BLE001
1522
+ pass
1523
+ return
1524
+
1525
+ done: asyncio.Future = asyncio.get_running_loop().create_future()
1526
+ self._connection_trigger_inflight[websocket] = done
1527
+ emitted: Optional[str] = None
1528
+ try:
1529
+ emitted = await self._trigger_once(websocket, user_id, user_trigger, client_info, current_page)
1530
+ finally:
1531
+ self._connection_trigger_inflight.pop(websocket, None)
1532
+ if not done.done():
1533
+ done.set_result(emitted)
1534
+
1535
+ async def _trigger_once(
1536
+ self,
1537
+ websocket: WebSocketServerProtocol,
1538
+ user_id: str,
1539
+ user_trigger: Optional[bool],
1540
+ client_info: str,
1541
+ current_page: Optional[dict],
1542
+ ) -> Optional[str]:
1543
+ """One trigger-session call. Returns the success frame it sent, if any."""
1448
1544
  safe_log(
1449
1545
  self._logger,
1450
1546
  "info",
@@ -1481,7 +1577,7 @@ class WebSocketServer:
1481
1577
  "message": "failed to trigger session",
1482
1578
  "error": "UPSTREAM_TRIGGER_FAILED",
1483
1579
  }))
1484
- return
1580
+ return None
1485
1581
 
1486
1582
  response_data = {
1487
1583
  "status": response.status,
@@ -1490,7 +1586,8 @@ class WebSocketServer:
1490
1586
  "time_taken": response.time_taken,
1491
1587
  }
1492
1588
 
1493
- await websocket.send(json.dumps(response_data))
1589
+ frame = json.dumps(response_data)
1590
+ await websocket.send(frame)
1494
1591
 
1495
1592
  safe_log(
1496
1593
  self._logger,
@@ -1503,6 +1600,7 @@ class WebSocketServer:
1503
1600
  "has_session_data": response.session_data is not None,
1504
1601
  },
1505
1602
  )
1603
+ return frame
1506
1604
 
1507
1605
  except Exception as e:
1508
1606
  # Generic on the wire, detailed in the server's own logs: upstream
@@ -127,31 +127,134 @@ async def test_user_trigger_forwards_the_browsers_page(base_url, api_key, ws_por
127
127
  client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
128
128
  token = await client.initialize_websocket(user_id="user_123", port=ws_port)
129
129
 
130
- async with websockets.connect(ws_url(ws_port, token)) as ws:
131
- await ws.recv() # auto-trigger on connect: no page known yet
132
- assert "current_page" not in json.loads(route.calls.last.request.content)
130
+ try:
131
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
132
+ await ws.recv() # auto-trigger on connect: no page known yet
133
+ assert "current_page" not in json.loads(route.calls.last.request.content)
134
+
135
+ await ws.send(json.dumps({
136
+ "user_trigger": True,
137
+ "page": {"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices",
138
+ "referrer_path": "/home", "junk": "ignored"},
139
+ }))
140
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
141
+ assert data["status"] == "success"
142
+
143
+ body = json.loads(route.calls.last.request.content)
144
+ assert body["external_user_id"] == "user_123"
145
+ assert body["current_page"] == {
146
+ "path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices", "referrer_path": "/home",
147
+ }
148
+
149
+ # A malformed hint costs nothing: the session is still triggered, without it.
150
+ await ws.send(json.dumps({"user_trigger": True, "page": {"pattern": 7}}))
151
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
152
+ assert data["status"] == "success"
153
+ assert "current_page" not in json.loads(route.calls.last.request.content)
154
+
155
+ finally:
156
+ await client.aclose()
133
157
 
134
- await ws.send(json.dumps({
135
- "user_trigger": True,
136
- "page": {"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices",
137
- "referrer_path": "/home", "junk": "ignored"},
138
- }))
139
- data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
140
- assert data["status"] == "success"
141
158
 
142
- body = json.loads(route.calls.last.request.content)
143
- assert body["external_user_id"] == "user_123"
144
- assert body["current_page"] == {
145
- "path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices", "referrer_path": "/home",
146
- }
159
+ @pytest.mark.asyncio
160
+ @respx.mock
161
+ async def test_a_client_that_asks_for_its_session_replaces_the_automatic_trigger(
162
+ base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
163
+ ):
164
+ """One Centive call per connection start, carrying the page.
147
165
 
148
- # A malformed hint costs nothing: the session is still triggered, without it.
149
- await ws.send(json.dumps({"user_trigger": True, "page": {"pattern": 7}}))
150
- data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
151
- assert data["status"] == "success"
166
+ Seen on dev: the connect-time automatic trigger and the browser SDK's own
167
+ request (sent the moment the socket opens) both reached Centive within
168
+ milliseconds, two Anam tokens per connection, and the opening prompt was
169
+ built from the page-less one.
170
+ """
171
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
172
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
173
+ )
174
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(
175
+ return_value=httpx.Response(200, json=trigger_session_response)
176
+ )
177
+
178
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
179
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
180
+
181
+ try:
182
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
183
+ await ws.send(json.dumps({"user_trigger": True, "page": {"path": "/dashboard", "pattern": "/dashboard"}}))
184
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
185
+ assert data["status"] == "success"
186
+
187
+ # Past the grace period nothing else arrives: the automatic trigger stood down.
188
+ with pytest.raises(asyncio.TimeoutError):
189
+ await asyncio.wait_for(ws.recv(), timeout=client.config.session_trigger_grace_seconds + 0.7)
190
+
191
+ assert route.call_count == 1
192
+ assert json.loads(route.calls.last.request.content)["current_page"]["path"] == "/dashboard"
193
+ finally:
194
+ await client.aclose()
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ @respx.mock
199
+ async def test_a_silent_client_still_gets_the_automatic_trigger(
200
+ base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
201
+ ):
202
+ """Older browser SDKs and custom frontends never ask; they get their session
203
+ after the grace period, from exactly one call."""
204
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
205
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
206
+ )
207
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(
208
+ return_value=httpx.Response(200, json=trigger_session_response)
209
+ )
210
+
211
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
212
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
213
+
214
+ try:
215
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
216
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
217
+ assert data["status"] == "success"
218
+ assert data["session_data"]["token"] == trigger_session_response["session_data"]["token"]
219
+
220
+ assert route.call_count == 1
152
221
  assert "current_page" not in json.loads(route.calls.last.request.content)
222
+ finally:
223
+ await client.aclose()
153
224
 
154
- await client.aclose()
225
+
226
+ @pytest.mark.asyncio
227
+ @respx.mock
228
+ async def test_concurrent_triggers_on_one_connection_share_a_call(
229
+ base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
230
+ ):
231
+ """A request that lands while another trigger for the connection is in
232
+ flight waits for it and receives the same frame; Centive is called once."""
233
+ respx.get(f"{base_url}/anam/pause-status/user_123").mock(
234
+ return_value=httpx.Response(200, json=pause_status_not_paused_response)
235
+ )
236
+
237
+ async def slow(request):
238
+ await asyncio.sleep(0.4)
239
+ return httpx.Response(200, json=trigger_session_response)
240
+
241
+ route = respx.post(f"{base_url}/anam/trigger-session").mock(side_effect=slow)
242
+
243
+ client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
244
+ client.config.session_trigger_grace_seconds = 0 # automatic trigger fires at once
245
+ token = await client.initialize_websocket(user_id="user_123", port=ws_port)
246
+
247
+ try:
248
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
249
+ await ws.send(json.dumps({"user_trigger": True})) # lands while the automatic call is in flight
250
+ first = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
251
+ second = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
252
+ assert first["status"] == second["status"] == "success"
253
+ assert first["session_data"] == second["session_data"]
254
+
255
+ assert route.call_count == 1
256
+ finally:
257
+ await client.aclose()
155
258
 
156
259
 
157
260
  @pytest.mark.asyncio
@@ -174,23 +277,25 @@ async def test_user_trigger_falls_back_to_the_last_page_view_seen(
174
277
  client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
175
278
  token = await client.initialize_websocket(user_id="user_123", port=ws_port)
176
279
 
177
- async with websockets.connect(ws_url(ws_port, token)) as ws:
178
- await ws.recv()
179
- frame = dict(telemetry_event_data)
180
- await ws.send(json.dumps(frame))
181
- ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
182
- assert ack["type"] == "telemetry_ack"
183
-
184
- await ws.send(json.dumps({"user_trigger": True}))
185
- data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
186
- assert data["status"] == "success"
187
-
188
- body = json.loads(route.calls.last.request.content)
189
- latest_view = [e for e in frame["events"] if e["event_type"] == "page_view"][-1]["page"]
190
- assert body["current_page"]["path"] == latest_view["path"]
191
- assert body["current_page"]["pattern"] == latest_view["pattern"]
192
-
193
- await client.aclose()
280
+ try:
281
+ async with websockets.connect(ws_url(ws_port, token)) as ws:
282
+ await ws.recv()
283
+ frame = dict(telemetry_event_data)
284
+ await ws.send(json.dumps(frame))
285
+ ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
286
+ assert ack["type"] == "telemetry_ack"
287
+
288
+ await ws.send(json.dumps({"user_trigger": True}))
289
+ data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
290
+ assert data["status"] == "success"
291
+
292
+ body = json.loads(route.calls.last.request.content)
293
+ latest_view = [e for e in frame["events"] if e["event_type"] == "page_view"][-1]["page"]
294
+ assert body["current_page"]["path"] == latest_view["path"]
295
+ assert body["current_page"]["pattern"] == latest_view["pattern"]
296
+
297
+ finally:
298
+ await client.aclose()
194
299
 
195
300
 
196
301
  @pytest.mark.asyncio