@net-mesh/core 0.19.0
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.
- package/README.md +45 -0
- package/errors.d.ts +62 -0
- package/errors.js +209 -0
- package/mesh_rpc.d.ts +297 -0
- package/mesh_rpc.js +780 -0
- package/net.darwin-arm64.node +0 -0
- package/net.darwin-x64.node +0 -0
- package/net.linux-arm64-gnu.node +0 -0
- package/net.linux-arm64-musl.node +0 -0
- package/net.linux-x64-gnu.node +0 -0
- package/net.linux-x64-musl.node +0 -0
- package/net.win32-arm64-msvc.node +0 -0
- package/net.win32-x64-msvc.node +0 -0
- package/package.json +95 -0
package/mesh_rpc.js
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Typed nRPC wrappers + retry / hedge / circuit-breaker helpers.
|
|
3
|
+
//
|
|
4
|
+
// Sits on top of the raw napi `MeshRpc` class (in index.js):
|
|
5
|
+
// translates typed JS objects to/from JSON-encoded Buffers,
|
|
6
|
+
// re-throws errors as typed `RpcError` subclasses, and provides
|
|
7
|
+
// pure-JS implementations of the resilience policies that mirror
|
|
8
|
+
// the Rust SDK's defaults.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// import { NetMesh } from '@net-mesh/core'
|
|
12
|
+
// import { TypedMeshRpc, RetryPolicy } from '@net-mesh/core/mesh_rpc'
|
|
13
|
+
//
|
|
14
|
+
// const mesh = await NetMesh.create({ ... })
|
|
15
|
+
// const rpc = TypedMeshRpc.fromMesh(mesh)
|
|
16
|
+
//
|
|
17
|
+
// const handle = rpc.serve('echo', async (req) => req)
|
|
18
|
+
// const reply = await rpc.call(targetId, 'echo', { hello: 'world' })
|
|
19
|
+
//
|
|
20
|
+
// // With retry:
|
|
21
|
+
// const policy = new RetryPolicy({ maxAttempts: 3 })
|
|
22
|
+
// const reply = await rpc.callWithRetry(targetId, 'echo', req, undefined, policy)
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.NRPC_TYPED_HANDLER_ERROR = exports.NRPC_TYPED_BAD_REQUEST = exports.CircuitBreaker = exports.BreakerOpenError = exports.HedgePolicy = exports.RetryPolicy = exports.TypedRpcStream = exports.TypedMeshRpc = void 0;
|
|
25
|
+
exports.defaultRetryable = defaultRetryable;
|
|
26
|
+
exports.defaultBreakerFailure = defaultBreakerFailure;
|
|
27
|
+
exports.appError = appError;
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
|
+
const native = require('./index');
|
|
30
|
+
// Duck-typed message extractor — keeps the "any rejected value
|
|
31
|
+
// with a string `message`" contract uniform across the two
|
|
32
|
+
// modules. The TS source compiles this `import` to a `require`
|
|
33
|
+
// of the sibling `./errors.js`, which `prepublishOnly` emits
|
|
34
|
+
// alongside `mesh_rpc.js`.
|
|
35
|
+
const errors_1 = require("./errors");
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// JSON codec.
|
|
38
|
+
//
|
|
39
|
+
// Default codec for typed wrappers. Matches the Rust SDK's
|
|
40
|
+
// Codec::Json. Encode failure (e.g. circular reference, BigInt
|
|
41
|
+
// in unsupported position) → throw RpcCodecError(direction='encode')
|
|
42
|
+
// BEFORE the call hits the wire. Decode failure on the reply
|
|
43
|
+
// surfaces as RpcCodecError(direction='decode').
|
|
44
|
+
// ============================================================================
|
|
45
|
+
const utf8 = new TextEncoder();
|
|
46
|
+
const utf8d = new TextDecoder('utf-8', { fatal: true });
|
|
47
|
+
function jsonEncode(value) {
|
|
48
|
+
let json;
|
|
49
|
+
try {
|
|
50
|
+
json = JSON.stringify(value);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw new Error(`nrpc:codec_encode: ${(0, errors_1.extractMessage)(e) || String(e)}`);
|
|
54
|
+
}
|
|
55
|
+
if (json === undefined) {
|
|
56
|
+
// JSON.stringify(undefined) === undefined. nRPC expects bytes.
|
|
57
|
+
throw new Error('nrpc:codec_encode: top-level undefined cannot be serialized');
|
|
58
|
+
}
|
|
59
|
+
return Buffer.from(utf8.encode(json));
|
|
60
|
+
}
|
|
61
|
+
function jsonDecode(buf) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(utf8d.decode(buf));
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
throw new Error(`nrpc:codec_decode: ${(0, errors_1.extractMessage)(e) || String(e)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
class TypedMeshRpc {
|
|
70
|
+
_raw;
|
|
71
|
+
/**
|
|
72
|
+
* Build a TypedMeshRpc against a NetMesh. Cheap; returns a
|
|
73
|
+
* new wrapper around an internal raw MeshRpc.
|
|
74
|
+
*/
|
|
75
|
+
static fromMesh(mesh) {
|
|
76
|
+
return new TypedMeshRpc(native.MeshRpc.fromMesh(mesh));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build a TypedMeshRpc from an already-constructed raw MeshRpc.
|
|
80
|
+
* Useful if you need the raw + typed surface side by side.
|
|
81
|
+
*/
|
|
82
|
+
constructor(rawMeshRpc) {
|
|
83
|
+
this._raw = rawMeshRpc;
|
|
84
|
+
}
|
|
85
|
+
/** Underlying raw `MeshRpc` for users who want the Buffer-level surface. */
|
|
86
|
+
get raw() {
|
|
87
|
+
return this._raw;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Register a typed handler. The handler receives a decoded
|
|
91
|
+
* `Req` and returns a `Resp` (or a Promise of one). JSON
|
|
92
|
+
* encode/decode happens at the binding boundary; encode
|
|
93
|
+
* failure inside the handler surfaces to the caller as
|
|
94
|
+
* `RpcServerError(status=0x0006 Internal)`.
|
|
95
|
+
*/
|
|
96
|
+
serve(service, handler) {
|
|
97
|
+
return this._raw.serve(service, async (reqBuf) => {
|
|
98
|
+
// Decode failures on the request surface to the caller as
|
|
99
|
+
// a canonical typed-bad-request: the Rust binding maps any
|
|
100
|
+
// promise rejection whose message starts with
|
|
101
|
+
// `nrpc:app_error:0x<code>:<body>` to
|
|
102
|
+
// RpcHandlerError::Application { code, message: body },
|
|
103
|
+
// which the fold emits as RpcStatus::Application(code).
|
|
104
|
+
// Status 0x8000 == NRPC_TYPED_BAD_REQUEST per the cross-
|
|
105
|
+
// binding contract pinned in
|
|
106
|
+
// `tests/cross_lang_nrpc/golden_vectors.json`.
|
|
107
|
+
let req;
|
|
108
|
+
try {
|
|
109
|
+
req = jsonDecode(reqBuf);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
const body = JSON.stringify({
|
|
113
|
+
error: 'invalid_request',
|
|
114
|
+
detail: (0, errors_1.extractMessage)(e) || String(e),
|
|
115
|
+
});
|
|
116
|
+
throw appError(0x8000, body);
|
|
117
|
+
}
|
|
118
|
+
const resp = await handler(req);
|
|
119
|
+
return jsonEncode(resp);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Direct-addressed typed call. Encodes `req` as JSON, calls,
|
|
124
|
+
* decodes the response. Throws an `RpcError` subclass on
|
|
125
|
+
* failure (matched by the napi prefix → JS class mapping in
|
|
126
|
+
* `errors.js`).
|
|
127
|
+
*
|
|
128
|
+
* Pass `opts.signal` (AbortSignal) for caller-driven
|
|
129
|
+
* cancellation. The wrapper mints a cancel token via the raw
|
|
130
|
+
* binding's `reserveCancelToken()`, attaches an abort listener,
|
|
131
|
+
* and lets the abort fire `cancelCall(token)` to drop the in-
|
|
132
|
+
* flight call (CANCEL fires on the wire, the call rejects with
|
|
133
|
+
* `nrpc:cancelled:`).
|
|
134
|
+
*/
|
|
135
|
+
async call(targetNodeId, service, req, opts) {
|
|
136
|
+
const reqBuf = jsonEncode(req);
|
|
137
|
+
const { rawOpts, detach } = wireAbortSignal(this._raw, opts);
|
|
138
|
+
try {
|
|
139
|
+
const respBuf = await this._raw.call(targetNodeId, service, reqBuf, rawOpts);
|
|
140
|
+
return jsonDecode(respBuf);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
detach();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Service-discovery typed call. Resolves `service` against the
|
|
148
|
+
* local capability index, picks a target per the routing
|
|
149
|
+
* policy, calls. Throws an `RpcError` subclass on failure.
|
|
150
|
+
*/
|
|
151
|
+
async callService(service, req, opts) {
|
|
152
|
+
const reqBuf = jsonEncode(req);
|
|
153
|
+
const { rawOpts, detach } = wireAbortSignal(this._raw, opts);
|
|
154
|
+
try {
|
|
155
|
+
const respBuf = await this._raw.callService(service, reqBuf, rawOpts);
|
|
156
|
+
return jsonDecode(respBuf);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
detach();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Open a typed streaming call. Returns a `TypedRpcStream` that
|
|
164
|
+
* yields decoded `Resp` values per `next()` until EOF.
|
|
165
|
+
*/
|
|
166
|
+
async callStreaming(targetNodeId, service, req, opts) {
|
|
167
|
+
const reqBuf = jsonEncode(req);
|
|
168
|
+
const inner = await this._raw.callStreaming(targetNodeId, service, reqBuf, opts);
|
|
169
|
+
return new TypedRpcStream(inner);
|
|
170
|
+
}
|
|
171
|
+
/** Pass-through to `MeshRpc.findServiceNodes`. */
|
|
172
|
+
findServiceNodes(service) {
|
|
173
|
+
return this._raw.findServiceNodes(service);
|
|
174
|
+
}
|
|
175
|
+
// ---- resilience helpers --------------------------------------------------
|
|
176
|
+
/**
|
|
177
|
+
* Direct-addressed typed call with retry. See {@link RetryPolicy}.
|
|
178
|
+
*/
|
|
179
|
+
async callWithRetry(targetNodeId, service, req, opts, policy) {
|
|
180
|
+
// Encode once and reuse across attempts (matches the Rust
|
|
181
|
+
// SDK's call_typed_with_retry contract).
|
|
182
|
+
const reqBuf = jsonEncode(req);
|
|
183
|
+
const respBuf = await runRetry(policy, async () => {
|
|
184
|
+
return await this._raw.call(targetNodeId, service, reqBuf, opts);
|
|
185
|
+
});
|
|
186
|
+
return jsonDecode(respBuf);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Hedge typed call across the listed targets. First reply
|
|
190
|
+
* (Ok or Err) wins; if every target fails, the surfaced error
|
|
191
|
+
* is the primary's (target index 0) for stable diagnostics.
|
|
192
|
+
*/
|
|
193
|
+
async callWithHedgeTo(targets, service, req, opts, policy) {
|
|
194
|
+
const reqBuf = jsonEncode(req);
|
|
195
|
+
const respBuf = await runHedge(policy, targets, async (targetId) => {
|
|
196
|
+
return await this._raw.call(targetId, service, reqBuf, opts);
|
|
197
|
+
});
|
|
198
|
+
return jsonDecode(respBuf);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
exports.TypedMeshRpc = TypedMeshRpc;
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// TypedRpcStream — typed wrapper around the raw RpcStream.
|
|
204
|
+
// ============================================================================
|
|
205
|
+
class TypedRpcStream {
|
|
206
|
+
_raw;
|
|
207
|
+
_done;
|
|
208
|
+
constructor(rawRpcStream) {
|
|
209
|
+
this._raw = rawRpcStream;
|
|
210
|
+
this._done = false;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Pull the next decoded value. Returns `null` on clean EOF.
|
|
214
|
+
* Throws `RpcCodecError(direction='decode')` if a chunk fails
|
|
215
|
+
* to decode (terminates the stream — the underlying CANCEL is
|
|
216
|
+
* fired by the raw RpcStream's drop).
|
|
217
|
+
*/
|
|
218
|
+
async next() {
|
|
219
|
+
if (this._done)
|
|
220
|
+
return null;
|
|
221
|
+
let buf;
|
|
222
|
+
try {
|
|
223
|
+
buf = await this._raw.next();
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
this._done = true;
|
|
227
|
+
throw e; // user catch site classifies via classifyError
|
|
228
|
+
}
|
|
229
|
+
if (buf === null || buf === undefined) {
|
|
230
|
+
this._done = true;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return jsonDecode(buf);
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
this._done = true;
|
|
238
|
+
// Close the underlying stream so the server's handler
|
|
239
|
+
// observes CANCEL — no point keeping a stream open whose
|
|
240
|
+
// subsequent chunks we can't decode.
|
|
241
|
+
try {
|
|
242
|
+
await this._raw.close();
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
/* swallow — best-effort */
|
|
246
|
+
}
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Async iterator support: `for await (const chunk of stream) { ... }`. */
|
|
251
|
+
async *[Symbol.asyncIterator]() {
|
|
252
|
+
while (true) {
|
|
253
|
+
const value = await this.next();
|
|
254
|
+
if (value === null)
|
|
255
|
+
return;
|
|
256
|
+
yield value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Grant `n` flow-control credits to the server pump. */
|
|
260
|
+
async grant(n) {
|
|
261
|
+
await this._raw.grant(n);
|
|
262
|
+
}
|
|
263
|
+
/** `true` if the call set `streamWindowInitial`. */
|
|
264
|
+
async flowControlled() {
|
|
265
|
+
return await this._raw.flowControlled();
|
|
266
|
+
}
|
|
267
|
+
/** Close the stream; emits CANCEL to the server. Idempotent. */
|
|
268
|
+
async close() {
|
|
269
|
+
this._done = true;
|
|
270
|
+
try {
|
|
271
|
+
await this._raw.close();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
/* swallow — best-effort */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
exports.TypedRpcStream = TypedRpcStream;
|
|
279
|
+
// ============================================================================
|
|
280
|
+
// RetryPolicy — mirrors net_sdk::mesh_rpc_resilience::RetryPolicy.
|
|
281
|
+
//
|
|
282
|
+
// Defaults: 3 attempts, 50ms initial backoff, doubling per
|
|
283
|
+
// attempt, capped at 1s, full-half jitter on. The retryable
|
|
284
|
+
// predicate skips RpcCodecError + RpcNoRouteError + non-transient
|
|
285
|
+
// RpcServerError statuses (matches Rust's default_retryable).
|
|
286
|
+
// ============================================================================
|
|
287
|
+
const DEFAULT_RETRY = Object.freeze({
|
|
288
|
+
maxAttempts: 3,
|
|
289
|
+
initialBackoffMs: 50,
|
|
290
|
+
maxBackoffMs: 1000,
|
|
291
|
+
backoffMultiplier: 2.0,
|
|
292
|
+
jitter: true,
|
|
293
|
+
});
|
|
294
|
+
// Wire-level RpcStatus codes the default predicate considers
|
|
295
|
+
// transient (server-observed). Matches the Rust SDK's default_retryable.
|
|
296
|
+
const STATUS_INTERNAL = 0x0006;
|
|
297
|
+
const STATUS_BACKPRESSURE = 0x0004;
|
|
298
|
+
const STATUS_TIMEOUT = 0x0003;
|
|
299
|
+
function defaultRetryable(err) {
|
|
300
|
+
// Per Rust SDK: NoRoute + Codec are caller-fixable / terminal
|
|
301
|
+
// and never retried. Timeout (caller-side) and Transport
|
|
302
|
+
// always retry. ServerError retries only for canonical
|
|
303
|
+
// transient statuses.
|
|
304
|
+
//
|
|
305
|
+
// Detection strategy: try `err.name` first (a runtime string,
|
|
306
|
+
// dual-module-safe), then fall back to message-prefix matching
|
|
307
|
+
// for raw napi errors that haven't been classified yet.
|
|
308
|
+
// String / null / undefined rejections short-circuit to "not
|
|
309
|
+
// retryable" — the predicate only fires for object-shaped errors.
|
|
310
|
+
if (err === null || err === undefined || typeof err !== 'object') {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const errAny = err;
|
|
314
|
+
const name = errAny.name ?? '';
|
|
315
|
+
switch (name) {
|
|
316
|
+
case 'RpcNoRouteError':
|
|
317
|
+
case 'RpcCodecError':
|
|
318
|
+
case 'RpcCancelledError':
|
|
319
|
+
// Cancellation is caller-driven — retrying defeats the
|
|
320
|
+
// point. Pinned by `RpcCancelledError`'s class docstring;
|
|
321
|
+
// pre-TS-migration the predicate fell through to the
|
|
322
|
+
// generic `nrpc:` "retry by default" branch and silently
|
|
323
|
+
// re-issued cancelled calls.
|
|
324
|
+
return false;
|
|
325
|
+
case 'RpcTimeoutError':
|
|
326
|
+
case 'RpcTransportError':
|
|
327
|
+
return true;
|
|
328
|
+
case 'RpcServerError': {
|
|
329
|
+
// Prefer err.status (set by RpcServerError constructor);
|
|
330
|
+
// fall back to parsing the message.
|
|
331
|
+
const status = typeof errAny.status === 'number'
|
|
332
|
+
? errAny.status
|
|
333
|
+
: parseStatusFromMessage((0, errors_1.extractMessage)(err));
|
|
334
|
+
return (status === STATUS_INTERNAL ||
|
|
335
|
+
status === STATUS_BACKPRESSURE ||
|
|
336
|
+
status === STATUS_TIMEOUT);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Fall back to message prefix. Use the same duck-typed
|
|
340
|
+
// extractor as classifyError so plain-object rejections with a
|
|
341
|
+
// `message` field classify the same way real Errors do.
|
|
342
|
+
const msg = (0, errors_1.extractMessage)(err);
|
|
343
|
+
if (!msg.startsWith('nrpc:'))
|
|
344
|
+
return false;
|
|
345
|
+
if (msg.startsWith('nrpc:no_route:'))
|
|
346
|
+
return false;
|
|
347
|
+
if (msg.startsWith('nrpc:codec_'))
|
|
348
|
+
return false;
|
|
349
|
+
if (msg.startsWith('nrpc:cancelled:'))
|
|
350
|
+
return false;
|
|
351
|
+
if (msg.startsWith('nrpc:server_error:')) {
|
|
352
|
+
const status = parseStatusFromMessage(msg);
|
|
353
|
+
return (status === STATUS_INTERNAL ||
|
|
354
|
+
status === STATUS_BACKPRESSURE ||
|
|
355
|
+
status === STATUS_TIMEOUT);
|
|
356
|
+
}
|
|
357
|
+
// nrpc:timeout, nrpc:transport, and any future variant → retry.
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
function parseStatusFromMessage(msg) {
|
|
361
|
+
if (!msg)
|
|
362
|
+
return undefined;
|
|
363
|
+
const m = /status=0x([0-9a-fA-F]+)/.exec(msg);
|
|
364
|
+
return m ? parseInt(m[1], 16) : undefined;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Retry policy. Defaults: 3 attempts, 50ms→1s exponential backoff,
|
|
368
|
+
* full-half jitter on, retryable predicate matches the Rust SDK's
|
|
369
|
+
* `default_retryable` (skips RpcCodecError, RpcNoRouteError, and
|
|
370
|
+
* non-transient ServerError statuses).
|
|
371
|
+
*/
|
|
372
|
+
class RetryPolicy {
|
|
373
|
+
maxAttempts;
|
|
374
|
+
initialBackoffMs;
|
|
375
|
+
maxBackoffMs;
|
|
376
|
+
backoffMultiplier;
|
|
377
|
+
jitter;
|
|
378
|
+
retryable;
|
|
379
|
+
constructor(opts) {
|
|
380
|
+
const merged = { ...DEFAULT_RETRY, ...(opts ?? {}) };
|
|
381
|
+
this.maxAttempts = Math.max(1, merged.maxAttempts | 0);
|
|
382
|
+
this.initialBackoffMs = Math.max(0, merged.initialBackoffMs);
|
|
383
|
+
this.maxBackoffMs = Math.max(this.initialBackoffMs, merged.maxBackoffMs);
|
|
384
|
+
this.backoffMultiplier = Math.max(1.0, merged.backoffMultiplier);
|
|
385
|
+
this.jitter = !!merged.jitter;
|
|
386
|
+
const retryable = (opts?.retryable ?? defaultRetryable);
|
|
387
|
+
if (typeof retryable !== 'function') {
|
|
388
|
+
throw new TypeError('RetryPolicy.retryable must be a function (received ' +
|
|
389
|
+
typeof retryable +
|
|
390
|
+
')');
|
|
391
|
+
}
|
|
392
|
+
this.retryable = retryable;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Compute the backoff for `attempt` (1-indexed). Caps at
|
|
396
|
+
* `maxBackoffMs` AFTER jitter so the cap is a true ceiling.
|
|
397
|
+
*/
|
|
398
|
+
computeBackoffMs(attempt) {
|
|
399
|
+
const exp = Math.max(0, attempt - 1);
|
|
400
|
+
const scaled = this.initialBackoffMs * Math.pow(this.backoffMultiplier, exp);
|
|
401
|
+
const preCap = Math.min(this.maxBackoffMs, scaled);
|
|
402
|
+
const jittered = this.jitter ? preCap * (0.5 + 0.5 * Math.random()) : preCap;
|
|
403
|
+
return Math.min(this.maxBackoffMs, jittered);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
exports.RetryPolicy = RetryPolicy;
|
|
407
|
+
async function runRetry(policy, op) {
|
|
408
|
+
let lastErr;
|
|
409
|
+
for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
|
|
410
|
+
try {
|
|
411
|
+
return await op();
|
|
412
|
+
}
|
|
413
|
+
catch (e) {
|
|
414
|
+
lastErr = e;
|
|
415
|
+
if (attempt === policy.maxAttempts || !policy.retryable(e)) {
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
const ms = policy.computeBackoffMs(attempt);
|
|
419
|
+
if (ms > 0)
|
|
420
|
+
await sleep(ms);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Unreachable under normal control flow (the loop returns or
|
|
424
|
+
// throws), but TS's exhaustiveness requires an explicit throw.
|
|
425
|
+
throw lastErr;
|
|
426
|
+
}
|
|
427
|
+
// ============================================================================
|
|
428
|
+
// HedgePolicy — mirrors net_sdk::mesh_rpc_resilience::HedgePolicy.
|
|
429
|
+
//
|
|
430
|
+
// Fire-then-race: primary at t=0, additional hedges at
|
|
431
|
+
// t = delay * idx. First reply (Ok or Err) wins; if every hedge
|
|
432
|
+
// fails, surfaces the PRIMARY's error deterministically (matches
|
|
433
|
+
// the Rust SDK's M19 fix).
|
|
434
|
+
// ============================================================================
|
|
435
|
+
const DEFAULT_HEDGE = Object.freeze({
|
|
436
|
+
delayMs: 50,
|
|
437
|
+
hedges: 1,
|
|
438
|
+
});
|
|
439
|
+
/**
|
|
440
|
+
* Hedge policy. Fire-then-race: primary at t=0, hedges at
|
|
441
|
+
* t = delayMs * idx. First reply wins; if every hedge fails,
|
|
442
|
+
* the primary's error is surfaced deterministically.
|
|
443
|
+
*/
|
|
444
|
+
class HedgePolicy {
|
|
445
|
+
delayMs;
|
|
446
|
+
hedges;
|
|
447
|
+
constructor(opts) {
|
|
448
|
+
const merged = { ...DEFAULT_HEDGE, ...(opts ?? {}) };
|
|
449
|
+
this.delayMs = Math.max(0, merged.delayMs);
|
|
450
|
+
this.hedges = Math.max(0, merged.hedges | 0);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
exports.HedgePolicy = HedgePolicy;
|
|
454
|
+
async function runHedge(policy, targets, op) {
|
|
455
|
+
if (!Array.isArray(targets) || targets.length === 0) {
|
|
456
|
+
// Plain Error with the stable prefix; user's catch site
|
|
457
|
+
// calls classifyError() to get a typed RpcNoRouteError.
|
|
458
|
+
throw new Error('nrpc:no_route: hedge: empty targets list');
|
|
459
|
+
}
|
|
460
|
+
// Hedges = 0 → degrade to a straight call against targets[0].
|
|
461
|
+
if (policy.hedges === 0 || targets.length === 1) {
|
|
462
|
+
return await op(targets[0]);
|
|
463
|
+
}
|
|
464
|
+
// Cap hedges to the available targets (primary + N-1 hedges).
|
|
465
|
+
const fanout = Math.min(targets.length, 1 + policy.hedges);
|
|
466
|
+
const errors = new Array(fanout).fill(undefined);
|
|
467
|
+
let resolved = false;
|
|
468
|
+
let firstOk = null;
|
|
469
|
+
let okIndex = -1;
|
|
470
|
+
// Each launch is a Promise that either:
|
|
471
|
+
// - resolves with { ok: true, value } if op succeeds AND we're first
|
|
472
|
+
// - resolves with { idx, err } if op fails
|
|
473
|
+
// - never resolves if a previous launch already won
|
|
474
|
+
const launches = [];
|
|
475
|
+
for (let i = 0; i < fanout; i++) {
|
|
476
|
+
const idx = i;
|
|
477
|
+
launches.push((async () => {
|
|
478
|
+
if (idx > 0)
|
|
479
|
+
await sleep(policy.delayMs * idx);
|
|
480
|
+
if (resolved)
|
|
481
|
+
return { idx, err: null, skipped: true };
|
|
482
|
+
try {
|
|
483
|
+
const value = await op(targets[idx]);
|
|
484
|
+
if (!resolved) {
|
|
485
|
+
resolved = true;
|
|
486
|
+
firstOk = value;
|
|
487
|
+
okIndex = idx;
|
|
488
|
+
}
|
|
489
|
+
return { idx, err: null, value };
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
errors[idx] = e;
|
|
493
|
+
return { idx, err: e };
|
|
494
|
+
}
|
|
495
|
+
})());
|
|
496
|
+
}
|
|
497
|
+
await Promise.all(launches);
|
|
498
|
+
if (okIndex >= 0)
|
|
499
|
+
return firstOk;
|
|
500
|
+
// All failed — surface the primary's error (or the lowest-
|
|
501
|
+
// indexed defined error). Deterministic across runs.
|
|
502
|
+
for (let i = 0; i < errors.length; i++) {
|
|
503
|
+
if (errors[i] !== undefined)
|
|
504
|
+
throw errors[i];
|
|
505
|
+
}
|
|
506
|
+
// Shouldn't reach here, but defend against a fanout = 0 edge
|
|
507
|
+
// case that slipped past the cap above.
|
|
508
|
+
throw new Error('nrpc:hedge: drained with no error captured (bug)');
|
|
509
|
+
}
|
|
510
|
+
const STATE_CLOSED = 'closed';
|
|
511
|
+
const STATE_OPEN = 'open';
|
|
512
|
+
const STATE_HALF_OPEN = 'half-open';
|
|
513
|
+
const DEFAULT_BREAKER = Object.freeze({
|
|
514
|
+
failureThreshold: 5,
|
|
515
|
+
resetAfterMs: 30_000,
|
|
516
|
+
successThreshold: 1,
|
|
517
|
+
});
|
|
518
|
+
function defaultBreakerFailure(err) {
|
|
519
|
+
// Mirror default_retryable — same set of "transient infra
|
|
520
|
+
// failures" counts toward tripping; codec / no-route / app
|
|
521
|
+
// errors don't.
|
|
522
|
+
return defaultRetryable(err);
|
|
523
|
+
}
|
|
524
|
+
// BreakerOpenError extends `Error` (not RpcError) so its identity
|
|
525
|
+
// is local to this module — the test imports BreakerOpenError
|
|
526
|
+
// from this same file, so `instanceof` works. Users who catch
|
|
527
|
+
// "any RPC failure" with `instanceof RpcError` should also catch
|
|
528
|
+
// `instanceof BreakerOpenError` separately when using the
|
|
529
|
+
// breaker. The `nrpc:breaker_open:` message prefix lets
|
|
530
|
+
// `classifyError` route this through the generic `RpcError` base
|
|
531
|
+
// for users who want a unified catch.
|
|
532
|
+
class BreakerOpenError extends Error {
|
|
533
|
+
constructor() {
|
|
534
|
+
super('nrpc:breaker_open: circuit breaker is open');
|
|
535
|
+
this.name = 'BreakerOpenError';
|
|
536
|
+
Object.setPrototypeOf(this, BreakerOpenError.prototype);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
exports.BreakerOpenError = BreakerOpenError;
|
|
540
|
+
/**
|
|
541
|
+
* Three-state circuit breaker. Long-lived; instantiate once per
|
|
542
|
+
* logical downstream and share. `breaker.call(() => ...)`
|
|
543
|
+
* composes around any async op.
|
|
544
|
+
*/
|
|
545
|
+
class CircuitBreaker {
|
|
546
|
+
failureThreshold;
|
|
547
|
+
resetAfterMs;
|
|
548
|
+
successThreshold;
|
|
549
|
+
failurePredicate;
|
|
550
|
+
_state;
|
|
551
|
+
_consecutiveFailures;
|
|
552
|
+
_consecutiveSuccesses;
|
|
553
|
+
_openedAt;
|
|
554
|
+
_probeInFlight;
|
|
555
|
+
constructor(opts) {
|
|
556
|
+
const merged = { ...DEFAULT_BREAKER, ...(opts ?? {}) };
|
|
557
|
+
this.failureThreshold = Math.max(1, merged.failureThreshold | 0);
|
|
558
|
+
this.resetAfterMs = Math.max(0, merged.resetAfterMs);
|
|
559
|
+
this.successThreshold = Math.max(1, merged.successThreshold | 0);
|
|
560
|
+
const predicate = (opts?.failurePredicate ??
|
|
561
|
+
defaultBreakerFailure);
|
|
562
|
+
if (typeof predicate !== 'function') {
|
|
563
|
+
throw new TypeError('CircuitBreaker.failurePredicate must be a function (received ' +
|
|
564
|
+
typeof predicate +
|
|
565
|
+
')');
|
|
566
|
+
}
|
|
567
|
+
this.failurePredicate = predicate;
|
|
568
|
+
this._state = STATE_CLOSED;
|
|
569
|
+
this._consecutiveFailures = 0;
|
|
570
|
+
this._consecutiveSuccesses = 0;
|
|
571
|
+
this._openedAt = 0;
|
|
572
|
+
this._probeInFlight = false;
|
|
573
|
+
}
|
|
574
|
+
state() {
|
|
575
|
+
// Lazy "Open → HalfOpen on cooldown elapsed" transition: we
|
|
576
|
+
// probe at admission time, not on a background timer.
|
|
577
|
+
return this._state;
|
|
578
|
+
}
|
|
579
|
+
consecutiveFailures() {
|
|
580
|
+
return this._consecutiveFailures;
|
|
581
|
+
}
|
|
582
|
+
reset() {
|
|
583
|
+
this._state = STATE_CLOSED;
|
|
584
|
+
this._consecutiveFailures = 0;
|
|
585
|
+
this._consecutiveSuccesses = 0;
|
|
586
|
+
this._openedAt = 0;
|
|
587
|
+
this._probeInFlight = false;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Wrap an async op. Returns its value on success, throws on
|
|
591
|
+
* failure (or on rejection). When the breaker is `Open` within
|
|
592
|
+
* its cooldown, throws `BreakerOpenError` without invoking
|
|
593
|
+
* `op`.
|
|
594
|
+
*
|
|
595
|
+
* Both success and failure paths record the outcome through
|
|
596
|
+
* `_recordOutcome`, which is responsible for clearing
|
|
597
|
+
* `_probeInFlight` on the half-open-probe admission. A
|
|
598
|
+
* synchronous throw inside `op` (rare — `await` is always at
|
|
599
|
+
* `op`'s call site) is still routed through the catch arm.
|
|
600
|
+
*/
|
|
601
|
+
async call(op) {
|
|
602
|
+
const admission = this._tryAdmit();
|
|
603
|
+
if (admission === 'reject') {
|
|
604
|
+
throw new BreakerOpenError();
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const value = await op();
|
|
608
|
+
this._recordOutcome(admission, true, undefined);
|
|
609
|
+
return value;
|
|
610
|
+
}
|
|
611
|
+
catch (e) {
|
|
612
|
+
this._recordOutcome(admission, false, e);
|
|
613
|
+
throw e;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
_tryAdmit() {
|
|
617
|
+
if (this._state === STATE_CLOSED)
|
|
618
|
+
return 'closed';
|
|
619
|
+
if (this._state === STATE_OPEN) {
|
|
620
|
+
const elapsed = Date.now() - this._openedAt;
|
|
621
|
+
if (elapsed >= this.resetAfterMs) {
|
|
622
|
+
this._state = STATE_HALF_OPEN;
|
|
623
|
+
this._consecutiveSuccesses = 0;
|
|
624
|
+
this._probeInFlight = true;
|
|
625
|
+
return 'half-open-probe';
|
|
626
|
+
}
|
|
627
|
+
return 'reject';
|
|
628
|
+
}
|
|
629
|
+
// HalfOpen — at most one probe at a time.
|
|
630
|
+
if (this._probeInFlight)
|
|
631
|
+
return 'reject';
|
|
632
|
+
this._probeInFlight = true;
|
|
633
|
+
return 'half-open-probe';
|
|
634
|
+
}
|
|
635
|
+
_recordOutcome(admission, ok, err) {
|
|
636
|
+
if (admission === 'closed') {
|
|
637
|
+
if (ok) {
|
|
638
|
+
this._consecutiveFailures = 0;
|
|
639
|
+
}
|
|
640
|
+
else if (this.failurePredicate(err)) {
|
|
641
|
+
this._consecutiveFailures += 1;
|
|
642
|
+
if (this._consecutiveFailures >= this.failureThreshold) {
|
|
643
|
+
this._state = STATE_OPEN;
|
|
644
|
+
this._openedAt = Date.now();
|
|
645
|
+
this._consecutiveSuccesses = 0;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
// half-open-probe
|
|
651
|
+
this._probeInFlight = false;
|
|
652
|
+
if (ok) {
|
|
653
|
+
this._consecutiveSuccesses += 1;
|
|
654
|
+
if (this._consecutiveSuccesses >= this.successThreshold) {
|
|
655
|
+
this._state = STATE_CLOSED;
|
|
656
|
+
this._consecutiveFailures = 0;
|
|
657
|
+
this._consecutiveSuccesses = 0;
|
|
658
|
+
this._openedAt = 0;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
else if (this.failurePredicate(err)) {
|
|
662
|
+
// Failed probe → re-open with fresh cooldown.
|
|
663
|
+
this._state = STATE_OPEN;
|
|
664
|
+
this._openedAt = Date.now();
|
|
665
|
+
this._consecutiveFailures = 0;
|
|
666
|
+
this._consecutiveSuccesses = 0;
|
|
667
|
+
}
|
|
668
|
+
// Predicate said "not a failure" (e.g. application error) →
|
|
669
|
+
// leave state HalfOpen for the next probe.
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
exports.CircuitBreaker = CircuitBreaker;
|
|
673
|
+
// ============================================================================
|
|
674
|
+
// Helpers.
|
|
675
|
+
// ============================================================================
|
|
676
|
+
function sleep(ms) {
|
|
677
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Wire an AbortSignal (`opts.signal`) into the raw napi cancel
|
|
681
|
+
* surface. Returns `{ rawOpts, detach }`:
|
|
682
|
+
*
|
|
683
|
+
* - `rawOpts` is the option object to pass to the raw call,
|
|
684
|
+
* with `cancelToken` populated when a signal was provided
|
|
685
|
+
* (the raw napi side does not understand `signal`).
|
|
686
|
+
* - `detach` MUST be called from a `finally` block on the call
|
|
687
|
+
* site to remove the abort listener regardless of
|
|
688
|
+
* success/failure path. Idempotent.
|
|
689
|
+
*
|
|
690
|
+
* If no signal is provided (or the signal is already aborted),
|
|
691
|
+
* the wrapper either short-circuits or returns the rawOpts
|
|
692
|
+
* unchanged so the non-cancellable fast path stays free of
|
|
693
|
+
* tokio-spawn / registry overhead.
|
|
694
|
+
*/
|
|
695
|
+
function wireAbortSignal(raw, opts) {
|
|
696
|
+
if (!opts || !opts.signal) {
|
|
697
|
+
return { rawOpts: opts, detach: () => { } };
|
|
698
|
+
}
|
|
699
|
+
const signal = opts.signal;
|
|
700
|
+
// If the signal is already aborted, fail fast — don't even
|
|
701
|
+
// start the call.
|
|
702
|
+
if (signal.aborted) {
|
|
703
|
+
throw new Error('nrpc:cancelled: AbortSignal already aborted');
|
|
704
|
+
}
|
|
705
|
+
// Mint a token, copy opts (drop `signal` since the napi side
|
|
706
|
+
// doesn't know it), attach a listener that calls cancelCall on
|
|
707
|
+
// abort. The listener removes itself on detach so the AbortSignal
|
|
708
|
+
// can be reused for a subsequent call without leaking handlers.
|
|
709
|
+
const token = raw.reserveCancelToken();
|
|
710
|
+
const rawOpts = { ...opts, cancelToken: token };
|
|
711
|
+
delete rawOpts.signal;
|
|
712
|
+
let detached = false;
|
|
713
|
+
const onAbort = () => {
|
|
714
|
+
if (detached)
|
|
715
|
+
return;
|
|
716
|
+
try {
|
|
717
|
+
raw.cancelCall(token);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
/* swallow — best-effort */
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
724
|
+
return {
|
|
725
|
+
rawOpts,
|
|
726
|
+
detach: () => {
|
|
727
|
+
if (detached)
|
|
728
|
+
return;
|
|
729
|
+
detached = true;
|
|
730
|
+
signal.removeEventListener('abort', onAbort);
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Build an Error a typed serve handler can throw to surface a
|
|
736
|
+
* specific application status code to the caller. The Rust
|
|
737
|
+
* binding parses messages of the form
|
|
738
|
+
* `nrpc:app_error:0x<code>:<body>` and maps them to
|
|
739
|
+
* `RpcStatus::Application(code)` — without this prefix the
|
|
740
|
+
* thrown error becomes a generic `RpcStatus::Internal`. Mirrors
|
|
741
|
+
* the Python binding's `RpcAppError(code, body)`.
|
|
742
|
+
*
|
|
743
|
+
* Use cases: typed handlers that want to return 4xx-style
|
|
744
|
+
* application errors (`NRPC_TYPED_BAD_REQUEST`,
|
|
745
|
+
* `NRPC_TYPED_HANDLER_ERROR`, custom app codes >= 0x8000).
|
|
746
|
+
*
|
|
747
|
+
* @example
|
|
748
|
+
* rpc.serve('echo', (req) => {
|
|
749
|
+
* if (typeof req.text !== 'string') {
|
|
750
|
+
* throw appError(NRPC_TYPED_BAD_REQUEST,
|
|
751
|
+
* JSON.stringify({error: 'missing text'}))
|
|
752
|
+
* }
|
|
753
|
+
* return { echo: req.text }
|
|
754
|
+
* })
|
|
755
|
+
*/
|
|
756
|
+
function appError(code, body) {
|
|
757
|
+
if (typeof code !== 'number' || code < 0 || code > 0xffff) {
|
|
758
|
+
throw new TypeError(`appError: code must be a 0..=0xFFFF integer (got ${code})`);
|
|
759
|
+
}
|
|
760
|
+
let bodyStr;
|
|
761
|
+
if (typeof body === 'string') {
|
|
762
|
+
bodyStr = body;
|
|
763
|
+
}
|
|
764
|
+
else if (Buffer.isBuffer(body)) {
|
|
765
|
+
bodyStr = body.toString('utf8');
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
bodyStr = String(body ?? '');
|
|
769
|
+
}
|
|
770
|
+
// The Rust parser splits on the FIRST colon after `0x<hex>:`,
|
|
771
|
+
// so the body itself can contain colons safely.
|
|
772
|
+
const codeHex = code.toString(16).padStart(4, '0');
|
|
773
|
+
return new Error(`nrpc:app_error:0x${codeHex}:${bodyStr}`);
|
|
774
|
+
}
|
|
775
|
+
// Status code constants (parallel to NRPC_TYPED_BAD_REQUEST /
|
|
776
|
+
// NRPC_TYPED_HANDLER_ERROR in the Rust SDK).
|
|
777
|
+
/** RpcStatus::Application(0x8000): typed handler bad-request body. */
|
|
778
|
+
exports.NRPC_TYPED_BAD_REQUEST = 0x8000;
|
|
779
|
+
/** RpcStatus::Application(0x8001): typed handler returned `throw`. */
|
|
780
|
+
exports.NRPC_TYPED_HANDLER_ERROR = 0x8001;
|