iii-sdk 0.11.3.dev1__tar.gz → 0.11.3.dev2__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 (57) hide show
  1. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/PKG-INFO +1 -1
  2. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/pyproject.toml +1 -1
  3. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/__init__.py +5 -0
  4. iii_sdk-0.11.3.dev2/src/iii/errors.py +88 -0
  5. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/iii.py +49 -13
  6. iii_sdk-0.11.3.dev2/tests/test_errors.py +168 -0
  7. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_rbac_workers.py +129 -0
  8. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/uv.lock +1 -1
  9. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/.gitignore +0 -0
  10. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/README.md +0 -0
  11. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/channels.py +0 -0
  12. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/format_utils.py +0 -0
  13. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/iii_constants.py +0 -0
  14. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/iii_types.py +0 -0
  15. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/logger.py +0 -0
  16. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/otel_worker_gauges.py +0 -0
  17. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/state.py +0 -0
  18. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/stream.py +0 -0
  19. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/telemetry.py +0 -0
  20. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/telemetry_exporters.py +0 -0
  21. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/telemetry_types.py +0 -0
  22. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/triggers.py +0 -0
  23. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/types.py +0 -0
  24. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/utils.py +0 -0
  25. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/src/iii/worker_metrics.py +0 -0
  26. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/conftest.py +0 -0
  27. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_api_triggers.py +0 -0
  28. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_async_api.py +0 -0
  29. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_bridge.py +0 -0
  30. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_channel_close_delay.py +0 -0
  31. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_context_propagation.py +0 -0
  32. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_data_channels.py +0 -0
  33. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_format_utils.py +0 -0
  34. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_healthcheck.py +0 -0
  35. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_hold_process.py +0 -0
  36. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_http_external_functions_integration.py +0 -0
  37. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_iii_registration_dedup.py +0 -0
  38. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_init_api.py +0 -0
  39. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_invocation_exception.py +0 -0
  40. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_logger_function_ids.py +0 -0
  41. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_logger_otel.py +0 -0
  42. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_middleware.py +0 -0
  43. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_pubsub.py +0 -0
  44. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_queue_integration.py +0 -0
  45. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_register_function_args.py +0 -0
  46. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_state.py +0 -0
  47. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_streams.py +0 -0
  48. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_streams_runtime_annotations.py +0 -0
  49. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_sync_api.py +0 -0
  50. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_telemetry.py +0 -0
  51. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_telemetry_exporters.py +0 -0
  52. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_telemetry_types.py +0 -0
  53. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_trace_helpers.py +0 -0
  54. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_trigger_metadata.py +0 -0
  55. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_utils.py +0 -0
  56. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_worker_metadata.py +0 -0
  57. {iii_sdk-0.11.3.dev1 → iii_sdk-0.11.3.dev2}/tests/test_worker_metrics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iii-sdk
3
- Version: 0.11.3.dev1
3
+ Version: 0.11.3.dev2
4
4
  Summary: III SDK for Python
5
5
  Project-URL: Homepage, https://github.com/iii-hq/sdk
6
6
  Project-URL: Repository, https://github.com/iii-hq/sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "iii-sdk"
7
- version = "0.11.3.dev1"
7
+ version = "0.11.3.dev2"
8
8
  description = "III SDK for Python"
9
9
  authors = [{ name = "III" }]
10
10
  license = { text = "Apache-2.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, asyncio.Future[Any]] = {}
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, future in list(self._pending.items()):
228
- if not future.done():
229
- future.set_exception(Exception("iii is shutting down"))
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
- future = self._pending.pop(invocation_id, None)
405
- if not future:
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(Exception(str(error)))
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
- TimeoutError: If the invocation times out.
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
- TimeoutError: If the invocation times out.
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] = future
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 TimeoutError(
1060
- f"Invocation of '{function_id}' timed out after {timeout_ms}ms"
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()
@@ -500,7 +500,7 @@ wheels = [
500
500
 
501
501
  [[package]]
502
502
  name = "iii-sdk"
503
- version = "0.11.0.dev9"
503
+ version = "0.11.2"
504
504
  source = { editable = "." }
505
505
  dependencies = [
506
506
  { name = "opentelemetry-api" },
File without changes
File without changes