rpc-python-toolkit 0.2.0__tar.gz → 0.2.1__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.
- {rpc_python_toolkit-0.2.0/src/rpc_python_toolkit.egg-info → rpc_python_toolkit-0.2.1}/PKG-INFO +30 -1
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/README.md +29 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/pyproject.toml +1 -1
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/endpoint.py +21 -15
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/protocol.py +11 -4
- rpc_python_toolkit-0.2.1/src/rpc_python_toolkit/version.py +1 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1/src/rpc_python_toolkit.egg-info}/PKG-INFO +30 -1
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/SOURCES.txt +3 -1
- rpc_python_toolkit-0.2.1/tests/test_errors.py +159 -0
- rpc_python_toolkit-0.2.1/tests/test_protocol.py +111 -0
- rpc_python_toolkit-0.2.0/src/rpc_python_toolkit/version.py +0 -1
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/LICENSE +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/setup.cfg +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/__init__.py +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/client.py +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/errors.py +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/serialization.py +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/validation.py +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/dependency_links.txt +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/requires.txt +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/top_level.txt +0 -0
- {rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/tests/test_core.py +0 -0
{rpc_python_toolkit-0.2.0/src/rpc_python_toolkit.egg-info → rpc_python_toolkit-0.2.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rpc-python-toolkit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Framework-agnostic Python JSON-RPC 2.0 toolkit with Safe Mode interoperability.
|
|
5
5
|
Author: Nicola Carpanese
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,6 +44,15 @@ Alpha package with the core JSON-RPC behavior implemented.
|
|
|
44
44
|
- Safe Mode follows the same `X-RPC-Safe-Enabled` and value marker conventions used by `rpc-express-toolkit`.
|
|
45
45
|
- The public API may still change before a stable Python release.
|
|
46
46
|
|
|
47
|
+
## Release Notes
|
|
48
|
+
|
|
49
|
+
### 0.2.1 - 2026-10-12
|
|
50
|
+
|
|
51
|
+
- Sanitize unexpected errors and `RpcError(-32603)` by default, removing internal messages and data while preserving explicit application errors.
|
|
52
|
+
- Validate request envelopes and reject non-JSON numeric constants, scalar/null parameters, boolean IDs, and non-finite IDs.
|
|
53
|
+
- Return errors for invalid batch elements, distinguish explicit null IDs from notifications, and preserve JSON payload shapes.
|
|
54
|
+
- Add error-handling and protocol regression tests, and document the tightened wire contract and trusted handler context.
|
|
55
|
+
|
|
47
56
|
## Installation
|
|
48
57
|
|
|
49
58
|
Install from PyPI:
|
|
@@ -108,10 +117,26 @@ print(response.body)
|
|
|
108
117
|
```
|
|
109
118
|
|
|
110
119
|
Batch can be disabled with `enable_batch=False`. Introspection remains opt-in.
|
|
120
|
+
Unexpected errors and `RpcError` instances with code `-32603` return only
|
|
121
|
+
`Internal error` by default, without internal messages or data. Other explicit
|
|
122
|
+
`RpcError` codes, messages, and data are preserved. Parse errors omit diagnostic
|
|
123
|
+
data. Use `sanitize_errors=False` only for local debugging to restore details.
|
|
124
|
+
|
|
125
|
+
Only valid requests without an `id` are notifications. An explicit `id: null`
|
|
126
|
+
receives a response; malformed envelopes (including scalar/null `params` or
|
|
127
|
+
boolean/non-finite IDs) return `-32600` with `id: null`. Method parameter schema
|
|
128
|
+
errors remain `-32602`.
|
|
129
|
+
Malformed JSON, including the non-JSON constants `NaN`, `Infinity`, and `-Infinity`,
|
|
130
|
+
returns `-32700`. Invalid elements in a batch produce individual error responses;
|
|
131
|
+
valid notifications remain silent even when their method fails. Objects, arrays,
|
|
132
|
+
and null results retain their JSON shapes.
|
|
133
|
+
|
|
111
134
|
When both application and request contexts are dictionaries, handlers receive a
|
|
112
135
|
merged dictionary and request values take precedence. For a non-dictionary
|
|
113
136
|
application context plus request data, handlers receive `RpcCallContext` with
|
|
114
137
|
separate `application` and `request` attributes.
|
|
138
|
+
These contexts are supplied by application/adapter code. Client `params` remain a
|
|
139
|
+
separate third handler argument and cannot replace trusted context.
|
|
115
140
|
|
|
116
141
|
Result:
|
|
117
142
|
|
|
@@ -147,6 +172,10 @@ Safe Mode enables `X-RPC-Safe-Enabled` negotiation and recursive value encoding/
|
|
|
147
172
|
|
|
148
173
|
Python exposes arbitrary precision integers as `int`. BigInt marker strings from other RPC Toolkit implementations are decoded to Python `int` values when they are real markers, while literal marker-like strings are protected by the `S:` prefix in Safe Mode.
|
|
149
174
|
|
|
175
|
+
Safe Mode transforms payload values, not method names, request IDs, error codes,
|
|
176
|
+
or error messages. Application `RpcError.data` is encoded like a result; sanitized
|
|
177
|
+
internal errors expose neither their original message nor their data.
|
|
178
|
+
|
|
150
179
|
## Examples
|
|
151
180
|
|
|
152
181
|
Runnable examples are available in [`examples/`](examples/):
|
|
@@ -19,6 +19,15 @@ Alpha package with the core JSON-RPC behavior implemented.
|
|
|
19
19
|
- Safe Mode follows the same `X-RPC-Safe-Enabled` and value marker conventions used by `rpc-express-toolkit`.
|
|
20
20
|
- The public API may still change before a stable Python release.
|
|
21
21
|
|
|
22
|
+
## Release Notes
|
|
23
|
+
|
|
24
|
+
### 0.2.1 - 2026-10-12
|
|
25
|
+
|
|
26
|
+
- Sanitize unexpected errors and `RpcError(-32603)` by default, removing internal messages and data while preserving explicit application errors.
|
|
27
|
+
- Validate request envelopes and reject non-JSON numeric constants, scalar/null parameters, boolean IDs, and non-finite IDs.
|
|
28
|
+
- Return errors for invalid batch elements, distinguish explicit null IDs from notifications, and preserve JSON payload shapes.
|
|
29
|
+
- Add error-handling and protocol regression tests, and document the tightened wire contract and trusted handler context.
|
|
30
|
+
|
|
22
31
|
## Installation
|
|
23
32
|
|
|
24
33
|
Install from PyPI:
|
|
@@ -83,10 +92,26 @@ print(response.body)
|
|
|
83
92
|
```
|
|
84
93
|
|
|
85
94
|
Batch can be disabled with `enable_batch=False`. Introspection remains opt-in.
|
|
95
|
+
Unexpected errors and `RpcError` instances with code `-32603` return only
|
|
96
|
+
`Internal error` by default, without internal messages or data. Other explicit
|
|
97
|
+
`RpcError` codes, messages, and data are preserved. Parse errors omit diagnostic
|
|
98
|
+
data. Use `sanitize_errors=False` only for local debugging to restore details.
|
|
99
|
+
|
|
100
|
+
Only valid requests without an `id` are notifications. An explicit `id: null`
|
|
101
|
+
receives a response; malformed envelopes (including scalar/null `params` or
|
|
102
|
+
boolean/non-finite IDs) return `-32600` with `id: null`. Method parameter schema
|
|
103
|
+
errors remain `-32602`.
|
|
104
|
+
Malformed JSON, including the non-JSON constants `NaN`, `Infinity`, and `-Infinity`,
|
|
105
|
+
returns `-32700`. Invalid elements in a batch produce individual error responses;
|
|
106
|
+
valid notifications remain silent even when their method fails. Objects, arrays,
|
|
107
|
+
and null results retain their JSON shapes.
|
|
108
|
+
|
|
86
109
|
When both application and request contexts are dictionaries, handlers receive a
|
|
87
110
|
merged dictionary and request values take precedence. For a non-dictionary
|
|
88
111
|
application context plus request data, handlers receive `RpcCallContext` with
|
|
89
112
|
separate `application` and `request` attributes.
|
|
113
|
+
These contexts are supplied by application/adapter code. Client `params` remain a
|
|
114
|
+
separate third handler argument and cannot replace trusted context.
|
|
90
115
|
|
|
91
116
|
Result:
|
|
92
117
|
|
|
@@ -122,6 +147,10 @@ Safe Mode enables `X-RPC-Safe-Enabled` negotiation and recursive value encoding/
|
|
|
122
147
|
|
|
123
148
|
Python exposes arbitrary precision integers as `int`. BigInt marker strings from other RPC Toolkit implementations are decoded to Python `int` values when they are real markers, while literal marker-like strings are protected by the `S:` prefix in Safe Mode.
|
|
124
149
|
|
|
150
|
+
Safe Mode transforms payload values, not method names, request IDs, error codes,
|
|
151
|
+
or error messages. Application `RpcError.data` is encoded like a result; sanitized
|
|
152
|
+
internal errors expose neither their original message nor their data.
|
|
153
|
+
|
|
125
154
|
## Examples
|
|
126
155
|
|
|
127
156
|
Runnable examples are available in [`examples/`](examples/):
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rpc-python-toolkit"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Framework-agnostic Python JSON-RPC 2.0 toolkit with Safe Mode interoperability."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -57,6 +57,7 @@ class RpcEndpoint:
|
|
|
57
57
|
max_batch_size: int = 100,
|
|
58
58
|
max_serialization_depth: int = 100,
|
|
59
59
|
max_deserialization_depth: int = 100,
|
|
60
|
+
sanitize_errors: bool = True,
|
|
60
61
|
):
|
|
61
62
|
self.context = context
|
|
62
63
|
self.methods: dict[str, MethodConfig] = {}
|
|
@@ -68,6 +69,7 @@ class RpcEndpoint:
|
|
|
68
69
|
self.max_batch_size = max_batch_size
|
|
69
70
|
self.max_serialization_depth = max_serialization_depth
|
|
70
71
|
self.max_deserialization_depth = max_deserialization_depth
|
|
72
|
+
self.sanitize_errors = sanitize_errors
|
|
71
73
|
self.validator = SchemaValidator()
|
|
72
74
|
|
|
73
75
|
if self.enable_introspection:
|
|
@@ -117,17 +119,13 @@ class RpcEndpoint:
|
|
|
117
119
|
try:
|
|
118
120
|
request = self._decode_payload(payload)
|
|
119
121
|
except Exception as exc:
|
|
122
|
+
error = {"code": -32700, "message": "Parse error"}
|
|
123
|
+
if not self.sanitize_errors:
|
|
124
|
+
error["data"] = {"message": str(exc)}
|
|
120
125
|
return RpcHttpResponse(
|
|
121
126
|
200,
|
|
122
127
|
headers,
|
|
123
|
-
make_response(
|
|
124
|
-
None,
|
|
125
|
-
error={
|
|
126
|
-
"code": -32700,
|
|
127
|
-
"message": "Parse error",
|
|
128
|
-
"data": {"message": str(exc)},
|
|
129
|
-
},
|
|
130
|
-
),
|
|
128
|
+
make_response(None, error=error),
|
|
131
129
|
)
|
|
132
130
|
|
|
133
131
|
request_context = context or {}
|
|
@@ -211,8 +209,12 @@ class RpcEndpoint:
|
|
|
211
209
|
return RpcHttpResponse(200, headers, responses)
|
|
212
210
|
|
|
213
211
|
def _process_single(self, request: Any, context: dict[str, Any]) -> dict[str, Any] | None:
|
|
212
|
+
# An invalid envelope is not a notification, even when it has no id.
|
|
213
|
+
# Do not echo an untrusted id until the complete envelope is validated.
|
|
214
|
+
valid_request = False
|
|
214
215
|
try:
|
|
215
216
|
validate_request_envelope(request)
|
|
217
|
+
valid_request = True
|
|
216
218
|
client_safe_enabled = self._client_safe_enabled(context)
|
|
217
219
|
|
|
218
220
|
if self.safe_enabled and self.strict_mode and not self._has_safe_header(context):
|
|
@@ -238,23 +240,24 @@ class RpcEndpoint:
|
|
|
238
240
|
result=self.serialize_bigints_and_dates(result),
|
|
239
241
|
)
|
|
240
242
|
except RpcError as exc:
|
|
241
|
-
if
|
|
243
|
+
if valid_request and not has_id(request):
|
|
242
244
|
return None
|
|
243
|
-
request_id = request.get("id") if
|
|
245
|
+
request_id = request.get("id") if valid_request else None
|
|
244
246
|
return self._make_error_response(request_id, exc)
|
|
245
247
|
except Exception as exc:
|
|
246
|
-
if
|
|
248
|
+
if valid_request and not has_id(request):
|
|
247
249
|
return None
|
|
248
|
-
request_id = request.get("id") if
|
|
250
|
+
request_id = request.get("id") if valid_request else None
|
|
249
251
|
return self._make_error_response(request_id, RpcError(str(exc), -32603))
|
|
250
252
|
|
|
251
253
|
def _make_error_response(self, request_id: Any, error: RpcError) -> dict[str, Any]:
|
|
254
|
+
internal_error = self.sanitize_errors and error.code == -32603
|
|
252
255
|
body = {
|
|
253
256
|
"code": error.code,
|
|
254
|
-
"message": str(error),
|
|
257
|
+
"message": "Internal error" if internal_error else str(error),
|
|
255
258
|
}
|
|
256
259
|
|
|
257
|
-
if error.data is not None:
|
|
260
|
+
if not internal_error and error.data is not None:
|
|
258
261
|
body["data"] = self.serialize_bigints_and_dates(error.data)
|
|
259
262
|
|
|
260
263
|
return make_response(request_id, error=body)
|
|
@@ -339,7 +342,10 @@ class RpcEndpoint:
|
|
|
339
342
|
payload = payload.decode("utf-8")
|
|
340
343
|
|
|
341
344
|
if isinstance(payload, str):
|
|
342
|
-
|
|
345
|
+
def reject_non_json_constant(value: str) -> None:
|
|
346
|
+
raise ValueError(f"Invalid JSON constant: {value}")
|
|
347
|
+
|
|
348
|
+
return json.loads(payload, parse_constant=reject_non_json_constant)
|
|
343
349
|
|
|
344
350
|
return payload
|
|
345
351
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
from .errors import RpcError
|
|
@@ -35,8 +36,14 @@ def validate_request_envelope(request: Any) -> None:
|
|
|
35
36
|
raise RpcError("Method not specified or invalid", -32600)
|
|
36
37
|
|
|
37
38
|
if "params" in request and not isinstance(request["params"], (dict, list)):
|
|
38
|
-
raise RpcError("Invalid params", -
|
|
39
|
-
|
|
40
|
-
if "id" in request
|
|
41
|
-
|
|
39
|
+
raise RpcError("Invalid params container", -32600)
|
|
40
|
+
|
|
41
|
+
if "id" in request:
|
|
42
|
+
request_id = request["id"]
|
|
43
|
+
if (
|
|
44
|
+
isinstance(request_id, bool)
|
|
45
|
+
or not isinstance(request_id, (str, int, float, type(None)))
|
|
46
|
+
or (isinstance(request_id, float) and not math.isfinite(request_id))
|
|
47
|
+
):
|
|
48
|
+
raise RpcError("Invalid request id", -32600)
|
|
42
49
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
{rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1/src/rpc_python_toolkit.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rpc-python-toolkit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Framework-agnostic Python JSON-RPC 2.0 toolkit with Safe Mode interoperability.
|
|
5
5
|
Author: Nicola Carpanese
|
|
6
6
|
License-Expression: MIT
|
|
@@ -44,6 +44,15 @@ Alpha package with the core JSON-RPC behavior implemented.
|
|
|
44
44
|
- Safe Mode follows the same `X-RPC-Safe-Enabled` and value marker conventions used by `rpc-express-toolkit`.
|
|
45
45
|
- The public API may still change before a stable Python release.
|
|
46
46
|
|
|
47
|
+
## Release Notes
|
|
48
|
+
|
|
49
|
+
### 0.2.1 - 2026-10-12
|
|
50
|
+
|
|
51
|
+
- Sanitize unexpected errors and `RpcError(-32603)` by default, removing internal messages and data while preserving explicit application errors.
|
|
52
|
+
- Validate request envelopes and reject non-JSON numeric constants, scalar/null parameters, boolean IDs, and non-finite IDs.
|
|
53
|
+
- Return errors for invalid batch elements, distinguish explicit null IDs from notifications, and preserve JSON payload shapes.
|
|
54
|
+
- Add error-handling and protocol regression tests, and document the tightened wire contract and trusted handler context.
|
|
55
|
+
|
|
47
56
|
## Installation
|
|
48
57
|
|
|
49
58
|
Install from PyPI:
|
|
@@ -108,10 +117,26 @@ print(response.body)
|
|
|
108
117
|
```
|
|
109
118
|
|
|
110
119
|
Batch can be disabled with `enable_batch=False`. Introspection remains opt-in.
|
|
120
|
+
Unexpected errors and `RpcError` instances with code `-32603` return only
|
|
121
|
+
`Internal error` by default, without internal messages or data. Other explicit
|
|
122
|
+
`RpcError` codes, messages, and data are preserved. Parse errors omit diagnostic
|
|
123
|
+
data. Use `sanitize_errors=False` only for local debugging to restore details.
|
|
124
|
+
|
|
125
|
+
Only valid requests without an `id` are notifications. An explicit `id: null`
|
|
126
|
+
receives a response; malformed envelopes (including scalar/null `params` or
|
|
127
|
+
boolean/non-finite IDs) return `-32600` with `id: null`. Method parameter schema
|
|
128
|
+
errors remain `-32602`.
|
|
129
|
+
Malformed JSON, including the non-JSON constants `NaN`, `Infinity`, and `-Infinity`,
|
|
130
|
+
returns `-32700`. Invalid elements in a batch produce individual error responses;
|
|
131
|
+
valid notifications remain silent even when their method fails. Objects, arrays,
|
|
132
|
+
and null results retain their JSON shapes.
|
|
133
|
+
|
|
111
134
|
When both application and request contexts are dictionaries, handlers receive a
|
|
112
135
|
merged dictionary and request values take precedence. For a non-dictionary
|
|
113
136
|
application context plus request data, handlers receive `RpcCallContext` with
|
|
114
137
|
separate `application` and `request` attributes.
|
|
138
|
+
These contexts are supplied by application/adapter code. Client `params` remain a
|
|
139
|
+
separate third handler argument and cannot replace trusted context.
|
|
115
140
|
|
|
116
141
|
Result:
|
|
117
142
|
|
|
@@ -147,6 +172,10 @@ Safe Mode enables `X-RPC-Safe-Enabled` negotiation and recursive value encoding/
|
|
|
147
172
|
|
|
148
173
|
Python exposes arbitrary precision integers as `int`. BigInt marker strings from other RPC Toolkit implementations are decoded to Python `int` values when they are real markers, while literal marker-like strings are protected by the `S:` prefix in Safe Mode.
|
|
149
174
|
|
|
175
|
+
Safe Mode transforms payload values, not method names, request IDs, error codes,
|
|
176
|
+
or error messages. Application `RpcError.data` is encoded like a result; sanitized
|
|
177
|
+
internal errors expose neither their original message nor their data.
|
|
178
|
+
|
|
150
179
|
## Examples
|
|
151
180
|
|
|
152
181
|
Runnable examples are available in [`examples/`](examples/):
|
{rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/SOURCES.txt
RENAMED
|
@@ -14,4 +14,6 @@ src/rpc_python_toolkit.egg-info/SOURCES.txt
|
|
|
14
14
|
src/rpc_python_toolkit.egg-info/dependency_links.txt
|
|
15
15
|
src/rpc_python_toolkit.egg-info/requires.txt
|
|
16
16
|
src/rpc_python_toolkit.egg-info/top_level.txt
|
|
17
|
-
tests/test_core.py
|
|
17
|
+
tests/test_core.py
|
|
18
|
+
tests/test_errors.py
|
|
19
|
+
tests/test_protocol.py
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from rpc_python_toolkit import RpcEndpoint, RpcError, RpcSafeEndpoint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ErrorSanitizationTests(unittest.TestCase):
|
|
10
|
+
def test_unexpected_handler_error_is_generic_even_with_code_and_data(self):
|
|
11
|
+
rpc = RpcEndpoint()
|
|
12
|
+
failure = RuntimeError("Synthetic private configuration detail")
|
|
13
|
+
failure.code = -32042
|
|
14
|
+
failure.data = {"path": "/private/example/config.ini"}
|
|
15
|
+
|
|
16
|
+
def fail(_request, _context, _params):
|
|
17
|
+
raise failure
|
|
18
|
+
|
|
19
|
+
rpc.add_method("fail", fail)
|
|
20
|
+
|
|
21
|
+
response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 1})
|
|
22
|
+
|
|
23
|
+
self.assertEqual(response.status, 200)
|
|
24
|
+
self.assertEqual(
|
|
25
|
+
response.body,
|
|
26
|
+
{
|
|
27
|
+
"jsonrpc": "2.0",
|
|
28
|
+
"id": 1,
|
|
29
|
+
"error": {"code": -32603, "message": "Internal error"},
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def test_explicit_internal_error_also_hides_message_and_data(self):
|
|
34
|
+
rpc = RpcEndpoint()
|
|
35
|
+
|
|
36
|
+
def fail(_request, _context, _params):
|
|
37
|
+
raise RpcError("Synthetic database detail", -32603, {"path": "/private/example"})
|
|
38
|
+
|
|
39
|
+
rpc.add_method("fail", fail)
|
|
40
|
+
|
|
41
|
+
response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 2})
|
|
42
|
+
|
|
43
|
+
self.assertEqual(response.body["error"], {"code": -32603, "message": "Internal error"})
|
|
44
|
+
|
|
45
|
+
def test_explicit_application_error_preserves_code_message_and_data(self):
|
|
46
|
+
rpc = RpcEndpoint()
|
|
47
|
+
|
|
48
|
+
def fail(_request, _context, _params):
|
|
49
|
+
raise RpcError("Quota exceeded", -32042, {"limit": 10})
|
|
50
|
+
|
|
51
|
+
rpc.add_method("fail", fail)
|
|
52
|
+
|
|
53
|
+
response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 3})
|
|
54
|
+
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
response.body["error"],
|
|
57
|
+
{"code": -32042, "message": "Quota exceeded", "data": {"limit": 10}},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def test_batch_sanitizes_errors_without_responding_to_failed_notifications(self):
|
|
61
|
+
rpc = RpcEndpoint()
|
|
62
|
+
|
|
63
|
+
def fail(_request, _context, _params):
|
|
64
|
+
raise RuntimeError("Synthetic batch detail")
|
|
65
|
+
|
|
66
|
+
rpc.add_method("fail", fail)
|
|
67
|
+
rpc.add_method("echo", lambda _request, _context, params: params)
|
|
68
|
+
|
|
69
|
+
response = rpc.handle_payload(
|
|
70
|
+
[
|
|
71
|
+
{"jsonrpc": "2.0", "method": "fail", "id": 1},
|
|
72
|
+
{"jsonrpc": "2.0", "method": "fail"},
|
|
73
|
+
{"jsonrpc": "2.0", "method": "echo", "params": [1, 2], "id": 2},
|
|
74
|
+
]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self.assertEqual(
|
|
78
|
+
response.body,
|
|
79
|
+
[
|
|
80
|
+
{"jsonrpc": "2.0", "id": 1, "error": {"code": -32603, "message": "Internal error"}},
|
|
81
|
+
{"jsonrpc": "2.0", "id": 2, "result": [1, 2]},
|
|
82
|
+
],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def test_unexpected_outer_error_is_sanitized(self):
|
|
86
|
+
rpc = RpcEndpoint()
|
|
87
|
+
|
|
88
|
+
with patch.object(rpc, "_process_batch", side_effect=RuntimeError("Synthetic adapter detail")):
|
|
89
|
+
response = rpc.handle_payload([{"jsonrpc": "2.0", "method": "echo", "id": 1}])
|
|
90
|
+
|
|
91
|
+
self.assertEqual(
|
|
92
|
+
response.body,
|
|
93
|
+
{"jsonrpc": "2.0", "id": None, "error": {"code": -32603, "message": "Internal error"}},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def test_unexpected_validation_error_is_sanitized(self):
|
|
97
|
+
rpc = RpcEndpoint()
|
|
98
|
+
rpc.add_method("echo", lambda _request, _context, params: params)
|
|
99
|
+
|
|
100
|
+
with patch.object(rpc.validator, "validate", side_effect=RuntimeError("Synthetic validator detail")):
|
|
101
|
+
response = rpc.handle_payload({"jsonrpc": "2.0", "method": "echo", "id": 1})
|
|
102
|
+
|
|
103
|
+
self.assertEqual(response.body["error"], {"code": -32603, "message": "Internal error"})
|
|
104
|
+
|
|
105
|
+
def test_parse_error_omits_diagnostic_data_by_default(self):
|
|
106
|
+
response = RpcEndpoint().handle_payload("{")
|
|
107
|
+
|
|
108
|
+
self.assertEqual(response.body["error"], {"code": -32700, "message": "Parse error"})
|
|
109
|
+
|
|
110
|
+
debug_response = RpcEndpoint(sanitize_errors=False).handle_payload("{")
|
|
111
|
+
self.assertEqual(debug_response.body["error"]["code"], -32700)
|
|
112
|
+
self.assertTrue(debug_response.body["error"]["data"]["message"])
|
|
113
|
+
|
|
114
|
+
def test_debug_option_preserves_internal_details(self):
|
|
115
|
+
for failure, expected in [
|
|
116
|
+
(RuntimeError("Synthetic debug detail"), {"code": -32603, "message": "Synthetic debug detail"}),
|
|
117
|
+
(
|
|
118
|
+
RpcError("Synthetic debug detail", -32603, {"path": "/private/example"}),
|
|
119
|
+
{"code": -32603, "message": "Synthetic debug detail", "data": {"path": "/private/example"}},
|
|
120
|
+
),
|
|
121
|
+
]:
|
|
122
|
+
with self.subTest(error_type=type(failure).__name__):
|
|
123
|
+
rpc = RpcEndpoint(sanitize_errors=False)
|
|
124
|
+
|
|
125
|
+
def fail(_request, _context, _params):
|
|
126
|
+
raise failure
|
|
127
|
+
|
|
128
|
+
rpc.add_method("fail", fail)
|
|
129
|
+
response = rpc.handle_payload({"jsonrpc": "2.0", "method": "fail", "id": 1})
|
|
130
|
+
|
|
131
|
+
self.assertEqual(response.body["error"], expected)
|
|
132
|
+
|
|
133
|
+
def test_safe_mode_preserves_application_data_and_sanitizes_internal_errors(self):
|
|
134
|
+
rpc = RpcSafeEndpoint()
|
|
135
|
+
|
|
136
|
+
def fail(_request, _context, params):
|
|
137
|
+
if params["internal"]:
|
|
138
|
+
raise RuntimeError("Synthetic Safe Mode detail")
|
|
139
|
+
raise RpcError("Quota exceeded", -32042, {"reason": "limit"})
|
|
140
|
+
|
|
141
|
+
rpc.add_method("fail", fail)
|
|
142
|
+
|
|
143
|
+
response = rpc.handle_payload(
|
|
144
|
+
[
|
|
145
|
+
{"jsonrpc": "2.0", "method": "fail", "params": {"internal": False}, "id": 1},
|
|
146
|
+
{"jsonrpc": "2.0", "method": "fail", "params": {"internal": True}, "id": 2},
|
|
147
|
+
],
|
|
148
|
+
{"headers": {"x-rpc-safe-enabled": "true"}},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
self.assertEqual(
|
|
152
|
+
response.body[0]["error"],
|
|
153
|
+
{"code": -32042, "message": "Quota exceeded", "data": {"reason": "S:limit"}},
|
|
154
|
+
)
|
|
155
|
+
self.assertEqual(response.body[1]["error"], {"code": -32603, "message": "Internal error"})
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
unittest.main()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from rpc_python_toolkit import RpcEndpoint, RpcSafeEndpoint
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProtocolTests(unittest.TestCase):
|
|
9
|
+
def setUp(self):
|
|
10
|
+
self.rpc = RpcEndpoint()
|
|
11
|
+
self.calls = []
|
|
12
|
+
self.rpc.add_method("echo", self.echo)
|
|
13
|
+
|
|
14
|
+
def echo(self, _request, _context, params):
|
|
15
|
+
self.calls.append(params)
|
|
16
|
+
return params
|
|
17
|
+
|
|
18
|
+
def test_invalid_envelopes_are_errors_not_notifications(self):
|
|
19
|
+
for request in [None, 1, True, "echo", {}, {"method": "echo"},
|
|
20
|
+
{"jsonrpc": "1.0", "method": "echo"},
|
|
21
|
+
{"jsonrpc": "2.0", "method": True}]:
|
|
22
|
+
with self.subTest(request=request):
|
|
23
|
+
response = self.rpc.handle_payload(request)
|
|
24
|
+
self.assertEqual(response.status, 200)
|
|
25
|
+
# A bare string is decoded as JSON; exercise structured invalid
|
|
26
|
+
# envelopes separately from JSON parse failures.
|
|
27
|
+
expected_code = -32700 if request == "echo" else -32600
|
|
28
|
+
self.assertEqual(response.body["error"]["code"], expected_code)
|
|
29
|
+
self.assertIsNone(response.body["id"])
|
|
30
|
+
self.assertEqual(self.calls, [])
|
|
31
|
+
|
|
32
|
+
def test_rejects_invalid_ids_without_echoing_them(self):
|
|
33
|
+
for request_id in [True, False, {}, [], float("nan"), float("inf"), -float("inf")]:
|
|
34
|
+
with self.subTest(request_id=request_id):
|
|
35
|
+
response = self.rpc.handle_payload(
|
|
36
|
+
{"jsonrpc": "2.0", "method": "echo", "id": request_id}
|
|
37
|
+
)
|
|
38
|
+
self.assertEqual(response.body["error"]["code"], -32600)
|
|
39
|
+
self.assertIsNone(response.body["id"])
|
|
40
|
+
self.assertEqual(self.calls, [])
|
|
41
|
+
|
|
42
|
+
def test_non_json_constants_on_wire_are_parse_errors(self):
|
|
43
|
+
for token in ["NaN", "Infinity", "-Infinity"]:
|
|
44
|
+
for member in ['"params":{"value":' + token + '}', '"id":' + token]:
|
|
45
|
+
with self.subTest(token=token, member=member):
|
|
46
|
+
response = self.rpc.handle_payload(
|
|
47
|
+
'{"jsonrpc":"2.0","method":"echo",' + member + '}'
|
|
48
|
+
)
|
|
49
|
+
self.assertEqual(response.body,
|
|
50
|
+
{"jsonrpc": "2.0", "id": None,
|
|
51
|
+
"error": {"code": -32700, "message": "Parse error"}})
|
|
52
|
+
self.assertEqual(self.calls, [])
|
|
53
|
+
|
|
54
|
+
def test_valid_json_number_overflow_in_id_is_invalid_request(self):
|
|
55
|
+
response = self.rpc.handle_payload('{"jsonrpc":"2.0","method":"echo","id":1e999}')
|
|
56
|
+
self.assertEqual(response.body["error"]["code"], -32600)
|
|
57
|
+
self.assertIsNone(response.body["id"])
|
|
58
|
+
self.assertEqual(self.calls, [])
|
|
59
|
+
|
|
60
|
+
def test_explicit_null_and_other_valid_ids_receive_responses(self):
|
|
61
|
+
for request_id in [None, 0, 1.5, "", "S:literal", "D:2026-01-01", "123n"]:
|
|
62
|
+
with self.subTest(request_id=request_id):
|
|
63
|
+
response = self.rpc.handle_payload(
|
|
64
|
+
{"jsonrpc": "2.0", "method": "echo", "params": {}, "id": request_id}
|
|
65
|
+
)
|
|
66
|
+
self.assertEqual(response.body, {"jsonrpc": "2.0", "id": request_id, "result": {}})
|
|
67
|
+
|
|
68
|
+
def test_params_must_be_object_or_array_when_present(self):
|
|
69
|
+
for params in [None, True, 1, "text"]:
|
|
70
|
+
for with_id in [True, False]:
|
|
71
|
+
with self.subTest(params=params, with_id=with_id):
|
|
72
|
+
request = {"jsonrpc": "2.0", "method": "echo", "params": params}
|
|
73
|
+
if with_id:
|
|
74
|
+
request["id"] = 7
|
|
75
|
+
response = self.rpc.handle_payload(request)
|
|
76
|
+
self.assertEqual(response.body["error"]["code"], -32600)
|
|
77
|
+
self.assertIsNone(response.body["id"])
|
|
78
|
+
self.assertEqual(self.calls, [])
|
|
79
|
+
|
|
80
|
+
def test_mixed_batch_retains_invalid_elements_and_explicit_null_calls(self):
|
|
81
|
+
response = self.rpc.handle_payload([
|
|
82
|
+
{},
|
|
83
|
+
{"jsonrpc": "2.0", "method": "echo"},
|
|
84
|
+
{"jsonrpc": "2.0", "method": "echo", "params": [], "id": None},
|
|
85
|
+
{"jsonrpc": "2.0", "method": "missing"},
|
|
86
|
+
])
|
|
87
|
+
self.assertEqual(len(response.body), 2)
|
|
88
|
+
self.assertEqual(response.body[0]["error"]["code"], -32600)
|
|
89
|
+
self.assertEqual(response.body[1], {"jsonrpc": "2.0", "id": None, "result": []})
|
|
90
|
+
|
|
91
|
+
def test_all_valid_notifications_have_no_response(self):
|
|
92
|
+
response = self.rpc.handle_payload([
|
|
93
|
+
{"jsonrpc": "2.0", "method": "echo"},
|
|
94
|
+
{"jsonrpc": "2.0", "method": "missing"},
|
|
95
|
+
])
|
|
96
|
+
self.assertEqual(response.status, 204)
|
|
97
|
+
self.assertIsNone(response.body)
|
|
98
|
+
|
|
99
|
+
def test_safe_mode_keeps_envelope_literals_and_nested_empty_containers(self):
|
|
100
|
+
rpc = RpcSafeEndpoint()
|
|
101
|
+
rpc.add_method("S:echo", self.echo)
|
|
102
|
+
response = rpc.handle_payload(
|
|
103
|
+
{"jsonrpc": "2.0", "method": "S:echo", "params": {"object": {}, "array": []}, "id": "123n"},
|
|
104
|
+
{"headers": {"x-rpc-safe-enabled": "true"}},
|
|
105
|
+
)
|
|
106
|
+
self.assertEqual(response.body,
|
|
107
|
+
{"jsonrpc": "2.0", "id": "123n", "result": {"object": {}, "array": []}})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
unittest.main()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit/serialization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/requires.txt
RENAMED
|
File without changes
|
{rpc_python_toolkit-0.2.0 → rpc_python_toolkit-0.2.1}/src/rpc_python_toolkit.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|