iii-sdk 0.11.2.dev2__tar.gz → 0.11.3__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.11.2.dev2 → iii_sdk-0.11.3}/PKG-INFO +1 -1
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/pyproject.toml +1 -1
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/__init__.py +5 -0
- iii_sdk-0.11.3/src/iii/errors.py +88 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/iii.py +49 -13
- iii_sdk-0.11.3/tests/test_errors.py +168 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_rbac_workers.py +129 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/uv.lock +1 -1
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/.gitignore +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/README.md +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/channels.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/format_utils.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/iii_constants.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/iii_types.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/logger.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/otel_worker_gauges.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/state.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/stream.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/telemetry.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/telemetry_exporters.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/telemetry_types.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/triggers.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/types.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/utils.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/src/iii/worker_metrics.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/conftest.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_api_triggers.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_async_api.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_bridge.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_channel_close_delay.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_context_propagation.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_data_channels.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_format_utils.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_healthcheck.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_hold_process.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_http_external_functions_integration.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_iii_registration_dedup.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_init_api.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_invocation_exception.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_logger_function_ids.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_logger_otel.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_middleware.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_pubsub.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_queue_integration.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_register_function_args.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_state.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_streams.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_streams_runtime_annotations.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_sync_api.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_telemetry.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_telemetry_exporters.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_telemetry_types.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_trace_helpers.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_trigger_metadata.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_utils.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_worker_metadata.py +0 -0
- {iii_sdk-0.11.2.dev2 → iii_sdk-0.11.3}/tests/test_worker_metrics.py +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""III SDK for Python."""
|
|
2
2
|
|
|
3
3
|
from .channels import ChannelReader, ChannelWriter
|
|
4
|
+
from .errors import IIIForbiddenError, IIIInvocationError, IIITimeoutError
|
|
4
5
|
from .format_utils import extract_request_format, extract_response_format, python_type_to_format
|
|
5
6
|
from .iii import TriggerAction, register_worker
|
|
6
7
|
from .iii_constants import FunctionRef, InitOptions, ReconnectionConfig, TelemetryOptions
|
|
@@ -62,6 +63,10 @@ __all__ = [
|
|
|
62
63
|
# Channels
|
|
63
64
|
"ChannelReader",
|
|
64
65
|
"ChannelWriter",
|
|
66
|
+
# Errors
|
|
67
|
+
"IIIForbiddenError",
|
|
68
|
+
"IIIInvocationError",
|
|
69
|
+
"IIITimeoutError",
|
|
65
70
|
# Core
|
|
66
71
|
"FunctionRef",
|
|
67
72
|
"InitOptions",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IIIInvocationError(Exception):
|
|
7
|
+
"""Raised when an invocation dispatched by the SDK fails.
|
|
8
|
+
|
|
9
|
+
Subclass by code:
|
|
10
|
+
- ``IIIForbiddenError`` (``code == 'FORBIDDEN'``)
|
|
11
|
+
- ``IIITimeoutError`` (``code == 'TIMEOUT'``)
|
|
12
|
+
|
|
13
|
+
Catch the base to handle every rejection; catch a subclass to react to
|
|
14
|
+
a specific category. ``except Exception`` continues to work because
|
|
15
|
+
``IIIInvocationError`` inherits from ``Exception``.
|
|
16
|
+
|
|
17
|
+
Attributes are read-only after construction. ``stacktrace`` is the
|
|
18
|
+
engine-side trace when the remote handler raised; it may include
|
|
19
|
+
internal file paths and should not be surfaced to end users. ``str(err)``
|
|
20
|
+
intentionally never includes the stacktrace.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
code: str,
|
|
26
|
+
message: str,
|
|
27
|
+
function_id: str | None = None,
|
|
28
|
+
stacktrace: str | None = None,
|
|
29
|
+
invocation_id: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(f"{code}: {message}")
|
|
32
|
+
self.code = code
|
|
33
|
+
self.message = message
|
|
34
|
+
self.function_id = function_id
|
|
35
|
+
self.stacktrace = stacktrace
|
|
36
|
+
self.invocation_id = invocation_id
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class IIIForbiddenError(IIIInvocationError):
|
|
40
|
+
"""Raised when RBAC denies an invocation. ``code == 'FORBIDDEN'``."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IIITimeoutError(IIIInvocationError):
|
|
44
|
+
"""Raised when an invocation exceeds its timeout. ``code == 'TIMEOUT'``."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _wrap_wire_error(
|
|
48
|
+
error: Any,
|
|
49
|
+
*,
|
|
50
|
+
function_id: str | None,
|
|
51
|
+
invocation_id: str | None,
|
|
52
|
+
) -> IIIInvocationError:
|
|
53
|
+
"""Convert a wire ``ErrorBody``-shaped dict into a typed exception.
|
|
54
|
+
|
|
55
|
+
Dispatches to ``IIIForbiddenError`` / ``IIITimeoutError`` based on
|
|
56
|
+
``error['code']``. Malformed shapes (non-dict, missing fields, non-string
|
|
57
|
+
values) fall back to ``IIIInvocationError(code='UNKNOWN', ...)`` so no
|
|
58
|
+
rejection path prints as a raw dict repr.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(error, dict):
|
|
61
|
+
raw_code = error.get("code")
|
|
62
|
+
code = raw_code if isinstance(raw_code, str) else "UNKNOWN"
|
|
63
|
+
|
|
64
|
+
raw_message = error.get("message")
|
|
65
|
+
message = raw_message if isinstance(raw_message, str) else "<no message>"
|
|
66
|
+
|
|
67
|
+
raw_stacktrace = error.get("stacktrace")
|
|
68
|
+
stacktrace = raw_stacktrace if isinstance(raw_stacktrace, str) else None
|
|
69
|
+
|
|
70
|
+
cls: type[IIIInvocationError] = {
|
|
71
|
+
"FORBIDDEN": IIIForbiddenError,
|
|
72
|
+
"TIMEOUT": IIITimeoutError,
|
|
73
|
+
}.get(code, IIIInvocationError)
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
code=code,
|
|
77
|
+
message=message,
|
|
78
|
+
function_id=function_id,
|
|
79
|
+
stacktrace=stacktrace,
|
|
80
|
+
invocation_id=invocation_id,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return IIIInvocationError(
|
|
84
|
+
code="UNKNOWN",
|
|
85
|
+
message=str(error),
|
|
86
|
+
function_id=function_id,
|
|
87
|
+
invocation_id=invocation_id,
|
|
88
|
+
)
|
|
@@ -9,6 +9,7 @@ import random
|
|
|
9
9
|
import threading
|
|
10
10
|
import traceback
|
|
11
11
|
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
12
13
|
from importlib.metadata import version
|
|
13
14
|
from typing import Any, Awaitable, Callable, Coroutine, TypeVar, cast
|
|
14
15
|
|
|
@@ -16,6 +17,7 @@ import websockets
|
|
|
16
17
|
from websockets.asyncio.client import ClientConnection
|
|
17
18
|
|
|
18
19
|
from .channels import ChannelReader, ChannelWriter
|
|
20
|
+
from .errors import IIIInvocationError, IIITimeoutError, _wrap_wire_error
|
|
19
21
|
from .format_utils import extract_request_format, extract_response_format
|
|
20
22
|
from .iii_constants import (
|
|
21
23
|
DEFAULT_RECONNECTION_CONFIG,
|
|
@@ -86,6 +88,18 @@ class _TraceContextError(Exception):
|
|
|
86
88
|
self.traceparent = traceparent
|
|
87
89
|
|
|
88
90
|
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class _PendingInvocation:
|
|
93
|
+
"""Pending invocation record kept on the SDK until the engine responds.
|
|
94
|
+
|
|
95
|
+
``function_id`` is preserved so the timeout and error-wrapping paths
|
|
96
|
+
can name the target without plumbing it through every call site.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
future: asyncio.Future[Any]
|
|
100
|
+
function_id: str
|
|
101
|
+
|
|
102
|
+
|
|
89
103
|
class III:
|
|
90
104
|
"""WebSocket client for communication with the III Engine.
|
|
91
105
|
|
|
@@ -107,7 +121,7 @@ class III:
|
|
|
107
121
|
self._ws: ClientConnection | None = None
|
|
108
122
|
self._functions: dict[str, RemoteFunctionData] = {}
|
|
109
123
|
self._services: dict[str, RegisterServiceMessage] = {}
|
|
110
|
-
self._pending: dict[str,
|
|
124
|
+
self._pending: dict[str, _PendingInvocation] = {}
|
|
111
125
|
self._triggers: dict[str, RegisterTriggerMessage] = {}
|
|
112
126
|
self._trigger_types: dict[str, RemoteTriggerTypeData] = {}
|
|
113
127
|
self._queue: list[dict[str, Any]] = []
|
|
@@ -224,9 +238,16 @@ class III:
|
|
|
224
238
|
pass
|
|
225
239
|
|
|
226
240
|
# Reject all pending invocations
|
|
227
|
-
for invocation_id,
|
|
228
|
-
if not future.done():
|
|
229
|
-
future.set_exception(
|
|
241
|
+
for invocation_id, pending in list(self._pending.items()):
|
|
242
|
+
if not pending.future.done():
|
|
243
|
+
pending.future.set_exception(
|
|
244
|
+
IIIInvocationError(
|
|
245
|
+
code="SHUTDOWN",
|
|
246
|
+
message="iii is shutting down",
|
|
247
|
+
function_id=pending.function_id,
|
|
248
|
+
invocation_id=invocation_id,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
230
251
|
self._pending.clear()
|
|
231
252
|
|
|
232
253
|
if self._ws:
|
|
@@ -401,15 +422,21 @@ class III:
|
|
|
401
422
|
log.debug(f"Worker registered with ID: {worker_id}")
|
|
402
423
|
|
|
403
424
|
def _handle_result(self, invocation_id: str, result: Any, error: Any) -> None:
|
|
404
|
-
|
|
405
|
-
if not
|
|
425
|
+
pending = self._pending.pop(invocation_id, None)
|
|
426
|
+
if not pending:
|
|
406
427
|
log.debug(f"No pending invocation: {invocation_id}")
|
|
407
428
|
return
|
|
408
429
|
|
|
409
430
|
if error:
|
|
410
|
-
future.set_exception(
|
|
431
|
+
pending.future.set_exception(
|
|
432
|
+
_wrap_wire_error(
|
|
433
|
+
error,
|
|
434
|
+
function_id=pending.function_id,
|
|
435
|
+
invocation_id=invocation_id,
|
|
436
|
+
)
|
|
437
|
+
)
|
|
411
438
|
else:
|
|
412
|
-
future.set_result(result)
|
|
439
|
+
pending.future.set_result(result)
|
|
413
440
|
|
|
414
441
|
def _inject_traceparent(self) -> str | None:
|
|
415
442
|
from opentelemetry import context as otel_context
|
|
@@ -972,7 +999,9 @@ class III:
|
|
|
972
999
|
actions.
|
|
973
1000
|
|
|
974
1001
|
Raises:
|
|
975
|
-
|
|
1002
|
+
IIITimeoutError: If the invocation times out. ``code == 'TIMEOUT'``.
|
|
1003
|
+
IIIForbiddenError: If RBAC denies the invocation. ``code == 'FORBIDDEN'``.
|
|
1004
|
+
IIIInvocationError: For any other engine rejection.
|
|
976
1005
|
|
|
977
1006
|
Examples:
|
|
978
1007
|
>>> result = iii.trigger({'function_id': 'greet', 'payload': {'name': 'World'}})
|
|
@@ -997,7 +1026,9 @@ class III:
|
|
|
997
1026
|
The result of the function invocation, or ``None`` for void calls.
|
|
998
1027
|
|
|
999
1028
|
Raises:
|
|
1000
|
-
|
|
1029
|
+
IIITimeoutError: If the invocation times out. ``code == 'TIMEOUT'``.
|
|
1030
|
+
IIIForbiddenError: If RBAC denies the invocation. ``code == 'FORBIDDEN'``.
|
|
1031
|
+
IIIInvocationError: For any other engine rejection.
|
|
1001
1032
|
|
|
1002
1033
|
Examples:
|
|
1003
1034
|
>>> result = await iii.trigger_async({'function_id': 'greet', 'payload': {'name': 'World'}})
|
|
@@ -1035,7 +1066,9 @@ class III:
|
|
|
1035
1066
|
invocation_id = str(uuid.uuid4())
|
|
1036
1067
|
future: asyncio.Future[Any] = self._loop.create_future()
|
|
1037
1068
|
|
|
1038
|
-
self._pending[invocation_id] =
|
|
1069
|
+
self._pending[invocation_id] = _PendingInvocation(
|
|
1070
|
+
future=future, function_id=function_id
|
|
1071
|
+
)
|
|
1039
1072
|
|
|
1040
1073
|
enqueue_action: TriggerActionEnqueue | None = (
|
|
1041
1074
|
action if isinstance(action, TriggerActionEnqueue) else None
|
|
@@ -1056,8 +1089,11 @@ class III:
|
|
|
1056
1089
|
return await asyncio.wait_for(future, timeout=timeout_secs)
|
|
1057
1090
|
except asyncio.TimeoutError:
|
|
1058
1091
|
self._pending.pop(invocation_id, None)
|
|
1059
|
-
raise
|
|
1060
|
-
|
|
1092
|
+
raise IIITimeoutError(
|
|
1093
|
+
code="TIMEOUT",
|
|
1094
|
+
message=f"invocation timed out after {timeout_ms}ms",
|
|
1095
|
+
function_id=function_id,
|
|
1096
|
+
invocation_id=invocation_id,
|
|
1061
1097
|
)
|
|
1062
1098
|
|
|
1063
1099
|
def list_functions(self) -> list[FunctionInfo]:
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Unit tests for the typed invocation error hierarchy. No engine required."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from iii import IIIForbiddenError, IIIInvocationError, IIITimeoutError
|
|
8
|
+
from iii.errors import _wrap_wire_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestIIIInvocationError:
|
|
12
|
+
def test_exposes_all_fields(self) -> None:
|
|
13
|
+
err = IIIInvocationError(
|
|
14
|
+
code="FORBIDDEN",
|
|
15
|
+
message="function 'engine::functions::list' not allowed",
|
|
16
|
+
function_id="engine::functions::list",
|
|
17
|
+
stacktrace="trace here",
|
|
18
|
+
invocation_id="inv-123",
|
|
19
|
+
)
|
|
20
|
+
assert isinstance(err, Exception)
|
|
21
|
+
assert isinstance(err, IIIInvocationError)
|
|
22
|
+
assert err.code == "FORBIDDEN"
|
|
23
|
+
assert err.message == "function 'engine::functions::list' not allowed"
|
|
24
|
+
assert err.function_id == "engine::functions::list"
|
|
25
|
+
assert err.stacktrace == "trace here"
|
|
26
|
+
assert err.invocation_id == "inv-123"
|
|
27
|
+
|
|
28
|
+
def test_str_is_code_colon_message(self) -> None:
|
|
29
|
+
err = IIIInvocationError(
|
|
30
|
+
code="FORBIDDEN",
|
|
31
|
+
message="function 'X' not allowed (add to rbac.expose_functions)",
|
|
32
|
+
function_id="X",
|
|
33
|
+
)
|
|
34
|
+
assert str(err) == "FORBIDDEN: function 'X' not allowed (add to rbac.expose_functions)"
|
|
35
|
+
|
|
36
|
+
def test_str_never_looks_like_raw_dict_repr(self) -> None:
|
|
37
|
+
"""Guards against the original Node [object Object] equivalent."""
|
|
38
|
+
err = IIIInvocationError(code="FORBIDDEN", message="nope")
|
|
39
|
+
assert str(err) != "{'code': 'FORBIDDEN', 'message': 'nope'}"
|
|
40
|
+
assert str(err) != repr({"code": "FORBIDDEN", "message": "nope"})
|
|
41
|
+
|
|
42
|
+
def test_str_does_not_leak_stacktrace(self) -> None:
|
|
43
|
+
"""Stacktrace is opt-in via .stacktrace attribute; str/repr must not include it."""
|
|
44
|
+
err = IIIInvocationError(
|
|
45
|
+
code="HANDLER",
|
|
46
|
+
message="boom",
|
|
47
|
+
stacktrace="/internal/path/secrets.py:line 42",
|
|
48
|
+
)
|
|
49
|
+
assert "/internal/path/secrets.py" not in str(err)
|
|
50
|
+
assert "/internal/path/secrets.py" not in repr(err)
|
|
51
|
+
|
|
52
|
+
def test_supports_optional_fields(self) -> None:
|
|
53
|
+
err = IIIInvocationError(code="TIMEOUT", message="gone")
|
|
54
|
+
assert err.function_id is None
|
|
55
|
+
assert err.stacktrace is None
|
|
56
|
+
assert err.invocation_id is None
|
|
57
|
+
assert str(err) == "TIMEOUT: gone"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestSubclassHierarchy:
|
|
61
|
+
def test_forbidden_is_invocation_error(self) -> None:
|
|
62
|
+
err = IIIForbiddenError(code="FORBIDDEN", message="x")
|
|
63
|
+
assert isinstance(err, IIIInvocationError)
|
|
64
|
+
assert isinstance(err, IIIForbiddenError)
|
|
65
|
+
assert isinstance(err, Exception)
|
|
66
|
+
|
|
67
|
+
def test_timeout_is_invocation_error(self) -> None:
|
|
68
|
+
err = IIITimeoutError(code="TIMEOUT", message="x")
|
|
69
|
+
assert isinstance(err, IIIInvocationError)
|
|
70
|
+
assert isinstance(err, IIITimeoutError)
|
|
71
|
+
assert isinstance(err, Exception)
|
|
72
|
+
|
|
73
|
+
def test_except_ordering_catches_subclass_first(self) -> None:
|
|
74
|
+
"""`except IIIForbiddenError` fires before `except IIIInvocationError`."""
|
|
75
|
+
caught: str | None = None
|
|
76
|
+
try:
|
|
77
|
+
raise IIIForbiddenError(code="FORBIDDEN", message="x")
|
|
78
|
+
except IIIForbiddenError:
|
|
79
|
+
caught = "forbidden"
|
|
80
|
+
except IIIInvocationError:
|
|
81
|
+
caught = "base"
|
|
82
|
+
assert caught == "forbidden"
|
|
83
|
+
|
|
84
|
+
def test_base_catches_every_subclass(self) -> None:
|
|
85
|
+
for err in (
|
|
86
|
+
IIIForbiddenError(code="FORBIDDEN", message="x"),
|
|
87
|
+
IIITimeoutError(code="TIMEOUT", message="x"),
|
|
88
|
+
IIIInvocationError(code="UNKNOWN", message="x"),
|
|
89
|
+
):
|
|
90
|
+
try:
|
|
91
|
+
raise err
|
|
92
|
+
except IIIInvocationError as got:
|
|
93
|
+
assert got.code in {"FORBIDDEN", "TIMEOUT", "UNKNOWN"}
|
|
94
|
+
|
|
95
|
+
def test_except_exception_still_works(self) -> None:
|
|
96
|
+
"""Migration guarantee: existing `except Exception:` handlers still catch."""
|
|
97
|
+
try:
|
|
98
|
+
raise IIIForbiddenError(code="FORBIDDEN", message="x")
|
|
99
|
+
except Exception as got:
|
|
100
|
+
assert isinstance(got, IIIInvocationError)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestWrapWireError:
|
|
104
|
+
def test_forbidden_dict_dispatches_to_forbidden_error(self) -> None:
|
|
105
|
+
err = _wrap_wire_error(
|
|
106
|
+
{"code": "FORBIDDEN", "message": "not allowed"},
|
|
107
|
+
function_id="engine::functions::list",
|
|
108
|
+
invocation_id="inv-1",
|
|
109
|
+
)
|
|
110
|
+
assert isinstance(err, IIIForbiddenError)
|
|
111
|
+
assert err.code == "FORBIDDEN"
|
|
112
|
+
assert err.function_id == "engine::functions::list"
|
|
113
|
+
assert err.invocation_id == "inv-1"
|
|
114
|
+
|
|
115
|
+
def test_timeout_dict_dispatches_to_timeout_error(self) -> None:
|
|
116
|
+
err = _wrap_wire_error(
|
|
117
|
+
{"code": "TIMEOUT", "message": "gone"},
|
|
118
|
+
function_id="api::slow",
|
|
119
|
+
invocation_id=None,
|
|
120
|
+
)
|
|
121
|
+
assert isinstance(err, IIITimeoutError)
|
|
122
|
+
assert err.code == "TIMEOUT"
|
|
123
|
+
|
|
124
|
+
def test_unknown_code_falls_back_to_base(self) -> None:
|
|
125
|
+
err = _wrap_wire_error(
|
|
126
|
+
{"code": "BUSINESS_RULE", "message": "nope"},
|
|
127
|
+
function_id=None,
|
|
128
|
+
invocation_id=None,
|
|
129
|
+
)
|
|
130
|
+
assert type(err) is IIIInvocationError
|
|
131
|
+
assert err.code == "BUSINESS_RULE"
|
|
132
|
+
|
|
133
|
+
def test_stacktrace_propagated_when_string(self) -> None:
|
|
134
|
+
err = _wrap_wire_error(
|
|
135
|
+
{"code": "HANDLER", "message": "boom", "stacktrace": "trace"},
|
|
136
|
+
function_id=None,
|
|
137
|
+
invocation_id=None,
|
|
138
|
+
)
|
|
139
|
+
assert err.stacktrace == "trace"
|
|
140
|
+
|
|
141
|
+
@pytest.mark.parametrize(
|
|
142
|
+
"bad_error",
|
|
143
|
+
[
|
|
144
|
+
None,
|
|
145
|
+
"a plain string",
|
|
146
|
+
42,
|
|
147
|
+
{},
|
|
148
|
+
{"code": 123, "message": "x"},
|
|
149
|
+
{"code": "X"},
|
|
150
|
+
{"message": "no code"},
|
|
151
|
+
{"code": "X", "message": None},
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
def test_malformed_wire_errors_never_produce_raw_repr(self, bad_error: object) -> None:
|
|
155
|
+
"""Guards against stringified-dict regression for every pathological shape."""
|
|
156
|
+
err = _wrap_wire_error(bad_error, function_id="fn", invocation_id=None)
|
|
157
|
+
assert isinstance(err, IIIInvocationError)
|
|
158
|
+
assert str(err).startswith(("UNKNOWN:", "X:", "123:"))
|
|
159
|
+
assert "{'" not in str(err), f"dict repr leaked into message: {err!s}"
|
|
160
|
+
assert "': " not in str(err), f"dict repr leaked into message: {err!s}"
|
|
161
|
+
|
|
162
|
+
def test_non_string_stacktrace_ignored(self) -> None:
|
|
163
|
+
err = _wrap_wire_error(
|
|
164
|
+
{"code": "X", "message": "m", "stacktrace": 42},
|
|
165
|
+
function_id=None,
|
|
166
|
+
invocation_id=None,
|
|
167
|
+
)
|
|
168
|
+
assert err.stacktrace is None
|
|
@@ -8,6 +8,8 @@ import pytest
|
|
|
8
8
|
from iii import (
|
|
9
9
|
AuthInput,
|
|
10
10
|
AuthResult,
|
|
11
|
+
IIIForbiddenError,
|
|
12
|
+
IIIInvocationError,
|
|
11
13
|
InitOptions,
|
|
12
14
|
MiddlewareFunctionInput,
|
|
13
15
|
OnFunctionRegistrationInput,
|
|
@@ -339,3 +341,130 @@ class TestRbacWorkers:
|
|
|
339
341
|
assert result["echoed"]["msg"] == "prefix-test"
|
|
340
342
|
finally:
|
|
341
343
|
iii_client.shutdown()
|
|
344
|
+
|
|
345
|
+
def test_forbidden_wrapped_as_typed_error(self, iii_server):
|
|
346
|
+
"""FORBIDDEN rejections surface as IIIForbiddenError with function_id set
|
|
347
|
+
and the engine's remediation phrase in the message."""
|
|
348
|
+
iii_client = register_worker(
|
|
349
|
+
EW_URL,
|
|
350
|
+
InitOptions(otel={"enabled": False}, headers={"x-test-token": "valid-token"}),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
with pytest.raises(IIIForbiddenError) as excinfo:
|
|
355
|
+
iii_client.trigger({
|
|
356
|
+
"function_id": "test::ew::private",
|
|
357
|
+
"payload": {},
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
err = excinfo.value
|
|
361
|
+
assert isinstance(err, IIIInvocationError) # base class
|
|
362
|
+
assert err.code == "FORBIDDEN"
|
|
363
|
+
assert err.function_id == "test::ew::private"
|
|
364
|
+
assert "FORBIDDEN" in str(err)
|
|
365
|
+
assert "test::ew::private" in str(err)
|
|
366
|
+
# Remediation phrase from engine/src/engine/mod.rs:806
|
|
367
|
+
assert "rbac.expose_functions" in str(err)
|
|
368
|
+
# Guards against raw-dict regression (the Python equivalent of
|
|
369
|
+
# Node's `[object Object]`).
|
|
370
|
+
assert str(err) != repr({"code": "FORBIDDEN"})
|
|
371
|
+
finally:
|
|
372
|
+
iii_client.shutdown()
|
|
373
|
+
|
|
374
|
+
def test_restricted_handler_happy_path_under_infra_carveout(self, iii_server):
|
|
375
|
+
"""Regression guard for the engine-side infrastructure carve-out.
|
|
376
|
+
|
|
377
|
+
A worker whose `expose_functions` only lists `test::ew::*` must still
|
|
378
|
+
be able to complete registration (engine::workers::register) and run
|
|
379
|
+
SDK-transparent engine calls (engine::log::*, engine::baggage::*).
|
|
380
|
+
If the carve-out ever regresses, connection setup FORBIDDENs here.
|
|
381
|
+
"""
|
|
382
|
+
iii_client = register_worker(
|
|
383
|
+
EW_URL,
|
|
384
|
+
InitOptions(otel={"enabled": False}, headers={"x-test-token": "valid-token"}),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
# Successful invocation proves handshake completed without tripping
|
|
389
|
+
# FORBIDDEN on engine::workers::register (the transparent startup
|
|
390
|
+
# trigger) — otherwise the worker would not be reachable.
|
|
391
|
+
result = iii_client.trigger({
|
|
392
|
+
"function_id": "test::ew::valid-token-echo",
|
|
393
|
+
"payload": {"msg": "carveout-regression"},
|
|
394
|
+
})
|
|
395
|
+
assert result["valid_token"] is True
|
|
396
|
+
assert result["echoed"]["msg"] == "carveout-regression"
|
|
397
|
+
finally:
|
|
398
|
+
iii_client.shutdown()
|
|
399
|
+
|
|
400
|
+
# --- Infrastructure carve-out regression guards ---
|
|
401
|
+
#
|
|
402
|
+
# Lock in the engine-side INFRASTRUCTURE_FUNCTIONS carve-out end-to-end over
|
|
403
|
+
# a real WebSocket. Previously a worker whose allowed_functions /
|
|
404
|
+
# expose_functions did not cover `engine::*` IDs tripped FORBIDDEN the
|
|
405
|
+
# moment a handler used the SDK logger — the reporter's original bug.
|
|
406
|
+
# Paired with identical scenarios in
|
|
407
|
+
# sdk/packages/node/iii/tests/rbac-workers.test.ts and
|
|
408
|
+
# sdk/packages/rust/iii/tests/rbac_workers.rs.
|
|
409
|
+
|
|
410
|
+
def test_infrastructure_logger_callable_from_user_handler(self, iii_server):
|
|
411
|
+
"""Real usage case: restricted worker's user handler calls the SDK
|
|
412
|
+
logger during invocation.
|
|
413
|
+
|
|
414
|
+
Handler runs under `allowed_functions: ['test::ew::valid-token-echo']`
|
|
415
|
+
and internally hits `engine::log::info` — allowed only via the
|
|
416
|
+
carve-out, not the allow-list. If the carve-out regresses, the nested
|
|
417
|
+
invocation FORBIDDENs and the handler raises instead of returning
|
|
418
|
+
``{"logged": True}``.
|
|
419
|
+
"""
|
|
420
|
+
iii_client = register_worker(
|
|
421
|
+
EW_URL,
|
|
422
|
+
InitOptions(otel={"enabled": False}, headers={"x-test-token": "valid-token"}),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
def handler(data: dict) -> dict:
|
|
427
|
+
# If the carve-out regresses, this nested trigger surfaces
|
|
428
|
+
# IIIForbiddenError and the handler propagates it as a failure.
|
|
429
|
+
iii_client.trigger({
|
|
430
|
+
"function_id": "engine::log::info",
|
|
431
|
+
"payload": {
|
|
432
|
+
"message": "carve-out regression guard: handler reached logger",
|
|
433
|
+
"data": {"input": data},
|
|
434
|
+
},
|
|
435
|
+
})
|
|
436
|
+
return {"logged": True, "echoed": data}
|
|
437
|
+
|
|
438
|
+
iii_client.register_function("test::ew::valid-token-echo", handler)
|
|
439
|
+
time.sleep(0.5)
|
|
440
|
+
|
|
441
|
+
result = iii_server.trigger({
|
|
442
|
+
"function_id": "test::ew::valid-token-echo",
|
|
443
|
+
"payload": {"msg": "real-usage-case"},
|
|
444
|
+
})
|
|
445
|
+
assert result["logged"] is True
|
|
446
|
+
finally:
|
|
447
|
+
iii_client.shutdown()
|
|
448
|
+
|
|
449
|
+
def test_infrastructure_logger_directly_callable(self, iii_server):
|
|
450
|
+
"""Direct variant: restricted worker invokes ``engine::log::info``
|
|
451
|
+
straight from its client — mirrors a bootstrap script / CLI.
|
|
452
|
+
|
|
453
|
+
``engine::log::info`` is NOT in valid-token's allowed_functions, so
|
|
454
|
+
a successful trigger here proves the carve-out path is reachable from
|
|
455
|
+
the worker client's own ``trigger()`` method.
|
|
456
|
+
"""
|
|
457
|
+
iii_client = register_worker(
|
|
458
|
+
EW_URL,
|
|
459
|
+
InitOptions(otel={"enabled": False}, headers={"x-test-token": "valid-token"}),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
# No exception == carve-out is working. If this raises
|
|
464
|
+
# IIIForbiddenError, the carve-out regressed.
|
|
465
|
+
iii_client.trigger({
|
|
466
|
+
"function_id": "engine::log::info",
|
|
467
|
+
"payload": {"message": "carve-out direct invocation"},
|
|
468
|
+
})
|
|
469
|
+
finally:
|
|
470
|
+
iii_client.shutdown()
|
|
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
|
|
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
|