iii-sdk 0.21.8.dev1__tar.gz → 0.21.8.dev3__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.
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/PKG-INFO +2 -2
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/pyproject.toml +2 -2
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/__init__.py +4 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/iii.py +94 -7
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/iii_types.py +1 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/types.py +3 -0
- iii_sdk-0.21.8.dev3/tests/test_connection_state_listener.py +149 -0
- iii_sdk-0.21.8.dev3/tests/test_reconnect_sends_reattach.py +77 -0
- iii_sdk-0.21.8.dev3/tests/test_reconnect_survives_handshake_eof.py +106 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/.gitignore +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/README.md +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/channel.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/channels.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/engine.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/errors.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/format_utils.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/helpers.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/iii_constants.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/internal.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/otel_worker_gauges.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/protocol.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/runtime.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/state.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/stream.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/trigger.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/triggers.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/utils.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/src/iii/worker_metrics.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/conftest.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_api_triggers.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_async_api.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_baggage_span_processor.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_bridge.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_channel_close_delay.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_channel_submodule.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_context_propagation.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_data_channels.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_engine_constants.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_errors.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_format_utils.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_healthcheck.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_helpers.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_hold_process.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_http_external_functions_integration.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_iii_registration_dedup.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_init_api.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_invocation_exception.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_invocation_metadata.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_logger_function_ids.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_logger_otel.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_middleware.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_payload.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_pubsub.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_rbac_workers.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_register_function_args.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_runtime_submodule.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_span_ops.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_state.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_stream_models.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_stream_types.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_streams.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_streams_runtime_annotations.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_sync_api.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_telemetry.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_telemetry_exporters.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_telemetry_types.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_trace_helpers.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_trigger_action.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_trigger_metadata.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_trigger_registration_error.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_trigger_submodule.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_trigger_type_lifecycle.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_utils.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_worker_metadata.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_worker_metrics.py +0 -0
- {iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iii-sdk
|
|
3
|
-
Version: 0.21.8.
|
|
3
|
+
Version: 0.21.8.dev3
|
|
4
4
|
Summary: III SDK for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/iii-hq/iii
|
|
6
6
|
Project-URL: Repository, https://github.com/iii-hq/iii
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
|
-
Requires-Dist: iii-helpers==0.21.8.
|
|
17
|
+
Requires-Dist: iii-helpers==0.21.8.dev3
|
|
18
18
|
Requires-Dist: opentelemetry-api>=1.25
|
|
19
19
|
Requires-Dist: pydantic>=2.0
|
|
20
20
|
Requires-Dist: websockets>=12.0
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "iii-sdk"
|
|
7
|
-
version = "0.21.8.
|
|
7
|
+
version = "0.21.8.dev3"
|
|
8
8
|
description = "III SDK for Python"
|
|
9
9
|
authors = [{ name = "III" }]
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -23,7 +23,7 @@ dependencies = [
|
|
|
23
23
|
"websockets>=12.0",
|
|
24
24
|
"pydantic>=2.0",
|
|
25
25
|
"opentelemetry-api>=1.25",
|
|
26
|
-
"iii-helpers==0.21.8.
|
|
26
|
+
"iii-helpers==0.21.8.dev3",
|
|
27
27
|
]
|
|
28
28
|
|
|
29
29
|
[project.urls]
|
|
@@ -5,6 +5,8 @@ from iii_helpers.queue import EnqueueResult
|
|
|
5
5
|
from .errors import InvocationError
|
|
6
6
|
from .iii import TriggerAction, register_worker
|
|
7
7
|
from .iii_constants import (
|
|
8
|
+
ConnectionStateCallback,
|
|
9
|
+
IIIConnectionState,
|
|
8
10
|
InitOptions,
|
|
9
11
|
TelemetryOptions,
|
|
10
12
|
)
|
|
@@ -24,6 +26,8 @@ __all__ = [
|
|
|
24
26
|
# Errors
|
|
25
27
|
"InvocationError",
|
|
26
28
|
# Core
|
|
29
|
+
"ConnectionStateCallback",
|
|
30
|
+
"IIIConnectionState",
|
|
27
31
|
"InitOptions",
|
|
28
32
|
"register_worker",
|
|
29
33
|
"TelemetryOptions",
|
|
@@ -32,6 +32,7 @@ from .format_utils import extract_request_format, extract_response_format
|
|
|
32
32
|
from .iii_constants import (
|
|
33
33
|
DEFAULT_RECONNECTION_CONFIG,
|
|
34
34
|
MAX_QUEUE_SIZE,
|
|
35
|
+
ConnectionStateCallback,
|
|
35
36
|
FunctionRef,
|
|
36
37
|
IIIConnectionState,
|
|
37
38
|
InitOptions,
|
|
@@ -202,7 +203,11 @@ class III:
|
|
|
202
203
|
)
|
|
203
204
|
self._reconnect_attempt = 0
|
|
204
205
|
self._connection_state: IIIConnectionState = "disconnected"
|
|
206
|
+
# Must exist before the auto-connect below: the loop thread calls
|
|
207
|
+
# _set_connection_state as soon as connect_async starts.
|
|
208
|
+
self._connection_listeners: list[ConnectionStateCallback] = []
|
|
205
209
|
self._worker_id: str | None = None
|
|
210
|
+
self._reattach_token: str | None = None
|
|
206
211
|
|
|
207
212
|
# Background event loop thread
|
|
208
213
|
self._loop = asyncio.new_event_loop()
|
|
@@ -232,11 +237,16 @@ class III:
|
|
|
232
237
|
return
|
|
233
238
|
if self._connection_state == "failed":
|
|
234
239
|
raise ConnectionError(f"Connection to {self._address} failed")
|
|
235
|
-
self._connected_event.wait(timeout=30)
|
|
240
|
+
connected = self._connected_event.wait(timeout=30)
|
|
236
241
|
if cast(IIIConnectionState, self._connection_state) == "failed":
|
|
237
242
|
raise ConnectionError(
|
|
238
243
|
f"Connection to {self._address} failed after max retries"
|
|
239
244
|
)
|
|
245
|
+
if not connected:
|
|
246
|
+
log.warning(
|
|
247
|
+
f"Timed out after 30s waiting for connection to {self._address}; "
|
|
248
|
+
"continuing to retry in background"
|
|
249
|
+
)
|
|
240
250
|
|
|
241
251
|
def shutdown(self) -> None:
|
|
242
252
|
"""Gracefully shut down the client, releasing all resources.
|
|
@@ -337,8 +347,18 @@ class III:
|
|
|
337
347
|
)
|
|
338
348
|
log.info(f"Connected to {self._address}")
|
|
339
349
|
await self._on_connected()
|
|
340
|
-
except
|
|
341
|
-
|
|
350
|
+
except Exception as e:
|
|
351
|
+
# Catch everything: a connect-time failure must never kill the
|
|
352
|
+
# reconnect loop. Enumerating exception types here has failed
|
|
353
|
+
# twice: ConnectionError/OSError missed websockets'
|
|
354
|
+
# InvalidHandshake (MOT-3931), and that in turn missed
|
|
355
|
+
# InvalidMessage, raised when the engine-boot listener race
|
|
356
|
+
# surfaces through iii-network's accept-then-dial as an EOF
|
|
357
|
+
# between TCP connect and the WS 101 (MOT-3966): the exception
|
|
358
|
+
# escaped, the connect task died silently, and the worker stayed
|
|
359
|
+
# a zombie with zero retries. CancelledError is BaseException,
|
|
360
|
+
# so shutdown cancellation still propagates.
|
|
361
|
+
log.warning(f"Connection failed: {type(e).__name__}: {e}")
|
|
342
362
|
if self._running:
|
|
343
363
|
self._schedule_reconnect()
|
|
344
364
|
|
|
@@ -378,6 +398,18 @@ class III:
|
|
|
378
398
|
async def _on_connected(self) -> None:
|
|
379
399
|
self._reconnect_attempt = 0
|
|
380
400
|
self._set_connection_state("connected")
|
|
401
|
+
# Reconnect: present the previous engine-assigned identity BEFORE the
|
|
402
|
+
# registration replay so the engine retires the old connection and the
|
|
403
|
+
# replay lands on a clean slate instead of racing its cleanup. The
|
|
404
|
+
# token proves we ARE that worker (ids alone are publicly listable).
|
|
405
|
+
if self._worker_id:
|
|
406
|
+
reattach: dict[str, Any] = {
|
|
407
|
+
"type": MessageType.REATTACH.value,
|
|
408
|
+
"previous_worker_id": self._worker_id,
|
|
409
|
+
}
|
|
410
|
+
if self._reattach_token:
|
|
411
|
+
reattach["reattach_token"] = self._reattach_token
|
|
412
|
+
await self._send(reattach)
|
|
381
413
|
# Re-register all (snapshot to avoid mutation from caller thread)
|
|
382
414
|
for trigger_type_data in list(self._trigger_types.values()):
|
|
383
415
|
await self._send(trigger_type_data.message)
|
|
@@ -425,7 +457,12 @@ class III:
|
|
|
425
457
|
async def _send(self, msg: Any) -> None:
|
|
426
458
|
data = self._to_dict(msg)
|
|
427
459
|
if self._ws and self._ws.state.name == "OPEN":
|
|
428
|
-
|
|
460
|
+
# Redact the reattach secret from debug logs; it authorizes
|
|
461
|
+
# evicting this worker's previous connection.
|
|
462
|
+
log_safe = {
|
|
463
|
+
k: ("***" if k == "reattach_token" else v) for k, v in data.items()
|
|
464
|
+
}
|
|
465
|
+
log.debug(f"Send: {json.dumps(log_safe)[:200]}")
|
|
429
466
|
await self._ws.send(json.dumps(data))
|
|
430
467
|
else:
|
|
431
468
|
if len(self._queue) >= MAX_QUEUE_SIZE:
|
|
@@ -486,6 +523,7 @@ class III:
|
|
|
486
523
|
elif msg_type == MessageType.WORKER_REGISTERED.value:
|
|
487
524
|
worker_id = data.get("worker_id", "")
|
|
488
525
|
self._worker_id = worker_id
|
|
526
|
+
self._reattach_token = data.get("reattach_token")
|
|
489
527
|
log.debug(f"Worker registered with ID: {worker_id}")
|
|
490
528
|
|
|
491
529
|
def _handle_result(self, invocation_id: str, result: Any, error: Any) -> None:
|
|
@@ -807,6 +845,50 @@ class III:
|
|
|
807
845
|
self._connected_event.set()
|
|
808
846
|
else:
|
|
809
847
|
self._connected_event.clear()
|
|
848
|
+
for handler in list(self._connection_listeners):
|
|
849
|
+
try:
|
|
850
|
+
handler(state)
|
|
851
|
+
except Exception:
|
|
852
|
+
log.exception("Connection state listener raised")
|
|
853
|
+
|
|
854
|
+
def add_connection_state_listener(
|
|
855
|
+
self, handler: ConnectionStateCallback
|
|
856
|
+
) -> Callable[[], None]:
|
|
857
|
+
"""Subscribe to connection-state transitions.
|
|
858
|
+
|
|
859
|
+
The handler is fired immediately with the current state (on the
|
|
860
|
+
caller's thread), then once per transition. Transitions fire on the
|
|
861
|
+
SDK's background event-loop thread: keep handlers fast and do not
|
|
862
|
+
call sync SDK methods from them (they would raise ``RuntimeError``).
|
|
863
|
+
Treat calls as state notifications, not edges. A state may rarely
|
|
864
|
+
be observed twice around subscription. Registering the same handler
|
|
865
|
+
twice fires it twice. Returns an idempotent unsubscribe function
|
|
866
|
+
that removes only its own registration.
|
|
867
|
+
|
|
868
|
+
Examples:
|
|
869
|
+
>>> unsubscribe = worker.add_connection_state_listener(
|
|
870
|
+
... lambda state: print(f"engine link: {state}")
|
|
871
|
+
... )
|
|
872
|
+
"""
|
|
873
|
+
self._connection_listeners.append(handler)
|
|
874
|
+
try:
|
|
875
|
+
handler(self._connection_state)
|
|
876
|
+
except Exception:
|
|
877
|
+
log.exception("Connection state listener raised on initial fire")
|
|
878
|
+
|
|
879
|
+
unsubscribed = False
|
|
880
|
+
|
|
881
|
+
def unsubscribe() -> None:
|
|
882
|
+
nonlocal unsubscribed
|
|
883
|
+
if unsubscribed:
|
|
884
|
+
return
|
|
885
|
+
unsubscribed = True
|
|
886
|
+
try:
|
|
887
|
+
self._connection_listeners.remove(handler)
|
|
888
|
+
except ValueError:
|
|
889
|
+
pass
|
|
890
|
+
|
|
891
|
+
return unsubscribe
|
|
810
892
|
|
|
811
893
|
def get_connection_state(self) -> IIIConnectionState:
|
|
812
894
|
"""Return the current WebSocket connection state.
|
|
@@ -1394,7 +1476,7 @@ class TriggerAction:
|
|
|
1394
1476
|
|
|
1395
1477
|
Examples:
|
|
1396
1478
|
>>> from iii import TriggerAction
|
|
1397
|
-
>>> # The queue must be declared in the
|
|
1479
|
+
>>> # The queue must be declared in the queue worker's queue_configs.
|
|
1398
1480
|
>>> worker.trigger({'function_id': 'process', 'payload': {}, 'action': TriggerAction.Enqueue(queue='jobs')})
|
|
1399
1481
|
>>> worker.trigger({'function_id': 'notify', 'payload': {}, 'action': TriggerAction.Void()})
|
|
1400
1482
|
"""
|
|
@@ -1417,14 +1499,19 @@ class TriggerAction:
|
|
|
1417
1499
|
def register_worker(address: str, options: InitOptions | None = None) -> III:
|
|
1418
1500
|
"""Register the worker with a iii instance, returns a connected worker client.
|
|
1419
1501
|
|
|
1420
|
-
Blocks
|
|
1502
|
+
Blocks up to 30 seconds for the WebSocket connection to be established.
|
|
1503
|
+
If the engine is not reachable in time, a warning is logged and the
|
|
1504
|
+
client is returned anyway; it keeps retrying in the background and
|
|
1505
|
+
flushes registrations once connected. Use
|
|
1506
|
+
``add_connection_state_listener`` to observe the actual transition.
|
|
1421
1507
|
|
|
1422
1508
|
Args:
|
|
1423
1509
|
address: WebSocket URL of the III engine (e.g. ``ws://localhost:49134``).
|
|
1424
1510
|
options: Optional configuration for worker name, timeouts, reconnection, and OTel.
|
|
1425
1511
|
|
|
1426
1512
|
Returns:
|
|
1427
|
-
|
|
1513
|
+
An III client instance (connected, or still connecting after the
|
|
1514
|
+
30s wait timed out).
|
|
1428
1515
|
|
|
1429
1516
|
Raises:
|
|
1430
1517
|
ConnectionError: If the connection fails or exceeds max retries.
|
|
@@ -21,6 +21,7 @@ class MessageType(str, Enum):
|
|
|
21
21
|
UNREGISTER_TRIGGER_TYPE = "unregistertriggertype"
|
|
22
22
|
TRIGGER_REGISTRATION_RESULT = "triggerregistrationresult"
|
|
23
23
|
WORKER_REGISTERED = "workerregistered"
|
|
24
|
+
REATTACH = "reattach"
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
class RegisterTriggerTypeMessage(BaseModel):
|
|
@@ -21,6 +21,7 @@ from typing import (
|
|
|
21
21
|
from iii_helpers.http import HttpInvocationConfig
|
|
22
22
|
from pydantic import BaseModel, ConfigDict
|
|
23
23
|
|
|
24
|
+
from .iii_constants import ConnectionStateCallback
|
|
24
25
|
from .iii_types import (
|
|
25
26
|
RegisterFunctionMessage,
|
|
26
27
|
RegisterTriggerInput,
|
|
@@ -111,6 +112,8 @@ class IIIClient(Protocol):
|
|
|
111
112
|
|
|
112
113
|
def unregister_trigger_type(self, trigger_type: RegisterTriggerTypeInput | dict[str, Any]) -> None: ...
|
|
113
114
|
|
|
115
|
+
def add_connection_state_listener(self, handler: ConnectionStateCallback) -> Callable[[], None]: ...
|
|
116
|
+
|
|
114
117
|
def shutdown(self) -> None: ...
|
|
115
118
|
|
|
116
119
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Unit tests for III.add_connection_state_listener (no live engine needed)."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from iii_helpers.observability import ReconnectionConfig
|
|
9
|
+
from websockets.exceptions import InvalidMessage
|
|
10
|
+
|
|
11
|
+
import iii.iii as iii_module
|
|
12
|
+
from iii import InitOptions
|
|
13
|
+
from iii.iii import III
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeWebSocket:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.state = SimpleNamespace(name="OPEN")
|
|
19
|
+
|
|
20
|
+
async def send(self, payload: str) -> None:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
async def close(self) -> None:
|
|
24
|
+
self.state = SimpleNamespace(name="CLOSED")
|
|
25
|
+
|
|
26
|
+
def __aiter__(self) -> "FakeWebSocket":
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
async def __anext__(self) -> Any:
|
|
30
|
+
raise StopAsyncIteration
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _patch_transport(monkeypatch: pytest.MonkeyPatch, connect) -> None:
|
|
34
|
+
monkeypatch.setattr(iii_module.websockets, "connect", connect)
|
|
35
|
+
monkeypatch.setattr("iii_helpers.observability.telemetry.init_otel", lambda **kwargs: None)
|
|
36
|
+
monkeypatch.setattr("iii_helpers.observability.telemetry.attach_event_loop", lambda loop: None)
|
|
37
|
+
monkeypatch.setattr(iii_module.III, "_register_worker_metadata", lambda self: None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _connected_client(monkeypatch: pytest.MonkeyPatch) -> III:
|
|
41
|
+
async def fake_connect(_addr: str, **kwargs: object) -> FakeWebSocket:
|
|
42
|
+
return FakeWebSocket()
|
|
43
|
+
|
|
44
|
+
_patch_transport(monkeypatch, fake_connect)
|
|
45
|
+
|
|
46
|
+
client = III("ws://fake")
|
|
47
|
+
client._wait_until_connected()
|
|
48
|
+
time.sleep(0.05)
|
|
49
|
+
assert client.get_connection_state() == "connected"
|
|
50
|
+
return client
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_immediate_fire_transitions_dedup_and_shutdown(
|
|
54
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
55
|
+
) -> None:
|
|
56
|
+
client = _connected_client(monkeypatch)
|
|
57
|
+
events: list[str] = []
|
|
58
|
+
|
|
59
|
+
client.add_connection_state_listener(events.append)
|
|
60
|
+
assert events == ["connected"] # fired immediately with current state
|
|
61
|
+
|
|
62
|
+
client._set_connection_state("reconnecting")
|
|
63
|
+
client._set_connection_state("connected")
|
|
64
|
+
client._set_connection_state("connected") # same state -> no fire
|
|
65
|
+
assert events == ["connected", "reconnecting", "connected"]
|
|
66
|
+
|
|
67
|
+
client.shutdown()
|
|
68
|
+
assert events == ["connected", "reconnecting", "connected", "disconnected"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_raising_listener_isolated_and_unsubscribe_idempotent(
|
|
72
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
73
|
+
) -> None:
|
|
74
|
+
client = _connected_client(monkeypatch)
|
|
75
|
+
events: list[str] = []
|
|
76
|
+
|
|
77
|
+
def bad(state: str) -> None:
|
|
78
|
+
raise RuntimeError("boom")
|
|
79
|
+
|
|
80
|
+
client.add_connection_state_listener(bad) # immediate fire raises, is swallowed
|
|
81
|
+
unsubscribe = client.add_connection_state_listener(events.append)
|
|
82
|
+
assert events == ["connected"]
|
|
83
|
+
|
|
84
|
+
client._set_connection_state("reconnecting")
|
|
85
|
+
assert events == ["connected", "reconnecting"] # bad didn't block delivery
|
|
86
|
+
|
|
87
|
+
unsubscribe()
|
|
88
|
+
unsubscribe() # idempotent
|
|
89
|
+
client._set_connection_state("connected")
|
|
90
|
+
assert events == ["connected", "reconnecting"]
|
|
91
|
+
|
|
92
|
+
client.shutdown() # fires "disconnected" into bad -> swallowed, no crash
|
|
93
|
+
assert events == ["connected", "reconnecting"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_duplicate_subscription_unsubscribe_removes_only_its_own(
|
|
97
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
98
|
+
) -> None:
|
|
99
|
+
client = _connected_client(monkeypatch)
|
|
100
|
+
events: list[str] = []
|
|
101
|
+
|
|
102
|
+
unsub1 = client.add_connection_state_listener(events.append)
|
|
103
|
+
unsub2 = client.add_connection_state_listener(events.append)
|
|
104
|
+
assert events == ["connected", "connected"]
|
|
105
|
+
|
|
106
|
+
unsub1()
|
|
107
|
+
unsub1() # second call must not remove the other registration
|
|
108
|
+
events.clear()
|
|
109
|
+
client._set_connection_state("reconnecting")
|
|
110
|
+
assert events == ["reconnecting"]
|
|
111
|
+
|
|
112
|
+
unsub2()
|
|
113
|
+
client._set_connection_state("connected")
|
|
114
|
+
assert events == ["reconnecting"]
|
|
115
|
+
|
|
116
|
+
client.shutdown()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_handshake_failure_keeps_retrying(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
120
|
+
attempts = 0
|
|
121
|
+
|
|
122
|
+
async def flaky_connect(_addr: str, **kwargs: object) -> FakeWebSocket:
|
|
123
|
+
nonlocal attempts
|
|
124
|
+
attempts += 1
|
|
125
|
+
if attempts <= 2:
|
|
126
|
+
# Peer accepted TCP but the WS upgrade died (not an OSError):
|
|
127
|
+
# must not kill the connect/reconnect task.
|
|
128
|
+
raise InvalidMessage("did not receive a valid HTTP response")
|
|
129
|
+
return FakeWebSocket()
|
|
130
|
+
|
|
131
|
+
_patch_transport(monkeypatch, flaky_connect)
|
|
132
|
+
|
|
133
|
+
client = III(
|
|
134
|
+
"ws://fake",
|
|
135
|
+
InitOptions(reconnection_config=ReconnectionConfig(initial_delay_ms=5, max_delay_ms=10)),
|
|
136
|
+
)
|
|
137
|
+
states: list[str] = []
|
|
138
|
+
client.add_connection_state_listener(states.append)
|
|
139
|
+
client._wait_until_connected()
|
|
140
|
+
assert client.get_connection_state() == "connected"
|
|
141
|
+
assert attempts == 3
|
|
142
|
+
# _set_connection_state sets the connected event before dispatching
|
|
143
|
+
# listeners, so give the loop thread a bounded window to deliver.
|
|
144
|
+
deadline = time.time() + 2
|
|
145
|
+
while "connected" not in states and time.time() < deadline:
|
|
146
|
+
time.sleep(0.01)
|
|
147
|
+
assert "connected" in states
|
|
148
|
+
|
|
149
|
+
client.shutdown()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""iii-hq/iii#1975 regression: on reconnect the SDK must present the previous
|
|
2
|
+
engine-assigned worker id via a ``reattach`` frame BEFORE replaying its
|
|
3
|
+
registration batch, so the engine retires the old connection and the replay
|
|
4
|
+
lands on a clean slate instead of racing its cleanup. On the first connect,
|
|
5
|
+
with no prior id, no reattach is sent.
|
|
6
|
+
|
|
7
|
+
Driven directly against ``_on_connected`` (no socket needed): the reattach is
|
|
8
|
+
the first thing sent, gated on a stored ``_worker_id``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from iii.iii import III
|
|
14
|
+
from iii.iii_constants import InitOptions
|
|
15
|
+
from iii.iii_types import MessageType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_reconnect_sends_reattach_first(monkeypatch) -> None:
|
|
19
|
+
monkeypatch.setattr(
|
|
20
|
+
"iii_helpers.observability.telemetry.init_otel", lambda **kwargs: None
|
|
21
|
+
)
|
|
22
|
+
monkeypatch.setattr(
|
|
23
|
+
"iii_helpers.observability.telemetry.attach_event_loop", lambda loop: None
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# The constructor auto-connects on a background thread; it never reaches a
|
|
27
|
+
# real engine here (unused.test fails to resolve), so it never calls
|
|
28
|
+
# _on_connected on its own — we drive that directly below. shutdown() in
|
|
29
|
+
# the finally stops the (non-daemon) loop thread.
|
|
30
|
+
client = III("ws://unused.test", InitOptions())
|
|
31
|
+
|
|
32
|
+
sent: list = []
|
|
33
|
+
|
|
34
|
+
async def fake_send(msg) -> None:
|
|
35
|
+
sent.append(msg)
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(client, "_send", fake_send)
|
|
38
|
+
monkeypatch.setattr(client, "_register_worker_metadata", lambda: None)
|
|
39
|
+
client._ws = None # _receive_loop returns immediately
|
|
40
|
+
|
|
41
|
+
# One binding to replay, so ordering (reattach before replay) is observable.
|
|
42
|
+
client._triggers["t1"] = {"type": "registertrigger", "id": "t1"}
|
|
43
|
+
|
|
44
|
+
async def drive() -> None:
|
|
45
|
+
# First connect: no stored id yet -> no reattach.
|
|
46
|
+
await client._on_connected()
|
|
47
|
+
assert not any(
|
|
48
|
+
isinstance(m, dict) and m.get("type") == MessageType.REATTACH.value
|
|
49
|
+
for m in sent
|
|
50
|
+
), f"no reattach must be sent on first connect: {sent}"
|
|
51
|
+
|
|
52
|
+
# Engine assigns an identity + secret; the socket then drops and
|
|
53
|
+
# reconnects.
|
|
54
|
+
client._worker_id = "w-old"
|
|
55
|
+
client._reattach_token = "tok-old"
|
|
56
|
+
sent.clear()
|
|
57
|
+
await client._on_connected()
|
|
58
|
+
|
|
59
|
+
loop = asyncio.new_event_loop()
|
|
60
|
+
try:
|
|
61
|
+
loop.run_until_complete(drive())
|
|
62
|
+
finally:
|
|
63
|
+
loop.close()
|
|
64
|
+
client.shutdown()
|
|
65
|
+
|
|
66
|
+
assert sent, "reconnect must send frames"
|
|
67
|
+
first = sent[0]
|
|
68
|
+
assert isinstance(first, dict) and first.get("type") == MessageType.REATTACH.value, (
|
|
69
|
+
f"reattach must be the first frame on reconnect, got: {sent}"
|
|
70
|
+
)
|
|
71
|
+
assert first.get("previous_worker_id") == "w-old"
|
|
72
|
+
assert first.get("reattach_token") == "tok-old"
|
|
73
|
+
# And it must precede the replayed registration.
|
|
74
|
+
assert any(
|
|
75
|
+
(m.get("type") if isinstance(m, dict) else getattr(m, "type", None)) == "registertrigger"
|
|
76
|
+
for m in sent[1:]
|
|
77
|
+
), f"registration replay must follow the reattach: {sent}"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""MOT-3966 regression: the reconnect loop must survive handshake-level failures.
|
|
2
|
+
|
|
3
|
+
First-boot conditions: the engine autostarts bundle workers before its worker
|
|
4
|
+
listener is accepting, and the VM's userspace network stack (iii-network)
|
|
5
|
+
accepts the guest's TCP connection before dialing the host — so "nothing
|
|
6
|
+
listening yet" surfaces to the client as an EOF between TCP connect and the
|
|
7
|
+
WS 101. The websockets library raises that as ``InvalidMessage`` (an
|
|
8
|
+
``InvalidHandshake``, NOT a ``ConnectionError``/``OSError``). The SDK's old
|
|
9
|
+
narrow ``except`` let it escape ``_do_connect``, silently killing the connect
|
|
10
|
+
task with zero retries: the worker stayed a zombie for the VM's lifetime.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import socket
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
from iii.iii import III
|
|
19
|
+
from iii.iii_constants import InitOptions
|
|
20
|
+
from iii_helpers.observability import ReconnectionConfig
|
|
21
|
+
|
|
22
|
+
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _minimal_ws_server(listener: socket.socket, eof_first_n: int) -> None:
|
|
26
|
+
"""Accept loop: the first ``eof_first_n`` connections are closed without
|
|
27
|
+
sending a byte (EOF during the client's handshake — the engine-boot race
|
|
28
|
+
as seen through iii-network's accept-then-dial). Later connections get a
|
|
29
|
+
real RFC 6455 101 reply and are held open."""
|
|
30
|
+
held = []
|
|
31
|
+
accepted = 0
|
|
32
|
+
while True:
|
|
33
|
+
try:
|
|
34
|
+
conn, _ = listener.accept()
|
|
35
|
+
except OSError:
|
|
36
|
+
return # listener closed: test over
|
|
37
|
+
accepted += 1
|
|
38
|
+
if accepted <= eof_first_n:
|
|
39
|
+
conn.close()
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
data = b""
|
|
43
|
+
try:
|
|
44
|
+
while b"\r\n\r\n" not in data:
|
|
45
|
+
chunk = conn.recv(4096)
|
|
46
|
+
if not chunk:
|
|
47
|
+
break
|
|
48
|
+
data += chunk
|
|
49
|
+
key = ""
|
|
50
|
+
for line in data.decode("latin1").split("\r\n"):
|
|
51
|
+
if line.lower().startswith("sec-websocket-key:"):
|
|
52
|
+
key = line.split(":", 1)[1].strip()
|
|
53
|
+
accept = base64.b64encode(
|
|
54
|
+
hashlib.sha1((key + WS_GUID).encode()).digest()
|
|
55
|
+
).decode()
|
|
56
|
+
conn.sendall(
|
|
57
|
+
b"HTTP/1.1 101 Switching Protocols\r\n"
|
|
58
|
+
b"Upgrade: websocket\r\n"
|
|
59
|
+
b"Connection: Upgrade\r\n"
|
|
60
|
+
b"Sec-WebSocket-Accept: " + accept.encode() + b"\r\n\r\n"
|
|
61
|
+
)
|
|
62
|
+
except OSError:
|
|
63
|
+
conn.close()
|
|
64
|
+
continue
|
|
65
|
+
held.append(conn) # keep open; the client streams registrations
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_reconnect_survives_handshake_eof(monkeypatch) -> None:
|
|
69
|
+
monkeypatch.setattr(
|
|
70
|
+
"iii_helpers.observability.telemetry.init_otel", lambda **kwargs: None
|
|
71
|
+
)
|
|
72
|
+
monkeypatch.setattr(
|
|
73
|
+
"iii_helpers.observability.telemetry.attach_event_loop", lambda loop: None
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
listener = socket.socket()
|
|
77
|
+
listener.bind(("127.0.0.1", 0))
|
|
78
|
+
listener.listen(8)
|
|
79
|
+
port = listener.getsockname()[1]
|
|
80
|
+
server = threading.Thread(
|
|
81
|
+
target=_minimal_ws_server, args=(listener, 1), daemon=True
|
|
82
|
+
)
|
|
83
|
+
server.start()
|
|
84
|
+
|
|
85
|
+
client = III(
|
|
86
|
+
f"ws://127.0.0.1:{port}",
|
|
87
|
+
InitOptions(
|
|
88
|
+
reconnection_config=ReconnectionConfig(
|
|
89
|
+
initial_delay_ms=100,
|
|
90
|
+
max_delay_ms=200,
|
|
91
|
+
jitter_factor=0.0,
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
# Pre-fix: the first connection's EOF raised InvalidMessage out of
|
|
97
|
+
# _do_connect, the connect task died silently, no retry ever ran,
|
|
98
|
+
# and this wait timed out with the client stuck "connecting".
|
|
99
|
+
client._connected_event.wait(timeout=10)
|
|
100
|
+
assert client._connection_state == "connected", (
|
|
101
|
+
f"client never recovered from the handshake EOF "
|
|
102
|
+
f"(state={client._connection_state!r}; reconnect loop died)"
|
|
103
|
+
)
|
|
104
|
+
finally:
|
|
105
|
+
client.shutdown()
|
|
106
|
+
listener.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{iii_sdk-0.21.8.dev1 → iii_sdk-0.21.8.dev3}/tests/test_http_external_functions_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|