@cyanheads/mcp-ts-core 0.13.3 → 0.13.5
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/AGENTS.md +9 -7
- package/CLAUDE.md +9 -7
- package/README.md +1 -1
- package/changelog/0.13.x/0.13.4.md +65 -0
- package/changelog/0.13.x/0.13.5.md +41 -0
- package/changelog/template.md +7 -7
- package/dist/config/appRoot.d.ts.map +1 -1
- package/dist/config/appRoot.js +48 -16
- package/dist/config/appRoot.js.map +1 -1
- package/dist/core/app.d.ts +20 -0
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +1 -0
- package/dist/core/app.js.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/linter/rules/error-contract-rules.d.ts +65 -3
- package/dist/linter/rules/error-contract-rules.d.ts.map +1 -1
- package/dist/linter/rules/error-contract-rules.js +138 -3
- package/dist/linter/rules/error-contract-rules.js.map +1 -1
- package/dist/linter/rules/index.d.ts +1 -1
- package/dist/linter/rules/index.d.ts.map +1 -1
- package/dist/linter/rules/index.js +1 -1
- package/dist/linter/rules/index.js.map +1 -1
- package/dist/linter/rules/resource-rules.d.ts.map +1 -1
- package/dist/linter/rules/resource-rules.js +4 -2
- package/dist/linter/rules/resource-rules.js.map +1 -1
- package/dist/linter/rules/schema-rules.d.ts +19 -0
- package/dist/linter/rules/schema-rules.d.ts.map +1 -1
- package/dist/linter/rules/schema-rules.js +36 -0
- package/dist/linter/rules/schema-rules.js.map +1 -1
- package/dist/linter/rules/tool-rules.d.ts +17 -0
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +101 -3
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/mcp-server/handlerContext.d.ts +11 -2
- package/dist/mcp-server/handlerContext.d.ts.map +1 -1
- package/dist/mcp-server/handlerContext.js +6 -4
- package/dist/mcp-server/handlerContext.js.map +1 -1
- package/dist/mcp-server/inputRequired.d.ts +35 -2
- package/dist/mcp-server/inputRequired.d.ts.map +1 -1
- package/dist/mcp-server/inputRequired.js +116 -2
- package/dist/mcp-server/inputRequired.js.map +1 -1
- package/dist/mcp-server/resources/resource-registration.d.ts +2 -1
- package/dist/mcp-server/resources/resource-registration.d.ts.map +1 -1
- package/dist/mcp-server/resources/resource-registration.js +4 -4
- package/dist/mcp-server/resources/resource-registration.js.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts +2 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +5 -2
- package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
- package/dist/mcp-server/server.d.ts.map +1 -1
- package/dist/mcp-server/server.js +11 -2
- package/dist/mcp-server/server.js.map +1 -1
- package/dist/mcp-server/tools/tool-registration.d.ts +2 -1
- package/dist/mcp-server/tools/tool-registration.d.ts.map +1 -1
- package/dist/mcp-server/tools/tool-registration.js +4 -4
- package/dist/mcp-server/tools/tool-registration.js.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +114 -0
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -0
- package/dist/mcp-server/tools/utils/inputPrevalidation.js +428 -0
- package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -0
- package/dist/mcp-server/tools/utils/strictenRecord.d.ts +42 -0
- package/dist/mcp-server/tools/utils/strictenRecord.d.ts.map +1 -0
- package/dist/mcp-server/tools/utils/strictenRecord.js +48 -0
- package/dist/mcp-server/tools/utils/strictenRecord.js.map +1 -0
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts +27 -0
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.js +35 -6
- package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +33 -5
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +136 -20
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/services/canvas/core/CanvasInstance.d.ts +4 -1
- package/dist/services/canvas/core/CanvasInstance.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasInstance.js +5 -0
- package/dist/services/canvas/core/CanvasInstance.js.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.d.ts +52 -1
- package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.js +79 -3
- package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
- package/dist/services/canvas/core/sqlGate.d.ts +14 -1
- package/dist/services/canvas/core/sqlGate.d.ts.map +1 -1
- package/dist/services/canvas/core/sqlGate.js +69 -7
- package/dist/services/canvas/core/sqlGate.js.map +1 -1
- package/dist/services/canvas/index.d.ts +2 -2
- package/dist/services/canvas/index.d.ts.map +1 -1
- package/dist/services/canvas/index.js +2 -2
- package/dist/services/canvas/index.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +20 -0
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +75 -25
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
- package/dist/services/canvas/types.d.ts +6 -1
- package/dist/services/canvas/types.d.ts.map +1 -1
- package/dist/types-global/errors.d.ts +30 -0
- package/dist/types-global/errors.d.ts.map +1 -1
- package/dist/types-global/errors.js +4 -3
- package/dist/types-global/errors.js.map +1 -1
- package/dist/utils/index.d.ts +3 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -2
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/errorHandler.js +13 -3
- package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
- package/dist/utils/internal/error-handler/mappings.d.ts +14 -1
- package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
- package/dist/utils/internal/error-handler/mappings.js +19 -1
- package/dist/utils/internal/error-handler/mappings.js.map +1 -1
- package/dist/utils/internal/error-handler/types.d.ts +12 -1
- package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +4 -1
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/network/fetchWithTimeout.d.ts +24 -4
- package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
- package/dist/utils/network/fetchWithTimeout.js +10 -6
- package/dist/utils/network/fetchWithTimeout.js.map +1 -1
- package/dist/utils/network/httpError.d.ts +56 -6
- package/dist/utils/network/httpError.d.ts.map +1 -1
- package/dist/utils/network/httpError.js +56 -7
- package/dist/utils/network/httpError.js.map +1 -1
- package/dist/utils/network/pacer.d.ts +117 -0
- package/dist/utils/network/pacer.d.ts.map +1 -0
- package/dist/utils/network/pacer.js +304 -0
- package/dist/utils/network/pacer.js.map +1 -0
- package/dist/utils/network/retry.d.ts +119 -3
- package/dist/utils/network/retry.d.ts.map +1 -1
- package/dist/utils/network/retry.js +176 -35
- package/dist/utils/network/retry.js.map +1 -1
- package/dist/utils/security/rateLimiter.d.ts +19 -1
- package/dist/utils/security/rateLimiter.d.ts.map +1 -1
- package/dist/utils/security/rateLimiter.js +49 -1
- package/dist/utils/security/rateLimiter.js.map +1 -1
- package/dist/utils/telemetry/attributes.d.ts +27 -0
- package/dist/utils/telemetry/attributes.d.ts.map +1 -1
- package/dist/utils/telemetry/attributes.js +38 -0
- package/dist/utils/telemetry/attributes.js.map +1 -1
- package/framework-skills/add-tool/SKILL.md +49 -5
- package/framework-skills/api-canvas/SKILL.md +37 -16
- package/framework-skills/api-config/SKILL.md +4 -4
- package/framework-skills/api-context/SKILL.md +4 -2
- package/framework-skills/api-errors/SKILL.md +39 -7
- package/framework-skills/api-linter/SKILL.md +92 -5
- package/framework-skills/api-telemetry/SKILL.md +43 -4
- package/framework-skills/api-utils/SKILL.md +9 -5
- package/framework-skills/api-utils/references/security.md +2 -2
- package/framework-skills/design-mcp-server/SKILL.md +20 -3
- package/framework-skills/field-test/SKILL.md +4 -2
- package/framework-skills/git-wrapup/SKILL.md +87 -69
- package/framework-skills/release-and-publish/SKILL.md +5 -5
- package/framework-skills/release-pr-review/SKILL.md +5 -5
- package/framework-skills/report-issue-framework/SKILL.md +6 -35
- package/framework-skills/report-issue-local/SKILL.md +6 -36
- package/package.json +2 -2
- package/templates/.github/ISSUE_TEMPLATE/bug_report.yml +2 -2
- package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +2 -2
- package/templates/AGENTS.md +1 -1
- package/templates/CLAUDE.md +1 -1
- package/templates/changelog/template.md +7 -7
|
@@ -10,11 +10,18 @@ import { JsonRpcErrorCode, McpError } from '../../types-global/errors.js';
|
|
|
10
10
|
* 4xx/5xx range, with specific mappings for the codes most upstream APIs use to
|
|
11
11
|
* signal authoritative outcomes (auth failures, conflicts, validation, rate limits).
|
|
12
12
|
*
|
|
13
|
-
* Returns `undefined` when the status is in the 1xx/2xx
|
|
13
|
+
* Returns `undefined` when the status is in the 1xx/2xx range — those are not
|
|
14
14
|
* errors and the caller should not be invoking error mapping on them.
|
|
15
15
|
*
|
|
16
|
+
* A 3xx does reach error mapping: under `redirect: 'manual'` the caller gets the
|
|
17
|
+
* redirect back rather than the followed response, and `!response.ok` throws. It
|
|
18
|
+
* maps like an unlisted 4xx — the request as sent cannot be served at this URL —
|
|
19
|
+
* which also keeps it out of `withRetry`'s transient set, since re-issuing it
|
|
20
|
+
* returns the same redirect.
|
|
21
|
+
*
|
|
16
22
|
* | Status | Code |
|
|
17
23
|
* |:-------|:-----|
|
|
24
|
+
* | 3xx | `InvalidRequest` |
|
|
18
25
|
* | 400 | `InvalidParams` |
|
|
19
26
|
* | 401 | `Unauthorized` |
|
|
20
27
|
* | 402 | `Forbidden` (payment-required, treated as access denial) |
|
|
@@ -57,6 +64,26 @@ export declare function httpStatusToErrorCode(status: number): JsonRpcErrorCode
|
|
|
57
64
|
export declare function httpStatusRetryability(status: number): {
|
|
58
65
|
retryable: false;
|
|
59
66
|
} | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* The selected response headers as a fragment to spread into an `McpError`'s
|
|
69
|
+
* `data`, mirroring {@link httpStatusRetryability}'s shape: `undefined` when
|
|
70
|
+
* nothing was captured, so the spread adds no `headers` key at all.
|
|
71
|
+
*
|
|
72
|
+
* Selection is case-insensitive and keys are lowercased, so `['X-Request-Id']`
|
|
73
|
+
* and `['x-request-id']` collapse to one `headers['x-request-id']` entry.
|
|
74
|
+
* Presence follows `Headers.has()` — a header carried with an empty value is
|
|
75
|
+
* captured as `''`, one the response does not carry adds no key — and a
|
|
76
|
+
* multi-valued field is captured comma-joined, as `Headers.get()` returns it.
|
|
77
|
+
*
|
|
78
|
+
* Every captured value reaches the MCP client as `structuredContent.error.data`,
|
|
79
|
+
* which is why capture is an explicit allowlist rather than a redacted copy of
|
|
80
|
+
* every header: an arbitrary upstream header name can carry a secret, so no
|
|
81
|
+
* field-name redactor can make the default safe. Both HTTP helpers route through
|
|
82
|
+
* here so the two cannot drift apart.
|
|
83
|
+
*/
|
|
84
|
+
export declare function selectErrorHeaders(headers: Headers, selector: readonly string[] | undefined): {
|
|
85
|
+
headers: Record<string, string>;
|
|
86
|
+
} | undefined;
|
|
60
87
|
/** Configuration for {@link httpErrorFromResponse}. */
|
|
61
88
|
export interface HttpErrorFromResponseOptions {
|
|
62
89
|
/**
|
|
@@ -82,14 +109,36 @@ export interface HttpErrorFromResponseOptions {
|
|
|
82
109
|
codeOverride?: (status: number) => JsonRpcErrorCode | undefined;
|
|
83
110
|
/**
|
|
84
111
|
* Additional fields merged into `error.data`. Always includes
|
|
85
|
-
* `{
|
|
86
|
-
*
|
|
112
|
+
* `{ status, statusText, body? }` from the response itself, plus the legacy
|
|
113
|
+
* aliases `statusCode` (= `status`) and `responseBody` (= `body`) for
|
|
87
114
|
* consumers reading `fetchWithTimeout`'s shape — slated for consolidation in a
|
|
88
115
|
* future major. A status whose failure is permanent also carries
|
|
89
116
|
* `retryable: false` (see {@link httpStatusRetryability}). Fields passed here
|
|
90
117
|
* override the defaults on key collision.
|
|
118
|
+
*
|
|
119
|
+
* `error.data` is forwarded to the client as `structuredContent.error.data`,
|
|
120
|
+
* so treat everything put here as client-visible.
|
|
91
121
|
*/
|
|
92
122
|
data?: Record<string, unknown>;
|
|
123
|
+
/**
|
|
124
|
+
* Response headers to copy onto `error.data.headers` under lowercase keys —
|
|
125
|
+
* provider budget headers (`x-ratelimit-remaining-usd`), a quota reset, a
|
|
126
|
+
* request ID. Omitted or empty, no `headers` key is emitted. `set-cookie` is
|
|
127
|
+
* never captured. See {@link selectErrorHeaders} for the selection semantics.
|
|
128
|
+
*
|
|
129
|
+
* Every selected value reaches the client alongside the rest of `error.data`,
|
|
130
|
+
* so never name a header that carries a credential — and note that a
|
|
131
|
+
* `Location` can itself carry a sensitive path, query, or token.
|
|
132
|
+
*/
|
|
133
|
+
errorHeaders?: string[];
|
|
134
|
+
/**
|
|
135
|
+
* Put the full `response.url` — path and query string included — on
|
|
136
|
+
* `error.data.url`. Default: `false`, because `error.data` reaches the client
|
|
137
|
+
* and an upstream request URL routinely carries user input, internal
|
|
138
|
+
* identifiers, or an API key in its query string. The error message still
|
|
139
|
+
* names the host either way. No key is added when `response.url` is empty.
|
|
140
|
+
*/
|
|
141
|
+
includeUrl?: boolean;
|
|
93
142
|
/**
|
|
94
143
|
* Logical service name included in the error message
|
|
95
144
|
* (e.g., `'NCBI'` → `"NCBI returned HTTP 429"`). When omitted, the message
|
|
@@ -104,9 +153,10 @@ export interface HttpErrorFromResponseOptions {
|
|
|
104
153
|
* Reads the response body (consuming it) when `captureBody` is true, so callers
|
|
105
154
|
* must `response.clone()` first if they intend to read the body elsewhere.
|
|
106
155
|
*
|
|
107
|
-
* Always returns an `McpError` even for 1xx/2xx
|
|
108
|
-
*
|
|
109
|
-
*
|
|
156
|
+
* Always returns an `McpError` even for 1xx/2xx — the caller is expected to have
|
|
157
|
+
* verified `!response.ok` first, but the helper falls back to a sensible code
|
|
158
|
+
* (`InternalError`) instead of silently producing nothing. A 3xx is a real
|
|
159
|
+
* outcome here rather than a fallback: see {@link httpStatusToErrorCode}.
|
|
110
160
|
*
|
|
111
161
|
* @example
|
|
112
162
|
* ```ts
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpError.d.ts","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAGtE
|
|
1
|
+
{"version":3,"file":"httpError.d.ts","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAGtE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAgClF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,KAAK,CAAA;CAAE,GAAG,SAAS,CAEvF;AAUD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACtC;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,SAAS,CAWjD;AAED,uDAAuD;AACvD,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,GAAG,SAAS,CAAC;IAChE;;;;;;;;;;;OAWG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE,4BAAiC,GACzC,OAAO,CAAC,QAAQ,CAAC,CAwDnB"}
|
|
@@ -11,11 +11,18 @@ import { readBoundedResponseText } from './responseBody.js';
|
|
|
11
11
|
* 4xx/5xx range, with specific mappings for the codes most upstream APIs use to
|
|
12
12
|
* signal authoritative outcomes (auth failures, conflicts, validation, rate limits).
|
|
13
13
|
*
|
|
14
|
-
* Returns `undefined` when the status is in the 1xx/2xx
|
|
14
|
+
* Returns `undefined` when the status is in the 1xx/2xx range — those are not
|
|
15
15
|
* errors and the caller should not be invoking error mapping on them.
|
|
16
16
|
*
|
|
17
|
+
* A 3xx does reach error mapping: under `redirect: 'manual'` the caller gets the
|
|
18
|
+
* redirect back rather than the followed response, and `!response.ok` throws. It
|
|
19
|
+
* maps like an unlisted 4xx — the request as sent cannot be served at this URL —
|
|
20
|
+
* which also keeps it out of `withRetry`'s transient set, since re-issuing it
|
|
21
|
+
* returns the same redirect.
|
|
22
|
+
*
|
|
17
23
|
* | Status | Code |
|
|
18
24
|
* |:-------|:-----|
|
|
25
|
+
* | 3xx | `InvalidRequest` |
|
|
19
26
|
* | 400 | `InvalidParams` |
|
|
20
27
|
* | 401 | `Unauthorized` |
|
|
21
28
|
* | 402 | `Forbidden` (payment-required, treated as access denial) |
|
|
@@ -43,8 +50,10 @@ import { readBoundedResponseText } from './responseBody.js';
|
|
|
43
50
|
* non-transient code.
|
|
44
51
|
*/
|
|
45
52
|
export function httpStatusToErrorCode(status) {
|
|
46
|
-
if (status <
|
|
53
|
+
if (status < 300)
|
|
47
54
|
return;
|
|
55
|
+
if (status < 400)
|
|
56
|
+
return JsonRpcErrorCode.InvalidRequest;
|
|
48
57
|
switch (status) {
|
|
49
58
|
case 400:
|
|
50
59
|
return JsonRpcErrorCode.InvalidParams;
|
|
@@ -89,6 +98,42 @@ export function httpStatusToErrorCode(status) {
|
|
|
89
98
|
export function httpStatusRetryability(status) {
|
|
90
99
|
return status === 501 ? { retryable: false } : undefined;
|
|
91
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Response headers no selector can reach. `set-cookie` is credential-bearing,
|
|
103
|
+
* and `Headers.get()` joins its values into a string that is not a valid
|
|
104
|
+
* reconstruction of the field, so capturing it would leak a secret and misreport
|
|
105
|
+
* it at the same time.
|
|
106
|
+
*/
|
|
107
|
+
const NEVER_CAPTURED_HEADERS = new Set(['set-cookie']);
|
|
108
|
+
/**
|
|
109
|
+
* The selected response headers as a fragment to spread into an `McpError`'s
|
|
110
|
+
* `data`, mirroring {@link httpStatusRetryability}'s shape: `undefined` when
|
|
111
|
+
* nothing was captured, so the spread adds no `headers` key at all.
|
|
112
|
+
*
|
|
113
|
+
* Selection is case-insensitive and keys are lowercased, so `['X-Request-Id']`
|
|
114
|
+
* and `['x-request-id']` collapse to one `headers['x-request-id']` entry.
|
|
115
|
+
* Presence follows `Headers.has()` — a header carried with an empty value is
|
|
116
|
+
* captured as `''`, one the response does not carry adds no key — and a
|
|
117
|
+
* multi-valued field is captured comma-joined, as `Headers.get()` returns it.
|
|
118
|
+
*
|
|
119
|
+
* Every captured value reaches the MCP client as `structuredContent.error.data`,
|
|
120
|
+
* which is why capture is an explicit allowlist rather than a redacted copy of
|
|
121
|
+
* every header: an arbitrary upstream header name can carry a secret, so no
|
|
122
|
+
* field-name redactor can make the default safe. Both HTTP helpers route through
|
|
123
|
+
* here so the two cannot drift apart.
|
|
124
|
+
*/
|
|
125
|
+
export function selectErrorHeaders(headers, selector) {
|
|
126
|
+
if (!selector?.length)
|
|
127
|
+
return;
|
|
128
|
+
const captured = {};
|
|
129
|
+
for (const name of selector) {
|
|
130
|
+
const key = name.toLowerCase();
|
|
131
|
+
if (NEVER_CAPTURED_HEADERS.has(key) || !headers.has(key))
|
|
132
|
+
continue;
|
|
133
|
+
captured[key] = headers.get(key) ?? '';
|
|
134
|
+
}
|
|
135
|
+
return Object.keys(captured).length > 0 ? { headers: captured } : undefined;
|
|
136
|
+
}
|
|
92
137
|
const DEFAULT_BODY_LIMIT = 500;
|
|
93
138
|
/**
|
|
94
139
|
* Builds an `McpError` from an HTTP `Response`, with status-aware classification
|
|
@@ -97,9 +142,10 @@ const DEFAULT_BODY_LIMIT = 500;
|
|
|
97
142
|
* Reads the response body (consuming it) when `captureBody` is true, so callers
|
|
98
143
|
* must `response.clone()` first if they intend to read the body elsewhere.
|
|
99
144
|
*
|
|
100
|
-
* Always returns an `McpError` even for 1xx/2xx
|
|
101
|
-
*
|
|
102
|
-
*
|
|
145
|
+
* Always returns an `McpError` even for 1xx/2xx — the caller is expected to have
|
|
146
|
+
* verified `!response.ok` first, but the helper falls back to a sensible code
|
|
147
|
+
* (`InternalError`) instead of silently producing nothing. A 3xx is a real
|
|
148
|
+
* outcome here rather than a fallback: see {@link httpStatusToErrorCode}.
|
|
103
149
|
*
|
|
104
150
|
* @example
|
|
105
151
|
* ```ts
|
|
@@ -127,7 +173,7 @@ const DEFAULT_BODY_LIMIT = 500;
|
|
|
127
173
|
* ```
|
|
128
174
|
*/
|
|
129
175
|
export async function httpErrorFromResponse(response, options = {}) {
|
|
130
|
-
const { captureBody = true, bodyLimit = DEFAULT_BODY_LIMIT, service, data: extraData, cause, codeOverride, } = options;
|
|
176
|
+
const { captureBody = true, bodyLimit = DEFAULT_BODY_LIMIT, includeUrl = false, service, data: extraData, cause, codeOverride, errorHeaders, } = options;
|
|
131
177
|
const code = codeOverride?.(response.status) ??
|
|
132
178
|
httpStatusToErrorCode(response.status) ??
|
|
133
179
|
JsonRpcErrorCode.InternalError;
|
|
@@ -144,7 +190,9 @@ export async function httpErrorFromResponse(response, options = {}) {
|
|
|
144
190
|
}
|
|
145
191
|
const retryAfter = response.headers.get('retry-after') ?? undefined;
|
|
146
192
|
const data = {
|
|
147
|
-
|
|
193
|
+
// The full URL is opt-in: `error.data` reaches the client, and a request URL
|
|
194
|
+
// routinely carries user input or an API key in its query string.
|
|
195
|
+
...(includeUrl && response.url && { url: response.url }),
|
|
148
196
|
// Canonical (Fetch `Response`-aligned) field names.
|
|
149
197
|
status: response.status,
|
|
150
198
|
statusText: response.statusText || undefined,
|
|
@@ -156,6 +204,7 @@ export async function httpErrorFromResponse(response, options = {}) {
|
|
|
156
204
|
...(body !== undefined && { responseBody: body }),
|
|
157
205
|
...(retryAfter !== undefined && { retryAfter }),
|
|
158
206
|
...httpStatusRetryability(response.status),
|
|
207
|
+
...selectErrorHeaders(response.headers, errorHeaders),
|
|
159
208
|
...extraData,
|
|
160
209
|
};
|
|
161
210
|
return new McpError(code, `${subject} returned HTTP ${response.status}${statusText}.`, data, cause !== undefined ? { cause } : undefined);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"httpError.js","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAE1E
|
|
1
|
+
{"version":3,"file":"httpError.js","sourceRoot":"","sources":["../../../src/utils/network/httpError.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One sliding start-rate window, e.g. `{ requests: 5, perMs: 60_000 }` for five
|
|
3
|
+
* requests a minute. Several may be declared together (per-minute and per-hour);
|
|
4
|
+
* a start needs every one of them to allow it.
|
|
5
|
+
*/
|
|
6
|
+
export interface PacerLimit {
|
|
7
|
+
/** Width of the sliding window in milliseconds. */
|
|
8
|
+
perMs: number;
|
|
9
|
+
/** Starts permitted within the window. */
|
|
10
|
+
requests: number;
|
|
11
|
+
}
|
|
12
|
+
/** Back-off applied to the shared gate when the upstream answers with a rate limit. */
|
|
13
|
+
export interface PacerCooldownOptions {
|
|
14
|
+
/** First cooldown, doubled on each consecutive rate limit. */
|
|
15
|
+
baseMs: number;
|
|
16
|
+
/**
|
|
17
|
+
* Ceiling for both the doubling and an honored `Retry-After`, so a pathological
|
|
18
|
+
* upstream value cannot park the queue.
|
|
19
|
+
*/
|
|
20
|
+
maxMs: number;
|
|
21
|
+
}
|
|
22
|
+
/** Configuration for {@link createPacer}. */
|
|
23
|
+
export interface PacerOptions {
|
|
24
|
+
/** Shared 429 gate. Omitted, an upstream rate limit paces nothing by itself. */
|
|
25
|
+
cooldown?: PacerCooldownOptions;
|
|
26
|
+
/** Sliding start-rate windows. Every entry must allow a start. */
|
|
27
|
+
limits?: PacerLimit[];
|
|
28
|
+
/** In-flight ceiling. Unset, only the windows and the start gap bind. */
|
|
29
|
+
maxConcurrent?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Absolute backpressure on queue length, for callers that pass no
|
|
32
|
+
* `maxWaitMs`. An arrival past it is shed at once.
|
|
33
|
+
*/
|
|
34
|
+
maxQueueDepth?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Minimum spacing between consecutive starts. Not expressible through
|
|
37
|
+
* {@link PacerOptions.limits} once a window admits more than one request:
|
|
38
|
+
* `{ requests: 10, perMs: 1000 }` permits ten starts in the same millisecond,
|
|
39
|
+
* which is exactly the burst the SEC and NCBI clients space out by hand.
|
|
40
|
+
*/
|
|
41
|
+
minStartGapMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Author-set label for telemetry attribution. Bounded and never
|
|
44
|
+
* caller-supplied — a metric attribute set lives until process restart.
|
|
45
|
+
*/
|
|
46
|
+
name: string;
|
|
47
|
+
}
|
|
48
|
+
/** Per-call options for {@link Pacer.run}. */
|
|
49
|
+
export interface PacerRunOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Ceiling on queue time — not on the task, which bounds itself. Enqueue
|
|
52
|
+
* rejects when the projected wait already exceeds it, and a still-queued entry
|
|
53
|
+
* rejects when it elapses.
|
|
54
|
+
*/
|
|
55
|
+
maxWaitMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Caller cancellation. While queued, an abort removes the entry and rejects
|
|
58
|
+
* with `signal.reason`; once dispatched the signal is the task's own to honor,
|
|
59
|
+
* and it is what {@link Pacer.run} hands the task.
|
|
60
|
+
*/
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
/** A queue in front of one upstream. Process-local; timers and `AbortSignal` only. */
|
|
64
|
+
export interface Pacer extends Disposable {
|
|
65
|
+
/**
|
|
66
|
+
* Clears the dispatch timer and rejects every queued waiter with
|
|
67
|
+
* `RequestCancelled`. In-flight tasks are left to finish. Idempotent; wire it
|
|
68
|
+
* through `createApp({ teardown })`.
|
|
69
|
+
*/
|
|
70
|
+
dispose(): void;
|
|
71
|
+
/**
|
|
72
|
+
* Queues `task` and runs it once a slot opens, handing it the caller's signal.
|
|
73
|
+
*
|
|
74
|
+
* @throws {McpError} `RateLimited` with `data.reason: 'pacer_shed'` when no
|
|
75
|
+
* slot fits the caller's wait budget or the queue is at capacity.
|
|
76
|
+
*/
|
|
77
|
+
run<T>(task: (signal: AbortSignal) => Promise<T>, options?: PacerRunOptions): Promise<T>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Creates a FIFO pacer for one upstream.
|
|
81
|
+
*
|
|
82
|
+
* A slot is granted when every {@link PacerOptions.limits} window,
|
|
83
|
+
* {@link PacerOptions.minStartGapMs}, {@link PacerOptions.maxConcurrent}, and the
|
|
84
|
+
* cooldown gate allow it. Each window is a sliding view over recorded *start*
|
|
85
|
+
* times, so a slow response never widens the rate the upstream sees.
|
|
86
|
+
*
|
|
87
|
+
* **Composition with `withRetry`.** Retry outside, pacer inside —
|
|
88
|
+
* `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` —
|
|
89
|
+
* so each attempt re-queues and is re-paced. The cooldown gate is an absolute
|
|
90
|
+
* instant rather than a duration counted from dequeue, so `withRetry`'s
|
|
91
|
+
* `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the
|
|
92
|
+
* caller waits the window the upstream named once, not twice. Pass the retry
|
|
93
|
+
* deadline's signal into `run` and the queue wait is charged to the same budget.
|
|
94
|
+
*
|
|
95
|
+
* **Runtime.** Process-local, so on Workers the limits bind per isolate, OTel is
|
|
96
|
+
* off and the metrics are inert, and `createWorkerHandler` accepts no `teardown`
|
|
97
|
+
* to call {@link Pacer.dispose} from.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* const pacer = createPacer({
|
|
102
|
+
* name: 'courtlistener',
|
|
103
|
+
* limits: [{ requests: 5, perMs: 60_000 }, { requests: 50, perMs: 3_600_000 }],
|
|
104
|
+
* minStartGapMs: 100,
|
|
105
|
+
* maxConcurrent: 2,
|
|
106
|
+
* maxQueueDepth: 100,
|
|
107
|
+
* cooldown: { baseMs: 5_000, maxMs: 60_000 },
|
|
108
|
+
* });
|
|
109
|
+
*
|
|
110
|
+
* const res = await pacer.run(
|
|
111
|
+
* (signal) => fetchWithTimeout(url, 30_000, ctx, { signal }),
|
|
112
|
+
* { signal: ctx.signal, maxWaitMs: 45_000 },
|
|
113
|
+
* );
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export declare function createPacer(options: PacerOptions): Pacer;
|
|
117
|
+
//# sourceMappingURL=pacer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pacer.d.ts","sourceRoot":"","sources":["../../../src/utils/network/pacer.ts"],"names":[],"mappings":"AAqBA;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,uFAAuF;AACvF,MAAM,WAAW,oBAAoB;IACnC,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;CACf;AAED,6CAA6C;AAC7C,MAAM,WAAW,YAAY;IAC3B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC,kEAAkE;IAClE,MAAM,CAAC,EAAE,UAAU,EAAE,CAAC;IACtB,yEAAyE;IACzE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;CACd;AAED,8CAA8C;AAC9C,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,sFAAsF;AACtF,MAAM,WAAW,KAAM,SAAQ,UAAU;IACvC;;;;OAIG;IACH,OAAO,IAAI,IAAI,CAAC;IAChB;;;;;OAKG;IACH,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAC1F;AAgDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,CAmQxD"}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Outbound request pacer — a FIFO queue in front of a rate-limited
|
|
3
|
+
* upstream. Holds work back until every configured window, the minimum start gap,
|
|
4
|
+
* and the concurrency cap allow it; sheds callers whose wait budget cannot be met;
|
|
5
|
+
* and closes a shared cooldown gate when the upstream answers 429.
|
|
6
|
+
*
|
|
7
|
+
* The inbound `RateLimiter` (`utils/security`) is the mirror image: it is keyed
|
|
8
|
+
* per caller and rejects synchronously, so it cannot queue work against an
|
|
9
|
+
* upstream budget.
|
|
10
|
+
* @module src/utils/network/pacer
|
|
11
|
+
*/
|
|
12
|
+
import { JsonRpcErrorCode, McpError, rateLimited, requestCancelled, } from '../../types-global/errors.js';
|
|
13
|
+
import { parseRetryAfterMs } from './retry.js';
|
|
14
|
+
import { ATTR_MCP_PACER_NAME } from '../telemetry/attributes.js';
|
|
15
|
+
import { createCounter, createHistogram, createUpDownCounter } from '../telemetry/metrics.js';
|
|
16
|
+
let instruments;
|
|
17
|
+
/**
|
|
18
|
+
* The four pacer instruments, shared across every pacer in the process and told
|
|
19
|
+
* apart by `mcp.pacer.name`. Lazy: a server that never queues emits no series.
|
|
20
|
+
*/
|
|
21
|
+
function getPacerMetrics() {
|
|
22
|
+
instruments ??= {
|
|
23
|
+
cooldowns: createCounter('mcp.pacer.cooldowns', 'Cooldown gates closed by an upstream rate limit', '{cooldowns}'),
|
|
24
|
+
queueDepth: createUpDownCounter('mcp.pacer.queue_depth', 'Requests waiting for a pacer slot', '{requests}'),
|
|
25
|
+
sheds: createCounter('mcp.pacer.sheds', 'Requests shed before dispatch because no slot fit the wait budget', '{requests}'),
|
|
26
|
+
wait: createHistogram('mcp.pacer.wait', 'Time a request spent waiting for a pacer slot', 'ms'),
|
|
27
|
+
};
|
|
28
|
+
return instruments;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Creates a FIFO pacer for one upstream.
|
|
32
|
+
*
|
|
33
|
+
* A slot is granted when every {@link PacerOptions.limits} window,
|
|
34
|
+
* {@link PacerOptions.minStartGapMs}, {@link PacerOptions.maxConcurrent}, and the
|
|
35
|
+
* cooldown gate allow it. Each window is a sliding view over recorded *start*
|
|
36
|
+
* times, so a slow response never widens the rate the upstream sees.
|
|
37
|
+
*
|
|
38
|
+
* **Composition with `withRetry`.** Retry outside, pacer inside —
|
|
39
|
+
* `withRetry(({ signal }) => pacer.run(fn, { signal }), { signal, deadlineMs })` —
|
|
40
|
+
* so each attempt re-queues and is re-paced. The cooldown gate is an absolute
|
|
41
|
+
* instant rather than a duration counted from dequeue, so `withRetry`'s
|
|
42
|
+
* `Retry-After` sleep and the gate overlap in wall-clock instead of summing: the
|
|
43
|
+
* caller waits the window the upstream named once, not twice. Pass the retry
|
|
44
|
+
* deadline's signal into `run` and the queue wait is charged to the same budget.
|
|
45
|
+
*
|
|
46
|
+
* **Runtime.** Process-local, so on Workers the limits bind per isolate, OTel is
|
|
47
|
+
* off and the metrics are inert, and `createWorkerHandler` accepts no `teardown`
|
|
48
|
+
* to call {@link Pacer.dispose} from.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const pacer = createPacer({
|
|
53
|
+
* name: 'courtlistener',
|
|
54
|
+
* limits: [{ requests: 5, perMs: 60_000 }, { requests: 50, perMs: 3_600_000 }],
|
|
55
|
+
* minStartGapMs: 100,
|
|
56
|
+
* maxConcurrent: 2,
|
|
57
|
+
* maxQueueDepth: 100,
|
|
58
|
+
* cooldown: { baseMs: 5_000, maxMs: 60_000 },
|
|
59
|
+
* });
|
|
60
|
+
*
|
|
61
|
+
* const res = await pacer.run(
|
|
62
|
+
* (signal) => fetchWithTimeout(url, 30_000, ctx, { signal }),
|
|
63
|
+
* { signal: ctx.signal, maxWaitMs: 45_000 },
|
|
64
|
+
* );
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function createPacer(options) {
|
|
68
|
+
const { cooldown, limits = [], maxConcurrent, maxQueueDepth, minStartGapMs = 0, name } = options;
|
|
69
|
+
const attributes = { [ATTR_MCP_PACER_NAME]: name };
|
|
70
|
+
/** Recorded start instants, ascending. Pruned to the longest constraint. */
|
|
71
|
+
const starts = [];
|
|
72
|
+
const retentionMs = Math.max(minStartGapMs, ...limits.map((limit) => limit.perMs), 0);
|
|
73
|
+
const queue = [];
|
|
74
|
+
let active = 0;
|
|
75
|
+
let consecutiveRateLimits = 0;
|
|
76
|
+
/** Absolute instant the shared gate reopens. */
|
|
77
|
+
let gateOpensAt = 0;
|
|
78
|
+
let dispatchTimer;
|
|
79
|
+
let disposed = false;
|
|
80
|
+
/**
|
|
81
|
+
* The earliest instant a start is permitted given `history`, evaluated at
|
|
82
|
+
* `at`. Exact over the cooldown gate, the start gap, and every window —
|
|
83
|
+
* concurrency is deliberately not modelled here, since a slot frees on an
|
|
84
|
+
* unknowable completion.
|
|
85
|
+
*/
|
|
86
|
+
function earliestStart(history, at) {
|
|
87
|
+
let earliest = gateOpensAt;
|
|
88
|
+
const last = history[history.length - 1];
|
|
89
|
+
if (last !== undefined && minStartGapMs > 0) {
|
|
90
|
+
earliest = Math.max(earliest, last + minStartGapMs);
|
|
91
|
+
}
|
|
92
|
+
for (const { requests, perMs } of limits) {
|
|
93
|
+
if (requests <= 0)
|
|
94
|
+
continue;
|
|
95
|
+
let seen = 0;
|
|
96
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
97
|
+
const start = history[i];
|
|
98
|
+
// A start exactly `perMs` old has already left the window.
|
|
99
|
+
if (start <= at - perMs)
|
|
100
|
+
break;
|
|
101
|
+
seen++;
|
|
102
|
+
if (seen === requests) {
|
|
103
|
+
earliest = Math.max(earliest, start + perMs);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return earliest;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* When an arrival joining behind `ahead` waiters could start, by replaying the
|
|
112
|
+
* windows forward. Exact while only the windows and the gap bind; a lower
|
|
113
|
+
* bound once `maxConcurrent` does — which is what keeps a shed from ever being
|
|
114
|
+
* a false one.
|
|
115
|
+
*/
|
|
116
|
+
function projectedStart(now, ahead) {
|
|
117
|
+
const simulated = starts.slice();
|
|
118
|
+
let at = now;
|
|
119
|
+
for (let i = 0; i <= ahead; i++) {
|
|
120
|
+
at = Math.max(at, earliestStart(simulated, at));
|
|
121
|
+
simulated.push(at);
|
|
122
|
+
}
|
|
123
|
+
return at;
|
|
124
|
+
}
|
|
125
|
+
/** Seconds until a slot opens, for the shed error's `retryAfter`. */
|
|
126
|
+
function secondsUntilSlot(now, ahead) {
|
|
127
|
+
return Math.ceil(Math.max(0, projectedStart(now, ahead) - now) / 1000);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The shed error. `rateLimited` with no `retryable: false`: to the calling
|
|
131
|
+
* agent this is an ordinary rate limit — wait `retryAfter`, call again — and
|
|
132
|
+
* that flag would tell them the opposite. `withRetry`'s default predicate
|
|
133
|
+
* reads `reason` instead, so an enclosing retry fails fast rather than
|
|
134
|
+
* sleeping past the deadline the shed exists to enforce.
|
|
135
|
+
*/
|
|
136
|
+
function shed(retryAfter, queueDepth) {
|
|
137
|
+
getPacerMetrics().sheds.add(1, attributes);
|
|
138
|
+
return rateLimited(`No ${name} request slot available within the caller's wait budget.`, {
|
|
139
|
+
reason: 'pacer_shed',
|
|
140
|
+
retryAfter,
|
|
141
|
+
queueDepth,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Releases an entry that has left the queue: clears its shed timer and abort
|
|
146
|
+
* listener, and settles the depth gauge. Leaving the line and the gauge are
|
|
147
|
+
* the same event, so every exit — dequeued, removed, disposed — goes through
|
|
148
|
+
* here.
|
|
149
|
+
*/
|
|
150
|
+
function detach(entry) {
|
|
151
|
+
if (entry.shedTimer !== undefined) {
|
|
152
|
+
clearTimeout(entry.shedTimer);
|
|
153
|
+
entry.shedTimer = undefined;
|
|
154
|
+
}
|
|
155
|
+
if (entry.onAbort && entry.signal) {
|
|
156
|
+
entry.signal.removeEventListener('abort', entry.onAbort);
|
|
157
|
+
entry.onAbort = undefined;
|
|
158
|
+
}
|
|
159
|
+
getPacerMetrics().queueDepth.add(-1, attributes);
|
|
160
|
+
}
|
|
161
|
+
/** Removes a waiter and settles the depth gauge. `false` if it already left. */
|
|
162
|
+
function remove(entry) {
|
|
163
|
+
const index = queue.indexOf(entry);
|
|
164
|
+
if (index === -1)
|
|
165
|
+
return false;
|
|
166
|
+
queue.splice(index, 1);
|
|
167
|
+
detach(entry);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
function clearDispatchTimer() {
|
|
171
|
+
if (dispatchTimer === undefined)
|
|
172
|
+
return;
|
|
173
|
+
clearTimeout(dispatchTimer);
|
|
174
|
+
dispatchTimer = undefined;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Drains as much of the queue as the constraints allow, then either arms the
|
|
178
|
+
* dispatch timer for the next opening or leaves no timer at all. FIFO: only
|
|
179
|
+
* the head is ever considered, so a cheap arrival never overtakes.
|
|
180
|
+
*/
|
|
181
|
+
function pump() {
|
|
182
|
+
clearDispatchTimer();
|
|
183
|
+
while (queue.length > 0) {
|
|
184
|
+
if (maxConcurrent !== undefined && active >= maxConcurrent)
|
|
185
|
+
return;
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const earliest = earliestStart(starts, now);
|
|
188
|
+
if (earliest > now) {
|
|
189
|
+
dispatchTimer = setTimeout(() => {
|
|
190
|
+
dispatchTimer = undefined;
|
|
191
|
+
pump();
|
|
192
|
+
}, earliest - now);
|
|
193
|
+
dispatchTimer.unref?.();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const entry = queue.shift();
|
|
197
|
+
if (!entry)
|
|
198
|
+
return;
|
|
199
|
+
detach(entry);
|
|
200
|
+
starts.push(now);
|
|
201
|
+
while (starts.length > 0 && starts[0] <= now - retentionMs)
|
|
202
|
+
starts.shift();
|
|
203
|
+
entry.dispatch(now);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Closes the shared gate on an upstream rate limit:
|
|
208
|
+
* `min(max(baseMs · 2^(consecutive−1), retryAfter), maxMs)`, as an absolute
|
|
209
|
+
* instant. Any other error leaves it open; the first success resets the count.
|
|
210
|
+
*/
|
|
211
|
+
function noteRateLimit(error) {
|
|
212
|
+
if (!cooldown)
|
|
213
|
+
return;
|
|
214
|
+
if (!(error instanceof McpError) || error.code !== JsonRpcErrorCode.RateLimited)
|
|
215
|
+
return;
|
|
216
|
+
consecutiveRateLimits++;
|
|
217
|
+
const doubled = cooldown.baseMs * 2 ** (consecutiveRateLimits - 1);
|
|
218
|
+
// An absent or unparseable hint contributes nothing, leaving the doubling.
|
|
219
|
+
const honored = parseRetryAfterMs(error) ?? 0;
|
|
220
|
+
const waitMs = Math.min(Math.max(doubled, honored), cooldown.maxMs);
|
|
221
|
+
gateOpensAt = Math.max(gateOpensAt, Date.now() + waitMs);
|
|
222
|
+
getPacerMetrics().cooldowns.add(1, attributes);
|
|
223
|
+
}
|
|
224
|
+
function run(task, runOptions = {}) {
|
|
225
|
+
const { maxWaitMs, signal } = runOptions;
|
|
226
|
+
if (disposed) {
|
|
227
|
+
return Promise.reject(requestCancelled(`The ${name} pacer has been disposed.`));
|
|
228
|
+
}
|
|
229
|
+
if (signal?.aborted)
|
|
230
|
+
return Promise.reject(signal.reason);
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const ahead = queue.length;
|
|
233
|
+
// Absolute backpressure first, so it rejects without arming a timer.
|
|
234
|
+
if (maxQueueDepth !== undefined && ahead >= maxQueueDepth) {
|
|
235
|
+
return Promise.reject(shed(secondsUntilSlot(now, ahead), ahead));
|
|
236
|
+
}
|
|
237
|
+
if (maxWaitMs !== undefined && projectedStart(now, ahead) - now > maxWaitMs) {
|
|
238
|
+
return Promise.reject(shed(secondsUntilSlot(now, ahead), ahead));
|
|
239
|
+
}
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const entry = {
|
|
242
|
+
enqueuedAt: now,
|
|
243
|
+
reject,
|
|
244
|
+
signal,
|
|
245
|
+
dispatch: (startedAt) => {
|
|
246
|
+
active++;
|
|
247
|
+
getPacerMetrics().wait.record(startedAt - entry.enqueuedAt, attributes);
|
|
248
|
+
// Called inside an async wrapper so a task that throws before returning
|
|
249
|
+
// a promise rejects its own caller instead of escaping into `pump()`.
|
|
250
|
+
void (async () => task(signal ?? new AbortController().signal))().then((value) => {
|
|
251
|
+
consecutiveRateLimits = 0;
|
|
252
|
+
resolve(value);
|
|
253
|
+
active--;
|
|
254
|
+
pump();
|
|
255
|
+
}, (error) => {
|
|
256
|
+
noteRateLimit(error);
|
|
257
|
+
reject(error);
|
|
258
|
+
active--;
|
|
259
|
+
pump();
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
if (maxWaitMs !== undefined) {
|
|
264
|
+
entry.shedTimer = setTimeout(() => {
|
|
265
|
+
entry.shedTimer = undefined;
|
|
266
|
+
if (!remove(entry))
|
|
267
|
+
return;
|
|
268
|
+
reject(shed(secondsUntilSlot(Date.now(), 0), queue.length));
|
|
269
|
+
// The shed entry is gone; whoever is behind it should not wait on it.
|
|
270
|
+
pump();
|
|
271
|
+
}, maxWaitMs);
|
|
272
|
+
entry.shedTimer.unref?.();
|
|
273
|
+
}
|
|
274
|
+
if (signal) {
|
|
275
|
+
entry.onAbort = () => {
|
|
276
|
+
if (!remove(entry))
|
|
277
|
+
return;
|
|
278
|
+
reject(signal.reason);
|
|
279
|
+
pump();
|
|
280
|
+
};
|
|
281
|
+
signal.addEventListener('abort', entry.onAbort, { once: true });
|
|
282
|
+
}
|
|
283
|
+
queue.push(entry);
|
|
284
|
+
getPacerMetrics().queueDepth.add(1, attributes);
|
|
285
|
+
pump();
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function dispose() {
|
|
289
|
+
if (disposed)
|
|
290
|
+
return;
|
|
291
|
+
disposed = true;
|
|
292
|
+
clearDispatchTimer();
|
|
293
|
+
for (const entry of queue.splice(0, queue.length)) {
|
|
294
|
+
detach(entry);
|
|
295
|
+
entry.reject(requestCancelled(`The ${name} pacer has been disposed.`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
dispose,
|
|
300
|
+
run,
|
|
301
|
+
[Symbol.dispose]: dispose,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
//# sourceMappingURL=pacer.js.map
|