@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68
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/AUTHORING.md +28 -1
- package/CHANGELOG.md +8 -0
- package/README.md +8 -4
- package/bin/apifuse-record.ts +207 -31
- package/bin/apifuse-submit-check.ts +273 -74
- package/dist/cli/local-connection.d.ts +111 -0
- package/dist/cli/local-connection.js +151 -0
- package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/dist/cli/templates/provider/README.md.tpl +17 -9
- package/dist/config/loader.d.ts +16 -0
- package/dist/config/loader.js +25 -8
- package/dist/error-resolution.d.ts +13 -0
- package/dist/error-resolution.js +72 -35
- package/dist/lint.d.ts +2 -0
- package/dist/lint.js +493 -2
- package/dist/runtime/executor.js +3 -0
- package/dist/runtime/http.d.ts +2 -0
- package/dist/runtime/http.js +2 -1
- package/dist/runtime/provider.d.ts +7 -0
- package/dist/runtime/provider.js +14 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/serve-implementation.d.ts +2 -1
- package/dist/server/serve-implementation.js +43 -29
- package/dist/server/state-backend-report.d.ts +70 -0
- package/dist/server/state-backend-report.js +113 -0
- package/package.json +1 -1
- package/src/cli/local-connection.ts +221 -0
- package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
- package/src/cli/templates/provider/README.md.tpl +17 -9
- package/src/config/loader.ts +49 -12
- package/src/error-resolution.ts +89 -35
- package/src/lint.ts +541 -1
- package/src/runtime/executor.ts +3 -0
- package/src/runtime/http.ts +2 -1
- package/src/runtime/provider.ts +15 -0
- package/src/server/index.ts +4 -0
- package/src/server/serve-implementation.ts +56 -30
- package/src/server/state-backend-report.ts +185 -0
|
@@ -29,6 +29,7 @@ import { SDK_VERSION } from "./sdk-version.js";
|
|
|
29
29
|
import { safeProviderErrorObservability } from "../error-observability.js";
|
|
30
30
|
import { providerErrorStackFrames } from "./error-stack-frames.js";
|
|
31
31
|
import {
|
|
32
|
+
registeredUpstreamErrorCategory,
|
|
32
33
|
SDK_OWNED_PROVIDER_ERROR_CODES,
|
|
33
34
|
SDK_RUNTIME_OWNED_ERROR_CODES,
|
|
34
35
|
SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
|
|
@@ -106,7 +107,7 @@ import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
|
|
|
106
107
|
import type * as NativeNetworkRuntimeModule from "../runtime/native-network.js";
|
|
107
108
|
import { createOcrClientFromEnv } from "../runtime/ocr.js";
|
|
108
109
|
import { bindOcrTelemetry, OcrTelemetryCollector } from "../runtime/ocr-telemetry.js";
|
|
109
|
-
import { getProviderBaseUrl } from "../runtime/provider.js";
|
|
110
|
+
import { getProviderBaseUrl, getProviderStealthBaseUrl } from "../runtime/provider.js";
|
|
110
111
|
import {
|
|
111
112
|
PROXY_AUTH_IP_DENIED_CODE,
|
|
112
113
|
PROXY_EDGE_AUTH_REJECTED_CODE,
|
|
@@ -216,6 +217,10 @@ import type {
|
|
|
216
217
|
} from "../types.js";
|
|
217
218
|
import { VALID_OPERATION_ERROR_STATUSES } from "../types.js";
|
|
218
219
|
import type { SelfTestCancellationLogEvent } from "./self-test.js";
|
|
220
|
+
import {
|
|
221
|
+
type ProviderStateBackendLogEvent,
|
|
222
|
+
providerStateBackendLogEvent,
|
|
223
|
+
} from "./state-backend-report.js";
|
|
219
224
|
import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
|
|
220
225
|
import {
|
|
221
226
|
collectStaticDiagnosticSensitiveValues,
|
|
@@ -635,15 +640,6 @@ function bindResolverSignalWithoutRuntime(
|
|
|
635
640
|
return boundResolver;
|
|
636
641
|
}
|
|
637
642
|
|
|
638
|
-
function getProviderStealthBaseUrl(provider: ProviderDefinition): string | undefined {
|
|
639
|
-
const baseUrl = getProviderBaseUrl(provider);
|
|
640
|
-
if (baseUrl) {
|
|
641
|
-
return baseUrl;
|
|
642
|
-
}
|
|
643
|
-
const firstHost = provider.allowedHosts?.[0];
|
|
644
|
-
return firstHost ? `https://${firstHost}` : undefined;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
643
|
function getProviderStealthProfile(provider: ProviderDefinition) {
|
|
648
644
|
return provider.stealth ? getStealthProfile(provider.stealth) : undefined;
|
|
649
645
|
}
|
|
@@ -1476,6 +1472,7 @@ export type ProviderServerLogEvent =
|
|
|
1476
1472
|
*/
|
|
1477
1473
|
warnings?: readonly string[];
|
|
1478
1474
|
}
|
|
1475
|
+
| ProviderStateBackendLogEvent
|
|
1479
1476
|
| SelfTestCancellationLogEvent;
|
|
1480
1477
|
|
|
1481
1478
|
export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
|
|
@@ -1607,8 +1604,9 @@ function zodDetails(
|
|
|
1607
1604
|
// (credential_unavailable) usually means a caller credential problem, an
|
|
1608
1605
|
// internal stateful-routing deadline is APIFuse-owned despite its timeout
|
|
1609
1606
|
// category, and the built-in upstream failure families keep their upstream
|
|
1610
|
-
// attribution even
|
|
1611
|
-
//
|
|
1607
|
+
// attribution even under an explicit non-upstream category. An unset category
|
|
1608
|
+
// needs no override: a registered upstream code already carries its category
|
|
1609
|
+
// (SDK_UPSTREAM_ERROR_CODE_REGISTRY), and the source follows from it.
|
|
1612
1610
|
function publicErrorSource(error: unknown, category: ProviderErrorCategory): ProviderErrorSource {
|
|
1613
1611
|
if (error instanceof StatefulRoutingDeadlineError) return "apifuse";
|
|
1614
1612
|
if (isProviderError(error)) {
|
|
@@ -1931,6 +1929,24 @@ function providerObservabilityDetails(
|
|
|
1931
1929
|
};
|
|
1932
1930
|
}
|
|
1933
1931
|
|
|
1932
|
+
// The category a `ProviderError` (validation errors included) gets when its
|
|
1933
|
+
// author set none, ahead of the branch's own default: an operation-declared
|
|
1934
|
+
// rejection status (409/410/422) → upstream_rejected, then the registered
|
|
1935
|
+
// upstream code's category (UPSTREAM_SCHEMA_ERROR → upstream_schema_drift,
|
|
1936
|
+
// NOT_FOUND → upstream_http, ...). `undefined` leaves the branch default.
|
|
1937
|
+
function derivedErrorCategory(
|
|
1938
|
+
error: unknown,
|
|
1939
|
+
declaredStatus: unknown,
|
|
1940
|
+
): ProviderErrorCategory | undefined {
|
|
1941
|
+
if (
|
|
1942
|
+
isEmittableErrorStatus(declaredStatus) &&
|
|
1943
|
+
categoryForStatus(declaredStatus) === "upstream_rejected"
|
|
1944
|
+
) {
|
|
1945
|
+
return "upstream_rejected";
|
|
1946
|
+
}
|
|
1947
|
+
return registeredUpstreamErrorCategory(providerErrorCode(error));
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1934
1950
|
function classifiedErrorObservabilityDetails(
|
|
1935
1951
|
error: unknown,
|
|
1936
1952
|
declaredErrorCode?: OperationErrorCode,
|
|
@@ -1945,12 +1961,10 @@ function classifiedErrorObservabilityDetails(
|
|
|
1945
1961
|
return {
|
|
1946
1962
|
category: providerCategory
|
|
1947
1963
|
? providerCategory
|
|
1948
|
-
:
|
|
1949
|
-
|
|
1950
|
-
? "upstream_rejected"
|
|
1951
|
-
: isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
|
|
1964
|
+
: (derivedErrorCategory(error, declaredStatus) ??
|
|
1965
|
+
(isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
|
|
1952
1966
|
? "provider_error"
|
|
1953
|
-
: "input_validation",
|
|
1967
|
+
: "input_validation")),
|
|
1954
1968
|
taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
|
|
1955
1969
|
retryable: isProviderError(error)
|
|
1956
1970
|
? (providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false)
|
|
@@ -1967,19 +1981,13 @@ function classifiedErrorObservabilityDetails(
|
|
|
1967
1981
|
}
|
|
1968
1982
|
|
|
1969
1983
|
if (isProviderError(error)) {
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
// status (409/410/422) classify as upstream_rejected unless the
|
|
1973
|
-
// author set an explicit category.
|
|
1974
|
-
const declaredStatus = effectiveDeclaration?.status;
|
|
1975
|
-
const rejectionDefault =
|
|
1976
|
-
providerErrorCode(error) === "UPSTREAM_REJECTED" ||
|
|
1977
|
-
(isEmittableErrorStatus(declaredStatus) &&
|
|
1978
|
-
categoryForStatus(declaredStatus) === "upstream_rejected")
|
|
1979
|
-
? ("upstream_rejected" as const)
|
|
1980
|
-
: ("provider_error" as const);
|
|
1984
|
+
// Retryability does not follow the derived category: it stays
|
|
1985
|
+
// instance ?? declared ?? false below.
|
|
1981
1986
|
return {
|
|
1982
|
-
category:
|
|
1987
|
+
category:
|
|
1988
|
+
providerErrorOption(error, "category") ??
|
|
1989
|
+
derivedErrorCategory(error, effectiveDeclaration?.status) ??
|
|
1990
|
+
"provider_error",
|
|
1983
1991
|
taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
|
|
1984
1992
|
retryable:
|
|
1985
1993
|
providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false,
|
|
@@ -3755,7 +3763,13 @@ function createServerAppWithCapabilityModules(
|
|
|
3755
3763
|
}
|
|
3756
3764
|
|
|
3757
3765
|
app.notFound((c) => {
|
|
3758
|
-
|
|
3766
|
+
// An unknown route is not an upstream answer: the explicit category keeps
|
|
3767
|
+
// the registered not_found code from attributing it to an upstream.
|
|
3768
|
+
const error = new ProviderError("Not found", {
|
|
3769
|
+
code: "not_found",
|
|
3770
|
+
category: "provider_error",
|
|
3771
|
+
retryable: false,
|
|
3772
|
+
});
|
|
3759
3773
|
const observabilityDetails = errorObservabilityDetails(error);
|
|
3760
3774
|
return responseWithErrorObservability(
|
|
3761
3775
|
c.json(
|
|
@@ -4354,6 +4368,18 @@ export async function serve<TContext extends Partial<ProviderContext> = Provider
|
|
|
4354
4368
|
!isUnavailableProviderEngine(engineSelection.engine),
|
|
4355
4369
|
),
|
|
4356
4370
|
);
|
|
4371
|
+
// Same contract for the stores: one line per process boot naming the state
|
|
4372
|
+
// and cache backends this pod resolved, so "which Redis is this provider's
|
|
4373
|
+
// state actually on" is a log grep instead of an inference from the
|
|
4374
|
+
// Deployment spec (apifuse#2302).
|
|
4375
|
+
logger(
|
|
4376
|
+
providerStateBackendLogEvent({
|
|
4377
|
+
providerId: provider.id,
|
|
4378
|
+
sdkVersion: SDK_VERSION,
|
|
4379
|
+
injectedState: options.state !== undefined,
|
|
4380
|
+
allowMemoryFallback: options.allowMemoryStateFallback === true,
|
|
4381
|
+
}),
|
|
4382
|
+
);
|
|
4357
4383
|
|
|
4358
4384
|
const servers: BunServerHandle[] = [];
|
|
4359
4385
|
try {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
providerCacheRedisResolutionFromEnv,
|
|
3
|
+
type ProviderRedisUrlResolution,
|
|
4
|
+
providerStateRedisResolutionFromEnv,
|
|
5
|
+
} from "../config/loader.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a provider store resolved to at boot.
|
|
9
|
+
*
|
|
10
|
+
* `endpoint` is deliberately host and port only: the URL can carry a password
|
|
11
|
+
* (`redis://:secret@host:6379`), and the diagnostic inventory already treats
|
|
12
|
+
* those credential components — and only those — as sensitive
|
|
13
|
+
* (`collectDiagnosticSensitiveValues`, `urlCredentialComponents(url, {
|
|
14
|
+
* includePathAndQuery: false })`). A URL that does not parse contributes no
|
|
15
|
+
* endpoint at all rather than a redacted-looking fragment of itself.
|
|
16
|
+
*/
|
|
17
|
+
export type ProviderStoreBackendReport = {
|
|
18
|
+
/**
|
|
19
|
+
* `redis` when a URL resolved, `memory` for the in-process fallback,
|
|
20
|
+
* `unsupported` when state has no backend and every call fails closed,
|
|
21
|
+
* `injected` when the host handed the server a store object.
|
|
22
|
+
*/
|
|
23
|
+
readonly backend: "redis" | "memory" | "unsupported" | "injected";
|
|
24
|
+
/** Env name that supplied the Redis URL. Present only for `redis`. */
|
|
25
|
+
readonly envName?: string;
|
|
26
|
+
/**
|
|
27
|
+
* `host:port` of the resolved Redis. Never includes credentials, path or
|
|
28
|
+
* query. Absent when the URL does not parse, or when its query carries an
|
|
29
|
+
* ioredis option that moves the endpoint (`path`, `port`, `host`, `family`)
|
|
30
|
+
* so the authority is not where the client connects — see
|
|
31
|
+
* `state_redis_url_ambiguous_endpoint`.
|
|
32
|
+
*/
|
|
33
|
+
readonly endpoint?: string;
|
|
34
|
+
/** URL scheme, e.g. `redis` or `rediss`. Present only for `redis`. */
|
|
35
|
+
readonly scheme?: string;
|
|
36
|
+
/** True when the URL came from a shared fallback env rather than the store's own. */
|
|
37
|
+
readonly fallback?: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One line per process boot naming the stores the provider resolved. Without
|
|
42
|
+
* it, a provider silently riding the cache Redis for its state is
|
|
43
|
+
* indistinguishable from one on a durable instance, from outside the pod and
|
|
44
|
+
* from the pod's own logs (apifuse#2302, the reason apifuse#2144 could only be
|
|
45
|
+
* verified by inference from the Deployment spec).
|
|
46
|
+
*/
|
|
47
|
+
export type ProviderStateBackendLogEvent = {
|
|
48
|
+
/** `warn` when a warning was raised, otherwise `info`. */
|
|
49
|
+
readonly level: "info" | "warn";
|
|
50
|
+
readonly event: "provider_state_backend";
|
|
51
|
+
readonly providerId: string;
|
|
52
|
+
readonly state: ProviderStoreBackendReport;
|
|
53
|
+
readonly cache: ProviderStoreBackendReport;
|
|
54
|
+
readonly sdkVersion: string;
|
|
55
|
+
/**
|
|
56
|
+
* Non-fatal findings, e.g. `state_redis_url_fallback` (the state store is
|
|
57
|
+
* sharing an instance provisioned for something else),
|
|
58
|
+
* `state_redis_url_unparsed`, or `state_redis_url_ambiguous_endpoint` (the
|
|
59
|
+
* URL's query carries an ioredis option that moves the endpoint, so the
|
|
60
|
+
* authority is not reported as the endpoint). Absent when the resolution
|
|
61
|
+
* was unambiguous.
|
|
62
|
+
*/
|
|
63
|
+
readonly warnings?: readonly string[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether ioredis' own URL parsing would move the endpoint away from the
|
|
68
|
+
* authority this function can see. `createProviderRedisClient` hands the raw
|
|
69
|
+
* URL to `new Redis(url, …)`, and `parseURL` ends with
|
|
70
|
+
* `defaults(result, queryOptions)` — lodash `defaults`, so a query key only
|
|
71
|
+
* applies where the authority supplied nothing:
|
|
72
|
+
*
|
|
73
|
+
* - `path` is never derived from the authority of a `redis://` URL (that
|
|
74
|
+
* pathname is the db index), so `?path=` always applies — and
|
|
75
|
+
* `StandaloneConnector` connects to `options.path` INSTEAD of host/port.
|
|
76
|
+
* The client is then on a Unix socket the authority says nothing about.
|
|
77
|
+
* - `port` applies only when the URL carries no explicit port.
|
|
78
|
+
* - `host` never applies here: we return early unless `parsed.hostname` is
|
|
79
|
+
* set, so `result.host` is always already populated.
|
|
80
|
+
* - `family` selects an address family; it does not move `host:port`.
|
|
81
|
+
*
|
|
82
|
+
* Verified against the pinned ioredis 5.11.1. In those cases the endpoint is
|
|
83
|
+
* WITHHELD and the ambiguity named, rather than reported as the authority — a
|
|
84
|
+
* diagnostic whose whole job is to answer "which Redis is this provider on"
|
|
85
|
+
* must not answer it wrongly. The query is never echoed: it can carry a
|
|
86
|
+
* password, which is why `endpoint` is host-and-port only to begin with.
|
|
87
|
+
*/
|
|
88
|
+
function queryMovesEndpoint(parsed: URL): boolean {
|
|
89
|
+
if (parsed.searchParams.has("path")) return true;
|
|
90
|
+
return parsed.searchParams.has("port") && parsed.port === "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function redisEndpoint(url: string): {
|
|
94
|
+
endpoint?: string;
|
|
95
|
+
scheme?: string;
|
|
96
|
+
ambiguous?: boolean;
|
|
97
|
+
} {
|
|
98
|
+
let parsed: URL;
|
|
99
|
+
try {
|
|
100
|
+
parsed = new URL(url);
|
|
101
|
+
} catch {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
// No authority means this is not the `redis://host:port` shape at all (e.g.
|
|
105
|
+
// a bare `host:port`, which parses as a scheme). Report nothing rather than
|
|
106
|
+
// a fragment of a string whose parts we have not identified.
|
|
107
|
+
if (!parsed.hostname) return {};
|
|
108
|
+
const scheme = parsed.protocol.replace(/:$/, "");
|
|
109
|
+
if (queryMovesEndpoint(parsed)) return { scheme, ambiguous: true };
|
|
110
|
+
return {
|
|
111
|
+
scheme,
|
|
112
|
+
endpoint: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function redisReport(resolution: ProviderRedisUrlResolution): {
|
|
117
|
+
report: ProviderStoreBackendReport;
|
|
118
|
+
parsed: boolean;
|
|
119
|
+
ambiguous: boolean;
|
|
120
|
+
} {
|
|
121
|
+
const { endpoint, scheme, ambiguous } = redisEndpoint(resolution.url);
|
|
122
|
+
return {
|
|
123
|
+
parsed: endpoint !== undefined || ambiguous === true,
|
|
124
|
+
ambiguous: ambiguous === true,
|
|
125
|
+
report: {
|
|
126
|
+
backend: "redis",
|
|
127
|
+
envName: resolution.envName,
|
|
128
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
129
|
+
...(scheme === undefined ? {} : { scheme }),
|
|
130
|
+
...(resolution.fallback ? { fallback: true } : {}),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build the boot event from the same env precedence the stores themselves use
|
|
137
|
+
* (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
|
|
138
|
+
* so the line can never describe a backend other than the one in use.
|
|
139
|
+
*/
|
|
140
|
+
export function providerStateBackendLogEvent(input: {
|
|
141
|
+
readonly providerId: string;
|
|
142
|
+
readonly sdkVersion: string;
|
|
143
|
+
/** The host passed an explicit state store, so no env resolution happened. */
|
|
144
|
+
readonly injectedState: boolean;
|
|
145
|
+
/** Mirrors `createProviderRuntimeStateFromEnv({ allowMemoryFallback })`. */
|
|
146
|
+
readonly allowMemoryFallback: boolean;
|
|
147
|
+
}): ProviderStateBackendLogEvent {
|
|
148
|
+
const warnings: string[] = [];
|
|
149
|
+
let state: ProviderStoreBackendReport;
|
|
150
|
+
if (input.injectedState) {
|
|
151
|
+
state = { backend: "injected" };
|
|
152
|
+
} else {
|
|
153
|
+
const resolution = providerStateRedisResolutionFromEnv();
|
|
154
|
+
if (resolution) {
|
|
155
|
+
const { report, parsed, ambiguous } = redisReport(resolution);
|
|
156
|
+
state = report;
|
|
157
|
+
if (!parsed) warnings.push("state_redis_url_unparsed");
|
|
158
|
+
if (ambiguous) warnings.push("state_redis_url_ambiguous_endpoint");
|
|
159
|
+
if (resolution.fallback) warnings.push("state_redis_url_fallback");
|
|
160
|
+
} else {
|
|
161
|
+
state = { backend: input.allowMemoryFallback ? "memory" : "unsupported" };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cacheResolution = providerCacheRedisResolutionFromEnv();
|
|
166
|
+
let cache: ProviderStoreBackendReport;
|
|
167
|
+
if (cacheResolution) {
|
|
168
|
+
const { report, parsed, ambiguous } = redisReport(cacheResolution);
|
|
169
|
+
cache = report;
|
|
170
|
+
if (!parsed) warnings.push("cache_redis_url_unparsed");
|
|
171
|
+
if (ambiguous) warnings.push("cache_redis_url_ambiguous_endpoint");
|
|
172
|
+
} else {
|
|
173
|
+
cache = { backend: "memory" };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
level: warnings.length > 0 ? "warn" : "info",
|
|
178
|
+
event: "provider_state_backend",
|
|
179
|
+
providerId: input.providerId,
|
|
180
|
+
state,
|
|
181
|
+
cache,
|
|
182
|
+
sdkVersion: input.sdkVersion,
|
|
183
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
184
|
+
};
|
|
185
|
+
}
|