@webpieces/core-util 0.4.894 → 0.4.896
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/package.json +1 -1
- package/src/errors/ApiError.d.ts +27 -1
- package/src/errors/ApiError.js +37 -1
- package/src/errors/ApiError.js.map +1 -1
- package/src/errors/ApiErrorCodec.js +5 -0
- package/src/errors/ApiErrorCodec.js.map +1 -1
- package/src/errors/ReceivedApiErrorRule.d.ts +6 -2
- package/src/errors/ReceivedApiErrorRule.js +6 -2
- package/src/errors/ReceivedApiErrorRule.js.map +1 -1
- package/src/errors/index.d.ts +1 -1
- package/src/errors/index.js +2 -1
- package/src/errors/index.js.map +1 -1
- package/src/http/ApiCallInfo.d.ts +3 -3
- package/src/http/ApiCallInfo.js +1 -1
- package/src/http/ApiCallInfo.js.map +1 -1
- package/src/http/ApiErrorHttpStatus.js +3 -0
- package/src/http/ApiErrorHttpStatus.js.map +1 -1
- package/src/http/CallRegistry.d.ts +31 -8
- package/src/http/CallRegistry.js +93 -7
- package/src/http/CallRegistry.js.map +1 -1
- package/src/http/CallRegistryCompileAssertions.js +9 -0
- package/src/http/CallRegistryCompileAssertions.js.map +1 -1
- package/src/http/ClientRegistry.d.ts +25 -23
- package/src/http/ClientRegistry.js +33 -31
- package/src/http/ClientRegistry.js.map +1 -1
- package/src/http/ErrorClassifier.d.ts +64 -0
- package/src/http/ErrorClassifier.js +39 -0
- package/src/http/ErrorClassifier.js.map +1 -0
- package/src/http/LogApiCall.d.ts +2 -18
- package/src/http/LogApiCall.js +7 -24
- package/src/http/LogApiCall.js.map +1 -1
- package/src/http/RetryStrategy.d.ts +32 -0
- package/src/http/RetryStrategy.js +96 -0
- package/src/http/RetryStrategy.js.map +1 -0
- package/src/http/TransportDefault.d.ts +25 -0
- package/src/http/TransportDefault.js +31 -0
- package/src/http/TransportDefault.js.map +1 -0
- package/src/http/WebpiecesDefaultErrorClassifier.d.ts +42 -0
- package/src/http/WebpiecesDefaultErrorClassifier.js +87 -0
- package/src/http/WebpiecesDefaultErrorClassifier.js.map +1 -0
- package/src/index.d.ts +7 -4
- package/src/index.js +13 -7
- package/src/index.js.map +1 -1
- package/src/http/FailureClassifier.d.ts +0 -48
- package/src/http/FailureClassifier.js +0 -19
- package/src/http/FailureClassifier.js.map +0 -1
- package/src/http/WebpiecesDefaultFailureClassifier.d.ts +0 -40
- package/src/http/WebpiecesDefaultFailureClassifier.js +0 -67
- package/src/http/WebpiecesDefaultFailureClassifier.js.map +0 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ApiMethodInfo } from './ApiMethodInfo';
|
|
2
|
+
/**
|
|
3
|
+
* The ONE verdict on a thrown API-call error. Three readers share it, so there is exactly one table
|
|
4
|
+
* answering "is this error expected, and is it worth repeating?":
|
|
5
|
+
*
|
|
6
|
+
* - `RetryStrategy` retries only {@link CallOutcome.TRANSIENT_FAILURE} (and only on a `READ` /
|
|
7
|
+
* `WRITE_IDEMPOTENT` endpoint — the ENDPOINT's safety is `CallContext.operation`'s question, never
|
|
8
|
+
* the classifier's);
|
|
9
|
+
* - `LogApiCall` logs {@link CallOutcome.EXPECTED} as `[API-*-resp-OTHER]` (`result='success'`) and
|
|
10
|
+
* both failure outcomes as `[API-*-resp-FAIL]` (`result='failure'`);
|
|
11
|
+
* - the MCP tool-call renderer derives its model-visible `retry` advice from the outcome plus the
|
|
12
|
+
* endpoint's operation.
|
|
13
|
+
*/
|
|
14
|
+
export declare enum CallOutcome {
|
|
15
|
+
/** It threw, but things are WORKING: a 266, a server-side 4xx, an external client's "404 = miss". */
|
|
16
|
+
EXPECTED = "expected",
|
|
17
|
+
/** Broken NOW, may work if repeated: unavailable, connection, timeouts, 429/backoff, bad-gateway. */
|
|
18
|
+
TRANSIENT_FAILURE = "transient",
|
|
19
|
+
/** Broken, and repeating will not help: a RECEIVED 4xx (our bug), implementation, 501, retries exhausted. */
|
|
20
|
+
FAILURE = "failure"
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Classifies a thrown API-call error into a {@link CallOutcome}.
|
|
24
|
+
*
|
|
25
|
+
* This is BEHAVIOR, so it is an interface (per CLAUDE.md), passed as an object with a NAMED method
|
|
26
|
+
* rather than a bare function — so "find usages" in an IDE lands on every implementation.
|
|
27
|
+
*
|
|
28
|
+
* Registered on {@link ClientRegistry} at startup — the same browser-safe, no-DI, populated-once
|
|
29
|
+
* singleton that owns URL mappings and the {@link ErrorTranslator}. Resolved most-specific first by
|
|
30
|
+
* {@link ClientRegistry.classify}:
|
|
31
|
+
*
|
|
32
|
+
* 1. `ClientRegistry.addErrorClassifier(new KeyedErrorClassifier(apiClass, c))` — per EXTERNAL
|
|
33
|
+
* client, keyed by {@link ApiMethodInfo.apiClass} ('FirestoreAdminClient', 'ClaudeApi',
|
|
34
|
+
* 'TwilioApi'). Each external API qualifies its errors differently (a Firestore 404 miss is
|
|
35
|
+
* EXPECTED; a Twilio 429 is TRANSIENT), so it overrides the tiers below for that apiClass only.
|
|
36
|
+
* 2. `ClientRegistry.setDefaultErrorClassifier(c)` — ONE per app/company. It reads
|
|
37
|
+
* {@link ApiMethodInfo.side}, so a single classifier covers the SERVER router AND every INTERNAL
|
|
38
|
+
* client (webpieces http client, cloud tasks).
|
|
39
|
+
* 3. {@link WebpiecesDefaultErrorClassifier} — the terminal tier, which never defers.
|
|
40
|
+
*
|
|
41
|
+
* Internal clients and the server register NOTHING — the ABSENCE of an apiClass entry is the signal.
|
|
42
|
+
* An external client that forgets to register is FAIL-SAFE: it falls to the tiers below, where every
|
|
43
|
+
* client-side error except a 266 is a failure.
|
|
44
|
+
*/
|
|
45
|
+
export interface ErrorClassifier {
|
|
46
|
+
/**
|
|
47
|
+
* @param error - the already-normalized thrown error (callers pass `toError(err)`)
|
|
48
|
+
* @param methodInfo - the call identity; `side` distinguishes server/client, `apiClass` the client
|
|
49
|
+
* @returns the outcome, or `undefined` to DEFER to the next tier (a per-apiClass classifier defers
|
|
50
|
+
* to the app default; the app default defers to the webpieces built-in). Deferring is what
|
|
51
|
+
* makes classifiers additive AND override-capable.
|
|
52
|
+
*/
|
|
53
|
+
classify(error: Error, methodInfo: ApiMethodInfo): CallOutcome | undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A per-external-client registration pair: the {@link ApiMethodInfo.apiClass} to key on, and the
|
|
57
|
+
* {@link ErrorClassifier} to apply for it. Data only, so it is a class (not an inline object literal),
|
|
58
|
+
* per CLAUDE.md — handed to {@link ClientRegistry.addErrorClassifier}.
|
|
59
|
+
*/
|
|
60
|
+
export declare class KeyedErrorClassifier {
|
|
61
|
+
readonly apiClass: string;
|
|
62
|
+
readonly classifier: ErrorClassifier;
|
|
63
|
+
constructor(apiClass: string, classifier: ErrorClassifier);
|
|
64
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KeyedErrorClassifier = exports.CallOutcome = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The ONE verdict on a thrown API-call error. Three readers share it, so there is exactly one table
|
|
6
|
+
* answering "is this error expected, and is it worth repeating?":
|
|
7
|
+
*
|
|
8
|
+
* - `RetryStrategy` retries only {@link CallOutcome.TRANSIENT_FAILURE} (and only on a `READ` /
|
|
9
|
+
* `WRITE_IDEMPOTENT` endpoint — the ENDPOINT's safety is `CallContext.operation`'s question, never
|
|
10
|
+
* the classifier's);
|
|
11
|
+
* - `LogApiCall` logs {@link CallOutcome.EXPECTED} as `[API-*-resp-OTHER]` (`result='success'`) and
|
|
12
|
+
* both failure outcomes as `[API-*-resp-FAIL]` (`result='failure'`);
|
|
13
|
+
* - the MCP tool-call renderer derives its model-visible `retry` advice from the outcome plus the
|
|
14
|
+
* endpoint's operation.
|
|
15
|
+
*/
|
|
16
|
+
var CallOutcome;
|
|
17
|
+
(function (CallOutcome) {
|
|
18
|
+
/** It threw, but things are WORKING: a 266, a server-side 4xx, an external client's "404 = miss". */
|
|
19
|
+
CallOutcome["EXPECTED"] = "expected";
|
|
20
|
+
/** Broken NOW, may work if repeated: unavailable, connection, timeouts, 429/backoff, bad-gateway. */
|
|
21
|
+
CallOutcome["TRANSIENT_FAILURE"] = "transient";
|
|
22
|
+
/** Broken, and repeating will not help: a RECEIVED 4xx (our bug), implementation, 501, retries exhausted. */
|
|
23
|
+
CallOutcome["FAILURE"] = "failure";
|
|
24
|
+
})(CallOutcome || (exports.CallOutcome = CallOutcome = {}));
|
|
25
|
+
/**
|
|
26
|
+
* A per-external-client registration pair: the {@link ApiMethodInfo.apiClass} to key on, and the
|
|
27
|
+
* {@link ErrorClassifier} to apply for it. Data only, so it is a class (not an inline object literal),
|
|
28
|
+
* per CLAUDE.md — handed to {@link ClientRegistry.addErrorClassifier}.
|
|
29
|
+
*/
|
|
30
|
+
class KeyedErrorClassifier {
|
|
31
|
+
apiClass;
|
|
32
|
+
classifier;
|
|
33
|
+
constructor(apiClass, classifier) {
|
|
34
|
+
this.apiClass = apiClass;
|
|
35
|
+
this.classifier = classifier;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.KeyedErrorClassifier = KeyedErrorClassifier;
|
|
39
|
+
//# sourceMappingURL=ErrorClassifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ErrorClassifier.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ErrorClassifier.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;;;GAWG;AACH,IAAY,WAOX;AAPD,WAAY,WAAW;IACnB,qGAAqG;IACrG,oCAAqB,CAAA;IACrB,qGAAqG;IACrG,8CAA+B,CAAA;IAC/B,6GAA6G;IAC7G,kCAAmB,CAAA;AACvB,CAAC,EAPW,WAAW,2BAAX,WAAW,QAOtB;AAoCD;;;;GAIG;AACH,MAAa,oBAAoB;IAET;IACA;IAFpB,YACoB,QAAgB,EAChB,UAA2B;QAD3B,aAAQ,GAAR,QAAQ,CAAQ;QAChB,eAAU,GAAV,UAAU,CAAiB;IAC5C,CAAC;CACP;AALD,oDAKC","sourcesContent":["import { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * The ONE verdict on a thrown API-call error. Three readers share it, so there is exactly one table\n * answering \"is this error expected, and is it worth repeating?\":\n *\n * - `RetryStrategy` retries only {@link CallOutcome.TRANSIENT_FAILURE} (and only on a `READ` /\n * `WRITE_IDEMPOTENT` endpoint — the ENDPOINT's safety is `CallContext.operation`'s question, never\n * the classifier's);\n * - `LogApiCall` logs {@link CallOutcome.EXPECTED} as `[API-*-resp-OTHER]` (`result='success'`) and\n * both failure outcomes as `[API-*-resp-FAIL]` (`result='failure'`);\n * - the MCP tool-call renderer derives its model-visible `retry` advice from the outcome plus the\n * endpoint's operation.\n */\nexport enum CallOutcome {\n /** It threw, but things are WORKING: a 266, a server-side 4xx, an external client's \"404 = miss\". */\n EXPECTED = 'expected',\n /** Broken NOW, may work if repeated: unavailable, connection, timeouts, 429/backoff, bad-gateway. */\n TRANSIENT_FAILURE = 'transient',\n /** Broken, and repeating will not help: a RECEIVED 4xx (our bug), implementation, 501, retries exhausted. */\n FAILURE = 'failure',\n}\n\n/**\n * Classifies a thrown API-call error into a {@link CallOutcome}.\n *\n * This is BEHAVIOR, so it is an interface (per CLAUDE.md), passed as an object with a NAMED method\n * rather than a bare function — so \"find usages\" in an IDE lands on every implementation.\n *\n * Registered on {@link ClientRegistry} at startup — the same browser-safe, no-DI, populated-once\n * singleton that owns URL mappings and the {@link ErrorTranslator}. Resolved most-specific first by\n * {@link ClientRegistry.classify}:\n *\n * 1. `ClientRegistry.addErrorClassifier(new KeyedErrorClassifier(apiClass, c))` — per EXTERNAL\n * client, keyed by {@link ApiMethodInfo.apiClass} ('FirestoreAdminClient', 'ClaudeApi',\n * 'TwilioApi'). Each external API qualifies its errors differently (a Firestore 404 miss is\n * EXPECTED; a Twilio 429 is TRANSIENT), so it overrides the tiers below for that apiClass only.\n * 2. `ClientRegistry.setDefaultErrorClassifier(c)` — ONE per app/company. It reads\n * {@link ApiMethodInfo.side}, so a single classifier covers the SERVER router AND every INTERNAL\n * client (webpieces http client, cloud tasks).\n * 3. {@link WebpiecesDefaultErrorClassifier} — the terminal tier, which never defers.\n *\n * Internal clients and the server register NOTHING — the ABSENCE of an apiClass entry is the signal.\n * An external client that forgets to register is FAIL-SAFE: it falls to the tiers below, where every\n * client-side error except a 266 is a failure.\n */\nexport interface ErrorClassifier {\n /**\n * @param error - the already-normalized thrown error (callers pass `toError(err)`)\n * @param methodInfo - the call identity; `side` distinguishes server/client, `apiClass` the client\n * @returns the outcome, or `undefined` to DEFER to the next tier (a per-apiClass classifier defers\n * to the app default; the app default defers to the webpieces built-in). Deferring is what\n * makes classifiers additive AND override-capable.\n */\n classify(error: Error, methodInfo: ApiMethodInfo): CallOutcome | undefined;\n}\n\n/**\n * A per-external-client registration pair: the {@link ApiMethodInfo.apiClass} to key on, and the\n * {@link ErrorClassifier} to apply for it. Data only, so it is a class (not an inline object literal),\n * per CLAUDE.md — handed to {@link ClientRegistry.addErrorClassifier}.\n */\nexport class KeyedErrorClassifier {\n constructor(\n public readonly apiClass: string,\n public readonly classifier: ErrorClassifier,\n ) {}\n}\n"]}
|
package/src/http/LogApiCall.d.ts
CHANGED
|
@@ -28,8 +28,8 @@ import { ApiCallContext } from './ApiCallContext';
|
|
|
28
28
|
* Logging format patterns:
|
|
29
29
|
* - [API-{side}-req] ClassName.methodName request={...}
|
|
30
30
|
* - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}
|
|
31
|
-
* - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (
|
|
32
|
-
* - [API-{side}-resp-FAIL] ClassName.methodName error={...} (
|
|
31
|
+
* - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (CallOutcome.EXPECTED)
|
|
32
|
+
* - [API-{side}-resp-FAIL] ClassName.methodName error={...} (TRANSIENT_FAILURE or FAILURE — final by position; each retried attempt is logged by CallRegistry as [API-client-attempt-TRANSIENT])
|
|
33
33
|
*/
|
|
34
34
|
export declare class LogApiCallImpl {
|
|
35
35
|
private readonly ctx;
|
|
@@ -111,20 +111,4 @@ export declare class LogApiCallImpl {
|
|
|
111
111
|
* and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').
|
|
112
112
|
*/
|
|
113
113
|
private byteSize;
|
|
114
|
-
/**
|
|
115
|
-
* Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api
|
|
116
|
-
* result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?
|
|
117
|
-
*
|
|
118
|
-
* BACK-COMPAT SHIM: the canonical logic now lives in {@link WebpiecesDefaultFailureClassifier}
|
|
119
|
-
* (the webpieces built-in tier), and the LIVE classification path is
|
|
120
|
-
* {@link ClientRegistry.classifyFailure} (per-apiClass → app default → built-in). This method
|
|
121
|
-
* delegates to the built-in so existing callers/tests keep the exact old behavior; it does NOT
|
|
122
|
-
* consult registered classifiers. `apiClass`/`methodName` are irrelevant to the built-in (it reads
|
|
123
|
-
* only `side`), hence the empty strings.
|
|
124
|
-
*
|
|
125
|
-
* @param error - The already-normalized error (callers pass toError(err), never a raw catch value)
|
|
126
|
-
* @param server - True when this side is the SERVER handling an inbound call; false for a CLIENT's outbound call
|
|
127
|
-
* @returns true if this should be treated as a non-failure (OTHER / result:'success')
|
|
128
|
-
*/
|
|
129
|
-
isUserError(error: Error, server: boolean): boolean;
|
|
130
114
|
}
|
package/src/http/LogApiCall.js
CHANGED
|
@@ -4,11 +4,10 @@ exports.LogApiCallImpl = void 0;
|
|
|
4
4
|
const errorUtils_1 = require("../lib/errorUtils");
|
|
5
5
|
const LogManager_1 = require("../logging/LogManager");
|
|
6
6
|
const ApiCallInfo_1 = require("./ApiCallInfo");
|
|
7
|
-
const ApiMethodInfo_1 = require("./ApiMethodInfo");
|
|
8
7
|
const WebpiecesCoreHeaders_1 = require("./WebpiecesCoreHeaders");
|
|
9
8
|
const ApiCallLogName_1 = require("./ApiCallLogName");
|
|
10
9
|
const ClientRegistry_1 = require("./ClientRegistry");
|
|
11
|
-
const
|
|
10
|
+
const ErrorClassifier_1 = require("./ErrorClassifier");
|
|
12
11
|
// The console backends special-case THIS logger name into a self-describing [API.{side}.{phase}]
|
|
13
12
|
// bracket (see ApiCallLogName) — so the name here and the name they match are the one constant.
|
|
14
13
|
const log = LogManager_1.LogManager.getLogger(ApiCallLogName_1.LOG_API_CALL_LOGGER_NAME);
|
|
@@ -40,8 +39,8 @@ const log = LogManager_1.LogManager.getLogger(ApiCallLogName_1.LOG_API_CALL_LOGG
|
|
|
40
39
|
* Logging format patterns:
|
|
41
40
|
* - [API-{side}-req] ClassName.methodName request={...}
|
|
42
41
|
* - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}
|
|
43
|
-
* - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (
|
|
44
|
-
* - [API-{side}-resp-FAIL] ClassName.methodName error={...} (
|
|
42
|
+
* - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (CallOutcome.EXPECTED)
|
|
43
|
+
* - [API-{side}-resp-FAIL] ClassName.methodName error={...} (TRANSIENT_FAILURE or FAILURE — final by position; each retried attempt is logged by CallRegistry as [API-client-attempt-TRANSIENT])
|
|
45
44
|
*/
|
|
46
45
|
class LogApiCallImpl {
|
|
47
46
|
ctx;
|
|
@@ -224,8 +223,10 @@ class LogApiCallImpl {
|
|
|
224
223
|
// Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,
|
|
225
224
|
// else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER
|
|
226
225
|
// raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is
|
|
227
|
-
// never a failure either side).
|
|
228
|
-
|
|
226
|
+
// never a failure either side). This line is FINAL BY POSITION: it only sees what escaped every
|
|
227
|
+
// retry, so a TRANSIENT_FAILURE here was never retried (no strategy, or a WRITE) or gave up —
|
|
228
|
+
// it logs FAIL exactly like a FAILURE. Only EXPECTED is "OTHER / result:'success'".
|
|
229
|
+
const isUser = ClientRegistry_1.ClientRegistry.classify(error, methodInfo) === ErrorClassifier_1.CallOutcome.EXPECTED;
|
|
229
230
|
stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure', durationMs, requestSize), () => isUser
|
|
230
231
|
? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType} error=${error.message}`)
|
|
231
232
|
: log.error(`[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`));
|
|
@@ -246,24 +247,6 @@ class LogApiCallImpl {
|
|
|
246
247
|
}
|
|
247
248
|
return bytes;
|
|
248
249
|
}
|
|
249
|
-
/**
|
|
250
|
-
* Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api
|
|
251
|
-
* result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?
|
|
252
|
-
*
|
|
253
|
-
* BACK-COMPAT SHIM: the canonical logic now lives in {@link WebpiecesDefaultFailureClassifier}
|
|
254
|
-
* (the webpieces built-in tier), and the LIVE classification path is
|
|
255
|
-
* {@link ClientRegistry.classifyFailure} (per-apiClass → app default → built-in). This method
|
|
256
|
-
* delegates to the built-in so existing callers/tests keep the exact old behavior; it does NOT
|
|
257
|
-
* consult registered classifiers. `apiClass`/`methodName` are irrelevant to the built-in (it reads
|
|
258
|
-
* only `side`), hence the empty strings.
|
|
259
|
-
*
|
|
260
|
-
* @param error - The already-normalized error (callers pass toError(err), never a raw catch value)
|
|
261
|
-
* @param server - True when this side is the SERVER handling an inbound call; false for a CLIENT's outbound call
|
|
262
|
-
* @returns true if this should be treated as a non-failure (OTHER / result:'success')
|
|
263
|
-
*/
|
|
264
|
-
isUserError(error, server) {
|
|
265
|
-
return !WebpiecesDefaultFailureClassifier_1.WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(error, new ApiMethodInfo_1.ApiMethodInfo(server ? 'server' : 'client', '', ''));
|
|
266
|
-
}
|
|
267
250
|
}
|
|
268
251
|
exports.LogApiCallImpl = LogApiCallImpl;
|
|
269
252
|
//# sourceMappingURL=LogApiCall.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":"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","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { LogManager } from '../logging/LogManager';\nimport { ApiCallInfo } from './ApiCallInfo';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { ApiCallContext } from './ApiCallContext';\nimport { WebpiecesCoreHeaders } from './WebpiecesCoreHeaders';\nimport { LOG_API_CALL_LOGGER_NAME } from './ApiCallLogName';\nimport { ClientRegistry } from './ClientRegistry';\nimport { WEBPIECES_DEFAULT_FAILURE_CLASSIFIER } from './WebpiecesDefaultFailureClassifier';\n\n// The console backends special-case THIS logger name into a self-describing [API.{side}.{phase}]\n// bracket (see ApiCallLogName) — so the name here and the name they match are the one constant.\nconst log = LogManager.getLogger(LOG_API_CALL_LOGGER_NAME);\n\n/**\n * LogApiCallImpl - Generic API call logging utility, used by BOTH server-side (LogApiFilter) and\n * client-side (ProxyClient) for one consistent logging shape across the framework.\n *\n * TWO things happen around each call:\n * 1. Text lines are emitted (the human-readable `[API-...]` patterns below).\n * 2. A structured {@link ApiCallInfo} tag is stamped into the ambient request context via the\n * {@link ApiCallContext} seam, so EVERY log line emitted during the call (not just the\n * req/resp lines) inherits a filterable `api` object — surfacing in GCP as\n * `jsonPayload.api.{method.{side,apiClass,methodName,controllerName},type,result}`.\n *\n * BROWSER-SAFE: this lives in core-util and runs in the browser bundle (via ProxyClient →\n * BrowserProxyClient), so it MUST NOT import `RequestContext` (Node async_hooks, and a circular dep).\n * It stamps through the {@link ApiCallContext} seam instead, and takes that seam as a REQUIRED\n * CONSTRUCTOR ARGUMENT — there is no process-global holder to install and none to forget. Each\n * environment-specific package constructs its own:\n *\n * LogApiFilter (@webpieces/http-routing) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * NodeProxyClient (@webpieces/http-client-node) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * TaskProxyClient (@webpieces/cloudtasks-client) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * BrowserProxyClient (@webpieces/http-client-browser) -> new LogApiCallImpl(new BrowserApiCallContext())\n *\n * NOT a singleton, deliberately: a shared instance would need a shared context, which is the global\n * this constructor replaced. Construct one where you know which environment you are in.\n *\n * Logging format patterns:\n * - [API-{side}-req] ClassName.methodName request={...}\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (user errors)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (server errors)\n */\nexport class LogApiCallImpl {\n /**\n * @param ctx - the environment's {@link ApiCallContext}. REQUIRED, with no default: that is what\n * turns \"nobody bootstrapped the context\" into a compile error instead of a throw on the first\n * real call in production.\n */\n constructor(private readonly ctx: ApiCallContext) {}\n\n /**\n * Execute an API call with logging + `api` context-tagging around it.\n *\n * @param methodInfo - The transport-neutral call identity (side, apiClass, methodName,\n * controllerName?). `apiClass` is what matches a client call to its server handler in the logs.\n * @param requestDto - The request DTO (external multi-param callers synthesize a small object)\n * @param method - The method to execute\n *\n * Correlation fields (requestId, tenantId, ...) are NOT stamped here — a logging BACKEND owns that,\n * reading RequestContext on every record. What IS stamped here is the per-call `api` tag, and only\n * for the SYNCHRONOUS span of each log line: set → log → remove. Because the tag is never held across\n * `await method(...)`, a concurrent browser call (single-threaded, one global slot) can never clobber\n * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is\n * exactly what the GCP filters (`jsonPayload.api.*`) want.\n */\n public async execute<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n responseCount?: (response: R) => number | undefined,\n ): Promise<R> {\n if (methodInfo.noLogging) {\n return this.executeUnlogged(methodInfo, requestDto, method);\n }\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const stamp = this.stamper(this.activeContext());\n\n // Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a\n // large DTO purely to measure it would double the cost of the thing we are measuring.\n // Only take the field-masking hit when this call declared sensitive fields; otherwise the plain\n // JSON.stringify fast path, unchanged for every existing caller.\n const requestBody = this.serialize(requestDto, methodInfo);\n const requestSize = this.byteSize(requestBody);\n // Declared out here so the catch below can read it too. Reassigned just before the call, so\n // the number times ONLY the call and not our own request-logging.\n let startMs = Date.now();\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- LogApiCall logs errors before re-throwing to caller\n try {\n stamp(new ApiCallInfo(methodInfo, 'request', undefined, undefined, requestSize), () =>\n log.info(`[API-${side}-req] ${id} request=${requestBody}`),\n );\n\n if (!requestDto) throw new Error(`Request cannot be null and was from ${id}`);\n\n startMs = Date.now();\n const response = await method(requestDto);\n const durationMs = Date.now() - startMs;\n\n const responseBody = this.serialize(response, methodInfo);\n const count = this.selectResponseCount(response, responseCount, side, id);\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n 'success',\n durationMs,\n requestSize,\n this.byteSize(responseBody),\n count,\n ),\n () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`),\n );\n\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n // Duration comes off the SAME start as the success path, so a slow failure (a timeout, a\n // hung dependency) reports its real cost rather than nothing.\n this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);\n throw err;\n }\n }\n\n /**\n * Build the tag-and-emit span used around every `[API-*]` line.\n *\n * set → emit → remove, as ONE synchronous span: the tag is live only while the logger reads it,\n * never across an await, so a single browser global slot can never be clobbered by a concurrent\n * call. `ctx` is passed in (rather than read from `this`) so the caller has already proven it is\n * ACTIVE before anything is stamped.\n */\n private stamper(ctx: ApiCallContext): (info: ApiCallInfo, emit: () => void) => void {\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n return (info: ApiCallInfo, emit: () => void): void => {\n ctx.set(key, info);\n // webpieces-disable no-unmanaged-exceptions -- cleanup must run when a logging backend throws\n try {\n emit();\n } finally {\n ctx.remove(key);\n }\n };\n }\n\n /**\n * The `@Endpoint(..., { noLogging: true })` path: run the call and say NOTHING about it.\n *\n * No `[API-*]` line, no {@link ApiCallInfo} stamp, and no serialization of either DTO — a\n * noLogging route is a log shipper or a log-ingest endpoint, so a line describing the call IS the\n * thing being shipped, and emitting it feeds the next batch its own payload (#976).\n *\n * It deliberately does NOT call {@link activeContext}: that guard exists because a call with\n * nowhere to STAMP a tag is a bug, and this path stamps nothing. A noLogging call is therefore\n * legal outside a RequestContext scope, which is exactly where a flusher tends to run.\n *\n * The null-request check is kept, because it is a contract error and not observability — a\n * noLogging route deserves the same error as every other route, and the throw propagates to the caller\n * unlogged here, exactly as the logged path re-throws.\n *\n * `responseCount` is not taken: it feeds only the `api.responseCount` tag, which is not emitted.\n */\n private async executeUnlogged<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n ): Promise<R> {\n if (!requestDto) {\n throw new Error(\n `Request cannot be null and was from ${methodInfo.apiClass}.${methodInfo.methodName}`,\n );\n }\n return method(requestDto);\n }\n\n /**\n * Evaluate an opt-in logical-item count without allowing observability code to affect the call.\n * Warnings deliberately contain neither request nor response bodies (nor the invalid value).\n */\n private selectResponseCount<R>(\n response: R,\n selector: ((response: R) => number | undefined) | undefined,\n side: string,\n id: string,\n ): number | undefined {\n if (!selector) {\n return undefined;\n }\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an observability callback must not fail the API call\n try {\n const count = selector(response);\n if (\n count === undefined ||\n (Number.isFinite(count) && Number.isInteger(count) && count >= 0)\n ) {\n return count;\n }\n this.warnInvalidResponseCount(side, id, 'returned an invalid value');\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n this.warnInvalidResponseCount(side, id, 'threw');\n }\n return undefined;\n }\n\n private warnInvalidResponseCount(side: string, id: string, reason: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a warning backend must not replace a successful API response\n try {\n log.warn(\n `[API-${side}-resp-COUNT-WARN] ${id} responseCount selector ${reason}; omitting responseCount`,\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // The API response is authoritative; observability failures are intentionally ignored here.\n }\n }\n\n /**\n * Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify\n * (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs\n * {@link MaskSpec.stringify}, which produces a masked STRING without ever mutating the DTO — so the\n * object handed to the transport, and thus the value ON THE WIRE, is unchanged.\n */\n private serialize(\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches execute)\n dto: unknown,\n methodInfo: ApiMethodInfo,\n ): string | undefined {\n return methodInfo.mask ? methodInfo.mask.stringify(dto) : JSON.stringify(dto);\n }\n\n /**\n * The ApiCallContext to stamp into. It cannot be MISSING (it is a constructor argument), but it\n * can be INACTIVE — a Node context used outside any `RequestContext.run(...)` scope. That throws:\n * an api call with nowhere to tag is a bug.\n */\n private activeContext(): ApiCallContext {\n const ctx = this.ctx;\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside a ' +\n 'RequestContext.run(...) scope — a server filter opens one per request, and a ' +\n 'non-webpieces host must open one around the work that calls a webpieces client. ' +\n '(A BrowserApiCallContext is always active, so this can only be the Node side.)',\n );\n }\n return ctx;\n }\n\n /**\n * Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.\n */\n private logFailure(\n error: Error,\n methodInfo: ApiMethodInfo,\n durationMs: number,\n requestSize: number | undefined,\n stamp: (info: ApiCallInfo, emit: () => void) => void,\n ): void {\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const errorType = error.constructor.name;\n // The MESSAGE is on BOTH branches, and that is load-bearing: this is the ONLY operator line\n // per failure now that `ApiErrorBoundary` writes none. An operator error message is never\n // published to a caller (see `ApiErrorCodec.publicMessage`), so the log is the one place it\n // exists at all — dropping it on the caller-error branch would lose it outright.\n // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,\n // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER\n // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is\n // never a failure either side). `isUser` = \"treat as non-failure (OTHER / result:'success')\".\n const isUser = !ClientRegistry.classifyFailure(error, methodInfo);\n\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n isUser ? 'success' : 'failure',\n durationMs,\n requestSize,\n ),\n () =>\n isUser\n ? log.warn(\n `[API-${side}-resp-OTHER] ${id} errorType=${errorType} error=${error.message}`,\n )\n : log.error(\n `[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`,\n ),\n );\n }\n\n /**\n * UTF-8 byte size without platform-specific encoding globals: LogApiCall runs in the\n * browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,\n * and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').\n */\n private byteSize(serialized: string | undefined): number | undefined {\n if (serialized === undefined) {\n return undefined;\n }\n let bytes = 0;\n for (const character of serialized) {\n const code = character.codePointAt(0)!;\n bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;\n }\n return bytes;\n }\n\n /**\n * Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api\n * result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?\n *\n * BACK-COMPAT SHIM: the canonical logic now lives in {@link WebpiecesDefaultFailureClassifier}\n * (the webpieces built-in tier), and the LIVE classification path is\n * {@link ClientRegistry.classifyFailure} (per-apiClass → app default → built-in). This method\n * delegates to the built-in so existing callers/tests keep the exact old behavior; it does NOT\n * consult registered classifiers. `apiClass`/`methodName` are irrelevant to the built-in (it reads\n * only `side`), hence the empty strings.\n *\n * @param error - The already-normalized error (callers pass toError(err), never a raw catch value)\n * @param server - True when this side is the SERVER handling an inbound call; false for a CLIENT's outbound call\n * @returns true if this should be treated as a non-failure (OTHER / result:'success')\n */\n isUserError(error: Error, server: boolean): boolean {\n return !WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(\n error,\n new ApiMethodInfo(server ? 'server' : 'client', '', ''),\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":"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","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { LogManager } from '../logging/LogManager';\nimport { ApiCallInfo } from './ApiCallInfo';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { ApiCallContext } from './ApiCallContext';\nimport { WebpiecesCoreHeaders } from './WebpiecesCoreHeaders';\nimport { LOG_API_CALL_LOGGER_NAME } from './ApiCallLogName';\nimport { ClientRegistry } from './ClientRegistry';\nimport { CallOutcome } from './ErrorClassifier';\n\n// The console backends special-case THIS logger name into a self-describing [API.{side}.{phase}]\n// bracket (see ApiCallLogName) — so the name here and the name they match are the one constant.\nconst log = LogManager.getLogger(LOG_API_CALL_LOGGER_NAME);\n\n/**\n * LogApiCallImpl - Generic API call logging utility, used by BOTH server-side (LogApiFilter) and\n * client-side (ProxyClient) for one consistent logging shape across the framework.\n *\n * TWO things happen around each call:\n * 1. Text lines are emitted (the human-readable `[API-...]` patterns below).\n * 2. A structured {@link ApiCallInfo} tag is stamped into the ambient request context via the\n * {@link ApiCallContext} seam, so EVERY log line emitted during the call (not just the\n * req/resp lines) inherits a filterable `api` object — surfacing in GCP as\n * `jsonPayload.api.{method.{side,apiClass,methodName,controllerName},type,result}`.\n *\n * BROWSER-SAFE: this lives in core-util and runs in the browser bundle (via ProxyClient →\n * BrowserProxyClient), so it MUST NOT import `RequestContext` (Node async_hooks, and a circular dep).\n * It stamps through the {@link ApiCallContext} seam instead, and takes that seam as a REQUIRED\n * CONSTRUCTOR ARGUMENT — there is no process-global holder to install and none to forget. Each\n * environment-specific package constructs its own:\n *\n * LogApiFilter (@webpieces/http-routing) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * NodeProxyClient (@webpieces/http-client-node) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * TaskProxyClient (@webpieces/cloudtasks-client) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * BrowserProxyClient (@webpieces/http-client-browser) -> new LogApiCallImpl(new BrowserApiCallContext())\n *\n * NOT a singleton, deliberately: a shared instance would need a shared context, which is the global\n * this constructor replaced. Construct one where you know which environment you are in.\n *\n * Logging format patterns:\n * - [API-{side}-req] ClassName.methodName request={...}\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} error={...} (CallOutcome.EXPECTED)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (TRANSIENT_FAILURE or FAILURE — final by position; each retried attempt is logged by CallRegistry as [API-client-attempt-TRANSIENT])\n */\nexport class LogApiCallImpl {\n /**\n * @param ctx - the environment's {@link ApiCallContext}. REQUIRED, with no default: that is what\n * turns \"nobody bootstrapped the context\" into a compile error instead of a throw on the first\n * real call in production.\n */\n constructor(private readonly ctx: ApiCallContext) {}\n\n /**\n * Execute an API call with logging + `api` context-tagging around it.\n *\n * @param methodInfo - The transport-neutral call identity (side, apiClass, methodName,\n * controllerName?). `apiClass` is what matches a client call to its server handler in the logs.\n * @param requestDto - The request DTO (external multi-param callers synthesize a small object)\n * @param method - The method to execute\n *\n * Correlation fields (requestId, tenantId, ...) are NOT stamped here — a logging BACKEND owns that,\n * reading RequestContext on every record. What IS stamped here is the per-call `api` tag, and only\n * for the SYNCHRONOUS span of each log line: set → log → remove. Because the tag is never held across\n * `await method(...)`, a concurrent browser call (single-threaded, one global slot) can never clobber\n * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is\n * exactly what the GCP filters (`jsonPayload.api.*`) want.\n */\n public async execute<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n responseCount?: (response: R) => number | undefined,\n ): Promise<R> {\n if (methodInfo.noLogging) {\n return this.executeUnlogged(methodInfo, requestDto, method);\n }\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const stamp = this.stamper(this.activeContext());\n\n // Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a\n // large DTO purely to measure it would double the cost of the thing we are measuring.\n // Only take the field-masking hit when this call declared sensitive fields; otherwise the plain\n // JSON.stringify fast path, unchanged for every existing caller.\n const requestBody = this.serialize(requestDto, methodInfo);\n const requestSize = this.byteSize(requestBody);\n // Declared out here so the catch below can read it too. Reassigned just before the call, so\n // the number times ONLY the call and not our own request-logging.\n let startMs = Date.now();\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- LogApiCall logs errors before re-throwing to caller\n try {\n stamp(new ApiCallInfo(methodInfo, 'request', undefined, undefined, requestSize), () =>\n log.info(`[API-${side}-req] ${id} request=${requestBody}`),\n );\n\n if (!requestDto) throw new Error(`Request cannot be null and was from ${id}`);\n\n startMs = Date.now();\n const response = await method(requestDto);\n const durationMs = Date.now() - startMs;\n\n const responseBody = this.serialize(response, methodInfo);\n const count = this.selectResponseCount(response, responseCount, side, id);\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n 'success',\n durationMs,\n requestSize,\n this.byteSize(responseBody),\n count,\n ),\n () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`),\n );\n\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n // Duration comes off the SAME start as the success path, so a slow failure (a timeout, a\n // hung dependency) reports its real cost rather than nothing.\n this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);\n throw err;\n }\n }\n\n /**\n * Build the tag-and-emit span used around every `[API-*]` line.\n *\n * set → emit → remove, as ONE synchronous span: the tag is live only while the logger reads it,\n * never across an await, so a single browser global slot can never be clobbered by a concurrent\n * call. `ctx` is passed in (rather than read from `this`) so the caller has already proven it is\n * ACTIVE before anything is stamped.\n */\n private stamper(ctx: ApiCallContext): (info: ApiCallInfo, emit: () => void) => void {\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n return (info: ApiCallInfo, emit: () => void): void => {\n ctx.set(key, info);\n // webpieces-disable no-unmanaged-exceptions -- cleanup must run when a logging backend throws\n try {\n emit();\n } finally {\n ctx.remove(key);\n }\n };\n }\n\n /**\n * The `@Endpoint(..., { noLogging: true })` path: run the call and say NOTHING about it.\n *\n * No `[API-*]` line, no {@link ApiCallInfo} stamp, and no serialization of either DTO — a\n * noLogging route is a log shipper or a log-ingest endpoint, so a line describing the call IS the\n * thing being shipped, and emitting it feeds the next batch its own payload (#976).\n *\n * It deliberately does NOT call {@link activeContext}: that guard exists because a call with\n * nowhere to STAMP a tag is a bug, and this path stamps nothing. A noLogging call is therefore\n * legal outside a RequestContext scope, which is exactly where a flusher tends to run.\n *\n * The null-request check is kept, because it is a contract error and not observability — a\n * noLogging route deserves the same error as every other route, and the throw propagates to the caller\n * unlogged here, exactly as the logged path re-throws.\n *\n * `responseCount` is not taken: it feeds only the `api.responseCount` tag, which is not emitted.\n */\n private async executeUnlogged<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n ): Promise<R> {\n if (!requestDto) {\n throw new Error(\n `Request cannot be null and was from ${methodInfo.apiClass}.${methodInfo.methodName}`,\n );\n }\n return method(requestDto);\n }\n\n /**\n * Evaluate an opt-in logical-item count without allowing observability code to affect the call.\n * Warnings deliberately contain neither request nor response bodies (nor the invalid value).\n */\n private selectResponseCount<R>(\n response: R,\n selector: ((response: R) => number | undefined) | undefined,\n side: string,\n id: string,\n ): number | undefined {\n if (!selector) {\n return undefined;\n }\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an observability callback must not fail the API call\n try {\n const count = selector(response);\n if (\n count === undefined ||\n (Number.isFinite(count) && Number.isInteger(count) && count >= 0)\n ) {\n return count;\n }\n this.warnInvalidResponseCount(side, id, 'returned an invalid value');\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n this.warnInvalidResponseCount(side, id, 'threw');\n }\n return undefined;\n }\n\n private warnInvalidResponseCount(side: string, id: string, reason: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a warning backend must not replace a successful API response\n try {\n log.warn(\n `[API-${side}-resp-COUNT-WARN] ${id} responseCount selector ${reason}; omitting responseCount`,\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // The API response is authoritative; observability failures are intentionally ignored here.\n }\n }\n\n /**\n * Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify\n * (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs\n * {@link MaskSpec.stringify}, which produces a masked STRING without ever mutating the DTO — so the\n * object handed to the transport, and thus the value ON THE WIRE, is unchanged.\n */\n private serialize(\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches execute)\n dto: unknown,\n methodInfo: ApiMethodInfo,\n ): string | undefined {\n return methodInfo.mask ? methodInfo.mask.stringify(dto) : JSON.stringify(dto);\n }\n\n /**\n * The ApiCallContext to stamp into. It cannot be MISSING (it is a constructor argument), but it\n * can be INACTIVE — a Node context used outside any `RequestContext.run(...)` scope. That throws:\n * an api call with nowhere to tag is a bug.\n */\n private activeContext(): ApiCallContext {\n const ctx = this.ctx;\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside a ' +\n 'RequestContext.run(...) scope — a server filter opens one per request, and a ' +\n 'non-webpieces host must open one around the work that calls a webpieces client. ' +\n '(A BrowserApiCallContext is always active, so this can only be the Node side.)',\n );\n }\n return ctx;\n }\n\n /**\n * Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.\n */\n private logFailure(\n error: Error,\n methodInfo: ApiMethodInfo,\n durationMs: number,\n requestSize: number | undefined,\n stamp: (info: ApiCallInfo, emit: () => void) => void,\n ): void {\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const errorType = error.constructor.name;\n // The MESSAGE is on BOTH branches, and that is load-bearing: this is the ONLY operator line\n // per failure now that `ApiErrorBoundary` writes none. An operator error message is never\n // published to a caller (see `ApiErrorCodec.publicMessage`), so the log is the one place it\n // exists at all — dropping it on the caller-error branch would lose it outright.\n // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,\n // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER\n // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is\n // never a failure either side). This line is FINAL BY POSITION: it only sees what escaped every\n // retry, so a TRANSIENT_FAILURE here was never retried (no strategy, or a WRITE) or gave up —\n // it logs FAIL exactly like a FAILURE. Only EXPECTED is \"OTHER / result:'success'\".\n const isUser = ClientRegistry.classify(error, methodInfo) === CallOutcome.EXPECTED;\n\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n isUser ? 'success' : 'failure',\n durationMs,\n requestSize,\n ),\n () =>\n isUser\n ? log.warn(\n `[API-${side}-resp-OTHER] ${id} errorType=${errorType} error=${error.message}`,\n )\n : log.error(\n `[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`,\n ),\n );\n }\n\n /**\n * UTF-8 byte size without platform-specific encoding globals: LogApiCall runs in the\n * browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,\n * and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').\n */\n private byteSize(serialized: string | undefined): number | undefined {\n if (serialized === undefined) {\n return undefined;\n }\n let bytes = 0;\n for (const character of serialized) {\n const code = character.codePointAt(0)!;\n bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;\n }\n return bytes;\n }\n}\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CallStrategy } from './CallStrategy';
|
|
2
|
+
/**
|
|
3
|
+
* THE webpieces retry strategy, shared by every transport — register its {@link RetryStrategy.run}
|
|
4
|
+
* with `CallRegistry.setStrategy(...)`, or hand it to a `TransportDefault`.
|
|
5
|
+
*
|
|
6
|
+
* Two questions, two owners, and it asks both:
|
|
7
|
+
*
|
|
8
|
+
* - **the ERROR** — `ClientRegistry.classify(error, …)`. Only `CallOutcome.TRANSIENT_FAILURE` is
|
|
9
|
+
* retried; an EXPECTED or FAILURE outcome is a definitive answer and is rethrown unchanged.
|
|
10
|
+
* - **the ENDPOINT** — `CallContext.operation`. A `WRITE` is NEVER retried, even when this strategy is
|
|
11
|
+
* registered at ALL: a transient error on a non-idempotent write may already have happened.
|
|
12
|
+
*
|
|
13
|
+
* Each attempt gets its own `attemptTimeoutMs` deadline. Between attempts it waits
|
|
14
|
+
* `initialDelayMs`, then multiplies by `delayMultiplier`, capped at `maxDelayMs`.
|
|
15
|
+
*
|
|
16
|
+
* When it RETRIED and ran out, it throws {@link ApiRetriesExhaustedError} (`cause` = the last
|
|
17
|
+
* attempt's error), which is classified FAILURE so no layer above retries again. A call that made a
|
|
18
|
+
* single attempt — `maxRetries` 0, a `WRITE`, a non-transient error — rethrows its own error unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export declare class RetryStrategy {
|
|
21
|
+
readonly attemptTimeoutMs: number;
|
|
22
|
+
readonly maxRetries: number;
|
|
23
|
+
readonly initialDelayMs: number;
|
|
24
|
+
readonly delayMultiplier: number;
|
|
25
|
+
readonly maxDelayMs: number;
|
|
26
|
+
/** The {@link CallStrategy} to register. A bound field, so `strategy.run` can be passed around. */
|
|
27
|
+
readonly run: CallStrategy<unknown>;
|
|
28
|
+
constructor(attemptTimeoutMs: number, maxRetries: number, initialDelayMs: number, delayMultiplier: number, maxDelayMs: number);
|
|
29
|
+
private execute;
|
|
30
|
+
private static requireWhole;
|
|
31
|
+
private static sleep;
|
|
32
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RetryStrategy = void 0;
|
|
4
|
+
const CallDeadline_1 = require("./CallDeadline");
|
|
5
|
+
const ClientRegistry_1 = require("./ClientRegistry");
|
|
6
|
+
const ErrorClassifier_1 = require("./ErrorClassifier");
|
|
7
|
+
const ApiMethodInfo_1 = require("./ApiMethodInfo");
|
|
8
|
+
const HttpEndpointOptions_1 = require("./HttpEndpointOptions");
|
|
9
|
+
const ApiError_1 = require("../errors/ApiError");
|
|
10
|
+
const errorUtils_1 = require("../lib/errorUtils");
|
|
11
|
+
/**
|
|
12
|
+
* THE webpieces retry strategy, shared by every transport — register its {@link RetryStrategy.run}
|
|
13
|
+
* with `CallRegistry.setStrategy(...)`, or hand it to a `TransportDefault`.
|
|
14
|
+
*
|
|
15
|
+
* Two questions, two owners, and it asks both:
|
|
16
|
+
*
|
|
17
|
+
* - **the ERROR** — `ClientRegistry.classify(error, …)`. Only `CallOutcome.TRANSIENT_FAILURE` is
|
|
18
|
+
* retried; an EXPECTED or FAILURE outcome is a definitive answer and is rethrown unchanged.
|
|
19
|
+
* - **the ENDPOINT** — `CallContext.operation`. A `WRITE` is NEVER retried, even when this strategy is
|
|
20
|
+
* registered at ALL: a transient error on a non-idempotent write may already have happened.
|
|
21
|
+
*
|
|
22
|
+
* Each attempt gets its own `attemptTimeoutMs` deadline. Between attempts it waits
|
|
23
|
+
* `initialDelayMs`, then multiplies by `delayMultiplier`, capped at `maxDelayMs`.
|
|
24
|
+
*
|
|
25
|
+
* When it RETRIED and ran out, it throws {@link ApiRetriesExhaustedError} (`cause` = the last
|
|
26
|
+
* attempt's error), which is classified FAILURE so no layer above retries again. A call that made a
|
|
27
|
+
* single attempt — `maxRetries` 0, a `WRITE`, a non-transient error — rethrows its own error unchanged.
|
|
28
|
+
*/
|
|
29
|
+
class RetryStrategy {
|
|
30
|
+
attemptTimeoutMs;
|
|
31
|
+
maxRetries;
|
|
32
|
+
initialDelayMs;
|
|
33
|
+
delayMultiplier;
|
|
34
|
+
maxDelayMs;
|
|
35
|
+
/** The {@link CallStrategy} to register. A bound field, so `strategy.run` can be passed around. */
|
|
36
|
+
// webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs
|
|
37
|
+
run;
|
|
38
|
+
constructor(attemptTimeoutMs, maxRetries, initialDelayMs, delayMultiplier, maxDelayMs) {
|
|
39
|
+
this.attemptTimeoutMs = attemptTimeoutMs;
|
|
40
|
+
this.maxRetries = maxRetries;
|
|
41
|
+
this.initialDelayMs = initialDelayMs;
|
|
42
|
+
this.delayMultiplier = delayMultiplier;
|
|
43
|
+
this.maxDelayMs = maxDelayMs;
|
|
44
|
+
CallDeadline_1.CallDeadline.validate(attemptTimeoutMs);
|
|
45
|
+
RetryStrategy.requireWhole('maxRetries', maxRetries);
|
|
46
|
+
RetryStrategy.requireWhole('initialDelayMs', initialDelayMs);
|
|
47
|
+
RetryStrategy.requireWhole('maxDelayMs', maxDelayMs);
|
|
48
|
+
if (!Number.isFinite(delayMultiplier) || delayMultiplier < 1) {
|
|
49
|
+
throw new RangeError('delayMultiplier must be finite and at least 1');
|
|
50
|
+
}
|
|
51
|
+
// webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs
|
|
52
|
+
this.run = (call, ctx) => this.execute(call, ctx);
|
|
53
|
+
}
|
|
54
|
+
// webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs
|
|
55
|
+
async execute(call, ctx) {
|
|
56
|
+
const methodInfo = new ApiMethodInfo_1.ApiMethodInfo('client', ctx.apiName, ctx.methodName);
|
|
57
|
+
let delayMs = this.initialDelayMs;
|
|
58
|
+
for (let attempt = 1;; attempt++) {
|
|
59
|
+
// webpieces-disable no-unmanaged-exceptions -- a strategy decides per failure whether to retry
|
|
60
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
61
|
+
try {
|
|
62
|
+
return await call(this.attemptTimeoutMs);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const error = (0, errorUtils_1.toError)(err);
|
|
66
|
+
const retryable = ctx.operation !== HttpEndpointOptions_1.WRITE &&
|
|
67
|
+
ClientRegistry_1.ClientRegistry.classify(error, methodInfo) === ErrorClassifier_1.CallOutcome.TRANSIENT_FAILURE;
|
|
68
|
+
if (!retryable) {
|
|
69
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a definitive answer is rethrown unchanged
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
if (attempt > this.maxRetries) {
|
|
73
|
+
if (attempt === 1) {
|
|
74
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- never retried, so nothing was exhausted
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
throw new ApiError_1.ApiRetriesExhaustedError(attempt, error);
|
|
78
|
+
}
|
|
79
|
+
await RetryStrategy.sleep(delayMs);
|
|
80
|
+
delayMs = Math.min(delayMs * this.delayMultiplier, this.maxDelayMs);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// webpieces-disable no-function-outside-class -- constructor argument guard
|
|
85
|
+
static requireWhole(name, value) {
|
|
86
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
87
|
+
throw new RangeError(`${name} must be a non-negative integer`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// webpieces-disable no-function-outside-class -- backoff timer
|
|
91
|
+
static sleep(ms) {
|
|
92
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.RetryStrategy = RetryStrategy;
|
|
96
|
+
//# sourceMappingURL=RetryStrategy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RetryStrategy.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/RetryStrategy.ts"],"names":[],"mappings":"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","sourcesContent":["import { Attempt, CallContext, CallStrategy } from './CallStrategy';\nimport { CallDeadline } from './CallDeadline';\nimport { ClientRegistry } from './ClientRegistry';\nimport { CallOutcome } from './ErrorClassifier';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { WRITE } from './HttpEndpointOptions';\nimport { ApiRetriesExhaustedError } from '../errors/ApiError';\nimport { toError } from '../lib/errorUtils';\n\n/**\n * THE webpieces retry strategy, shared by every transport — register its {@link RetryStrategy.run}\n * with `CallRegistry.setStrategy(...)`, or hand it to a `TransportDefault`.\n *\n * Two questions, two owners, and it asks both:\n *\n * - **the ERROR** — `ClientRegistry.classify(error, …)`. Only `CallOutcome.TRANSIENT_FAILURE` is\n * retried; an EXPECTED or FAILURE outcome is a definitive answer and is rethrown unchanged.\n * - **the ENDPOINT** — `CallContext.operation`. A `WRITE` is NEVER retried, even when this strategy is\n * registered at ALL: a transient error on a non-idempotent write may already have happened.\n *\n * Each attempt gets its own `attemptTimeoutMs` deadline. Between attempts it waits\n * `initialDelayMs`, then multiplies by `delayMultiplier`, capped at `maxDelayMs`.\n *\n * When it RETRIED and ran out, it throws {@link ApiRetriesExhaustedError} (`cause` = the last\n * attempt's error), which is classified FAILURE so no layer above retries again. A call that made a\n * single attempt — `maxRetries` 0, a `WRITE`, a non-transient error — rethrows its own error unchanged.\n */\nexport class RetryStrategy {\n /** The {@link CallStrategy} to register. A bound field, so `strategy.run` can be passed around. */\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n readonly run: CallStrategy<unknown>;\n\n constructor(\n readonly attemptTimeoutMs: number,\n readonly maxRetries: number,\n readonly initialDelayMs: number,\n readonly delayMultiplier: number,\n readonly maxDelayMs: number,\n ) {\n CallDeadline.validate(attemptTimeoutMs);\n RetryStrategy.requireWhole('maxRetries', maxRetries);\n RetryStrategy.requireWhole('initialDelayMs', initialDelayMs);\n RetryStrategy.requireWhole('maxDelayMs', maxDelayMs);\n if (!Number.isFinite(delayMultiplier) || delayMultiplier < 1) {\n throw new RangeError('delayMultiplier must be finite and at least 1');\n }\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n this.run = (call: Attempt<unknown>, ctx: CallContext): Promise<unknown> => this.execute(call, ctx);\n }\n\n // webpieces-disable no-any-unknown -- registry strategies span heterogeneous response DTOs\n private async execute(call: Attempt<unknown>, ctx: CallContext): Promise<unknown> {\n const methodInfo = new ApiMethodInfo('client', ctx.apiName, ctx.methodName);\n let delayMs = this.initialDelayMs;\n for (let attempt = 1; ; attempt++) {\n // webpieces-disable no-unmanaged-exceptions -- a strategy decides per failure whether to retry\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await call(this.attemptTimeoutMs);\n } catch (err: unknown) {\n const error = toError(err);\n const retryable =\n ctx.operation !== WRITE &&\n ClientRegistry.classify(error, methodInfo) === CallOutcome.TRANSIENT_FAILURE;\n if (!retryable) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a definitive answer is rethrown unchanged\n throw err;\n }\n if (attempt > this.maxRetries) {\n if (attempt === 1) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- never retried, so nothing was exhausted\n throw err;\n }\n throw new ApiRetriesExhaustedError(attempt, error);\n }\n await RetryStrategy.sleep(delayMs);\n delayMs = Math.min(delayMs * this.delayMultiplier, this.maxDelayMs);\n }\n }\n }\n\n // webpieces-disable no-function-outside-class -- constructor argument guard\n private static requireWhole(name: string, value: number): void {\n if (!Number.isInteger(value) || value < 0) {\n throw new RangeError(`${name} must be a non-negative integer`);\n }\n }\n\n // webpieces-disable no-function-outside-class -- backoff timer\n private static sleep(ms: number): Promise<void> {\n return new Promise<void>((resolve: () => void) => setTimeout(resolve, ms));\n }\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { CallStrategy } from './CallStrategy';
|
|
2
|
+
/**
|
|
3
|
+
* What a TRANSPORT does for a call nobody registered a policy for — the last rung of
|
|
4
|
+
* {@link CallRegistry.execute}'s ladder (strategy method → API → ALL, else timeout method → API →
|
|
5
|
+
* ALL, else this).
|
|
6
|
+
*
|
|
7
|
+
* It is the ONLY per-transport difference: every transport calls the same `CallRegistry.execute`, so
|
|
8
|
+
* a strategy registered at ALL / API / method governs RPC and Cloud Tasks enqueue identically.
|
|
9
|
+
*
|
|
10
|
+
* - RPC (node and browser, through `ProxyClient`): `new TransportDefault(30_000)` — one attempt, no
|
|
11
|
+
* default retry, because only the application knows whether repeating a POST is safe.
|
|
12
|
+
* - Cloud Tasks enqueue: `new TransportDefault(30_000, new RetryStrategy(5_000, 3, 500, 2, 4_000).run)`
|
|
13
|
+
* — enqueue is idempotent by construction (one task name for every attempt), so it retries.
|
|
14
|
+
*/
|
|
15
|
+
export declare class TransportDefault {
|
|
16
|
+
/** The single attempt's deadline when no strategy (registered or default) runs. */
|
|
17
|
+
readonly timeoutMs: number;
|
|
18
|
+
/** The transport's own strategy; when set it owns timing exactly like a registered one. */
|
|
19
|
+
readonly strategy?: CallStrategy<unknown> | undefined;
|
|
20
|
+
constructor(
|
|
21
|
+
/** The single attempt's deadline when no strategy (registered or default) runs. */
|
|
22
|
+
timeoutMs: number,
|
|
23
|
+
/** The transport's own strategy; when set it owns timing exactly like a registered one. */
|
|
24
|
+
strategy?: CallStrategy<unknown> | undefined);
|
|
25
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TransportDefault = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* What a TRANSPORT does for a call nobody registered a policy for — the last rung of
|
|
6
|
+
* {@link CallRegistry.execute}'s ladder (strategy method → API → ALL, else timeout method → API →
|
|
7
|
+
* ALL, else this).
|
|
8
|
+
*
|
|
9
|
+
* It is the ONLY per-transport difference: every transport calls the same `CallRegistry.execute`, so
|
|
10
|
+
* a strategy registered at ALL / API / method governs RPC and Cloud Tasks enqueue identically.
|
|
11
|
+
*
|
|
12
|
+
* - RPC (node and browser, through `ProxyClient`): `new TransportDefault(30_000)` — one attempt, no
|
|
13
|
+
* default retry, because only the application knows whether repeating a POST is safe.
|
|
14
|
+
* - Cloud Tasks enqueue: `new TransportDefault(30_000, new RetryStrategy(5_000, 3, 500, 2, 4_000).run)`
|
|
15
|
+
* — enqueue is idempotent by construction (one task name for every attempt), so it retries.
|
|
16
|
+
*/
|
|
17
|
+
class TransportDefault {
|
|
18
|
+
timeoutMs;
|
|
19
|
+
strategy;
|
|
20
|
+
constructor(
|
|
21
|
+
/** The single attempt's deadline when no strategy (registered or default) runs. */
|
|
22
|
+
timeoutMs,
|
|
23
|
+
/** The transport's own strategy; when set it owns timing exactly like a registered one. */
|
|
24
|
+
// webpieces-disable no-any-unknown -- transport strategies span heterogeneous response DTOs
|
|
25
|
+
strategy) {
|
|
26
|
+
this.timeoutMs = timeoutMs;
|
|
27
|
+
this.strategy = strategy;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
exports.TransportDefault = TransportDefault;
|
|
31
|
+
//# sourceMappingURL=TransportDefault.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TransportDefault.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/TransportDefault.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;;;;GAYG;AACH,MAAa,gBAAgB;IAGZ;IAGA;IALb;IACI,mFAAmF;IAC1E,SAAiB;IAC1B,2FAA2F;IAC3F,4FAA4F;IACnF,QAAgC;QAHhC,cAAS,GAAT,SAAS,CAAQ;QAGjB,aAAQ,GAAR,QAAQ,CAAwB;IAC1C,CAAC;CACP;AARD,4CAQC","sourcesContent":["import { CallStrategy } from './CallStrategy';\n\n/**\n * What a TRANSPORT does for a call nobody registered a policy for — the last rung of\n * {@link CallRegistry.execute}'s ladder (strategy method → API → ALL, else timeout method → API →\n * ALL, else this).\n *\n * It is the ONLY per-transport difference: every transport calls the same `CallRegistry.execute`, so\n * a strategy registered at ALL / API / method governs RPC and Cloud Tasks enqueue identically.\n *\n * - RPC (node and browser, through `ProxyClient`): `new TransportDefault(30_000)` — one attempt, no\n * default retry, because only the application knows whether repeating a POST is safe.\n * - Cloud Tasks enqueue: `new TransportDefault(30_000, new RetryStrategy(5_000, 3, 500, 2, 4_000).run)`\n * — enqueue is idempotent by construction (one task name for every attempt), so it retries.\n */\nexport class TransportDefault {\n constructor(\n /** The single attempt's deadline when no strategy (registered or default) runs. */\n readonly timeoutMs: number,\n /** The transport's own strategy; when set it owns timing exactly like a registered one. */\n // webpieces-disable no-any-unknown -- transport strategies span heterogeneous response DTOs\n readonly strategy?: CallStrategy<unknown>,\n ) {}\n}\n"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ApiMethodInfo } from './ApiMethodInfo';
|
|
2
|
+
import { CallOutcome, ErrorClassifier } from './ErrorClassifier';
|
|
3
|
+
/**
|
|
4
|
+
* The webpieces built-in {@link ErrorClassifier} — the terminal tier, used when no per-apiClass
|
|
5
|
+
* classifier and no app default claims an error. Two tables, one per question:
|
|
6
|
+
*
|
|
7
|
+
* # 1. EXPECTED or not — "are things WORKING?"
|
|
8
|
+
*
|
|
9
|
+
* NOT "was it an HTTP 4xx vs 5xx" — the two differ (see 408). Classification is by portable Error
|
|
10
|
+
* TYPE, never by transport: this runs over in-process calls, queue handlers and HTTP through one code
|
|
11
|
+
* path and only ever sees a thrown Error.
|
|
12
|
+
*
|
|
13
|
+
* - ApiEndUserError (266): ALWAYS expected, server OR client — an expected "user made a mistake".
|
|
14
|
+
* - SERVER — a healthy server correctly rejecting a CLIENT'S mistake is metrics NOISE, not a failure:
|
|
15
|
+
* ApiBadRequestError (400), ApiUnauthorizedError (401), ApiForbiddenError (403), ApiNotFoundError
|
|
16
|
+
* (404), ApiConflictError (409), ApiPreconditionFailedError (412), ApiUnsupportedMediaTypeError
|
|
17
|
+
* (415), ApiUnprocessableError (422), an ApiCodedError whose `isCallerError()` is true (below 500,
|
|
18
|
+
* except 408/429), and an OAuthProtocolError below 500 (a refused grant is the OAuth protocol
|
|
19
|
+
* working, #1176). ApiRequestTimeoutError (408) is deliberately absent: the client may NEVER have
|
|
20
|
+
* seen the response.
|
|
21
|
+
* - CLIENT: receiving ANY error except 266 means the outbound call failed — never expected here.
|
|
22
|
+
* (An external client whose 404 means "miss" registers its own classifier for that.)
|
|
23
|
+
*
|
|
24
|
+
* # 2. TRANSIENT or FAILURE — "may repeating it work?"
|
|
25
|
+
*
|
|
26
|
+
* The kind table that used to live inside the MCP renderer:
|
|
27
|
+
*
|
|
28
|
+
* - TRANSIENT: request-timeout (408), dependency-timeout (504, incl. a client's own
|
|
29
|
+
* `ApiCallTimeoutError`), bad-gateway (502 proxy), unavailable (503), rate-limited (429),
|
|
30
|
+
* dependency-backoff, connection, and an ApiCodedError of 408/429.
|
|
31
|
+
* - FAILURE: everything else — a received 4xx (our bug), implementation, not-implemented (501), a
|
|
32
|
+
* generic dependency defect, and {@link ApiRetriesExhaustedError}, which is checked FIRST: it is an
|
|
33
|
+
* `ApiDependencyError`, and classifying it transient would let every layer above retry again.
|
|
34
|
+
*/
|
|
35
|
+
export declare class WebpiecesDefaultErrorClassifier implements ErrorClassifier {
|
|
36
|
+
/** Terminal tier — NEVER returns undefined. */
|
|
37
|
+
classify(error: Error, methodInfo: ApiMethodInfo): CallOutcome;
|
|
38
|
+
private isExpected;
|
|
39
|
+
private isTransient;
|
|
40
|
+
}
|
|
41
|
+
/** Process-wide built-in instance — stateless, so one shared instance is enough. */
|
|
42
|
+
export declare const WEBPIECES_DEFAULT_ERROR_CLASSIFIER: WebpiecesDefaultErrorClassifier;
|