@apifuse/provider-sdk 2.2.0-beta.67 → 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 +4 -0
- package/README.md +8 -4
- package/bin/apifuse-record.ts +81 -21
- package/bin/apifuse-submit-check.ts +170 -28
- 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/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
|
@@ -15,7 +15,7 @@ import { createEngineForMode, isUnavailableProviderEngine, resolveProviderEngine
|
|
|
15
15
|
import { SDK_VERSION } from "./sdk-version.js";
|
|
16
16
|
import { safeProviderErrorObservability } from "../error-observability.js";
|
|
17
17
|
import { providerErrorStackFrames } from "./error-stack-frames.js";
|
|
18
|
-
import { SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
|
|
18
|
+
import { registeredUpstreamErrorCategory, SDK_OWNED_PROVIDER_ERROR_CODES, SDK_RUNTIME_OWNED_ERROR_CODES, SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES, } from "../error-resolution.js";
|
|
19
19
|
import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, SDKError, } from "../errors.js";
|
|
20
20
|
import { sanitizeDiagnosticText } from "../fixture-sanitization.js";
|
|
21
21
|
import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
|
|
@@ -37,7 +37,7 @@ import { HttpTelemetryCollector } from "../runtime/http-telemetry.js";
|
|
|
37
37
|
import { wrapWithInstrumentation } from "../runtime/instrumentation.js";
|
|
38
38
|
import { createOcrClientFromEnv } from "../runtime/ocr.js";
|
|
39
39
|
import { bindOcrTelemetry, OcrTelemetryCollector } from "../runtime/ocr-telemetry.js";
|
|
40
|
-
import { getProviderBaseUrl } from "../runtime/provider.js";
|
|
40
|
+
import { getProviderBaseUrl, getProviderStealthBaseUrl } from "../runtime/provider.js";
|
|
41
41
|
import { PROXY_AUTH_IP_DENIED_CODE, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_EXHAUSTED_CODE, } from "../runtime/proxy-errors.js";
|
|
42
42
|
import { PROVIDER_TELEMETRY_HEADER, ProxyTelemetryCollector, } from "../runtime/proxy-telemetry.js";
|
|
43
43
|
import { closedEnum, RequestTelemetry, tenantOpaqueKeys, } from "../runtime/request-telemetry.js";
|
|
@@ -62,6 +62,7 @@ import { getStealthProfile } from "../stealth/profiles.js";
|
|
|
62
62
|
import { APIFUSE_STREAM_DONE_EVENT, APIFUSE_STREAM_ERROR_EVENT, encodeSseEvent, error as streamError, } from "../stream.js";
|
|
63
63
|
import { sanitizeSpanNameForOutput, sanitizeTraceAttributes } from "../trace-sanitization.js";
|
|
64
64
|
import { VALID_OPERATION_ERROR_STATUSES } from "../types.js";
|
|
65
|
+
import { providerStateBackendLogEvent, } from "./state-backend-report.js";
|
|
65
66
|
import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
|
|
66
67
|
import { collectStaticDiagnosticSensitiveValues, createDiagnosticEnvObserver, } from "./sensitive-values.js";
|
|
67
68
|
import { resolveServerTraceContextOptions } from "./trace-output.js";
|
|
@@ -340,14 +341,6 @@ function bindResolverSignalWithoutRuntime(resolver, defaultSignal) {
|
|
|
340
341
|
inheritResolverTelemetryBinding(resolver, boundResolver);
|
|
341
342
|
return boundResolver;
|
|
342
343
|
}
|
|
343
|
-
function getProviderStealthBaseUrl(provider) {
|
|
344
|
-
const baseUrl = getProviderBaseUrl(provider);
|
|
345
|
-
if (baseUrl) {
|
|
346
|
-
return baseUrl;
|
|
347
|
-
}
|
|
348
|
-
const firstHost = provider.allowedHosts?.[0];
|
|
349
|
-
return firstHost ? `https://${firstHost}` : undefined;
|
|
350
|
-
}
|
|
351
344
|
function getProviderStealthProfile(provider) {
|
|
352
345
|
return provider.stealth ? getStealthProfile(provider.stealth) : undefined;
|
|
353
346
|
}
|
|
@@ -849,8 +842,9 @@ function zodDetails(error, redact) {
|
|
|
849
842
|
// (credential_unavailable) usually means a caller credential problem, an
|
|
850
843
|
// internal stateful-routing deadline is APIFuse-owned despite its timeout
|
|
851
844
|
// category, and the built-in upstream failure families keep their upstream
|
|
852
|
-
// attribution even
|
|
853
|
-
//
|
|
845
|
+
// attribution even under an explicit non-upstream category. An unset category
|
|
846
|
+
// needs no override: a registered upstream code already carries its category
|
|
847
|
+
// (SDK_UPSTREAM_ERROR_CODE_REGISTRY), and the source follows from it.
|
|
854
848
|
function publicErrorSource(error, category) {
|
|
855
849
|
if (error instanceof StatefulRoutingDeadlineError)
|
|
856
850
|
return "apifuse";
|
|
@@ -1120,6 +1114,18 @@ function providerObservabilityDetails(error, declaredErrorCode) {
|
|
|
1120
1114
|
...(error.upstreamStatus ? { upstreamStatus: error.upstreamStatus } : {}),
|
|
1121
1115
|
};
|
|
1122
1116
|
}
|
|
1117
|
+
// The category a `ProviderError` (validation errors included) gets when its
|
|
1118
|
+
// author set none, ahead of the branch's own default: an operation-declared
|
|
1119
|
+
// rejection status (409/410/422) → upstream_rejected, then the registered
|
|
1120
|
+
// upstream code's category (UPSTREAM_SCHEMA_ERROR → upstream_schema_drift,
|
|
1121
|
+
// NOT_FOUND → upstream_http, ...). `undefined` leaves the branch default.
|
|
1122
|
+
function derivedErrorCategory(error, declaredStatus) {
|
|
1123
|
+
if (isEmittableErrorStatus(declaredStatus) &&
|
|
1124
|
+
categoryForStatus(declaredStatus) === "upstream_rejected") {
|
|
1125
|
+
return "upstream_rejected";
|
|
1126
|
+
}
|
|
1127
|
+
return registeredUpstreamErrorCategory(providerErrorCode(error));
|
|
1128
|
+
}
|
|
1123
1129
|
function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
|
|
1124
1130
|
const effectiveDeclaration = sdkOwnsErrorResolution(error) ? undefined : declaredErrorCode;
|
|
1125
1131
|
const providerDetails = providerObservabilityDetails(error, effectiveDeclaration);
|
|
@@ -1131,12 +1137,10 @@ function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
|
|
|
1131
1137
|
return {
|
|
1132
1138
|
category: providerCategory
|
|
1133
1139
|
? providerCategory
|
|
1134
|
-
:
|
|
1135
|
-
|
|
1136
|
-
? "upstream_rejected"
|
|
1137
|
-
: isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
|
|
1140
|
+
: (derivedErrorCategory(error, declaredStatus) ??
|
|
1141
|
+
(isEmittableErrorStatus(declaredStatus) && declaredStatus >= 500
|
|
1138
1142
|
? "provider_error"
|
|
1139
|
-
: "input_validation",
|
|
1143
|
+
: "input_validation")),
|
|
1140
1144
|
taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
|
|
1141
1145
|
retryable: isProviderError(error)
|
|
1142
1146
|
? (providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false)
|
|
@@ -1151,18 +1155,12 @@ function classifiedErrorObservabilityDetails(error, declaredErrorCode) {
|
|
|
1151
1155
|
};
|
|
1152
1156
|
}
|
|
1153
1157
|
if (isProviderError(error)) {
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
// status (409/410/422) classify as upstream_rejected unless the
|
|
1157
|
-
// author set an explicit category.
|
|
1158
|
-
const declaredStatus = effectiveDeclaration?.status;
|
|
1159
|
-
const rejectionDefault = providerErrorCode(error) === "UPSTREAM_REJECTED" ||
|
|
1160
|
-
(isEmittableErrorStatus(declaredStatus) &&
|
|
1161
|
-
categoryForStatus(declaredStatus) === "upstream_rejected")
|
|
1162
|
-
? "upstream_rejected"
|
|
1163
|
-
: "provider_error";
|
|
1158
|
+
// Retryability does not follow the derived category: it stays
|
|
1159
|
+
// instance ?? declared ?? false below.
|
|
1164
1160
|
return {
|
|
1165
|
-
category: providerErrorOption(error, "category") ??
|
|
1161
|
+
category: providerErrorOption(error, "category") ??
|
|
1162
|
+
derivedErrorCategory(error, effectiveDeclaration?.status) ??
|
|
1163
|
+
"provider_error",
|
|
1166
1164
|
taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
|
|
1167
1165
|
retryable: providerErrorOption(error, "retryable") ?? effectiveDeclaration?.retryable ?? false,
|
|
1168
1166
|
};
|
|
@@ -2560,7 +2558,13 @@ function createServerAppWithCapabilityModules(provider, serverOptions, capabilit
|
|
|
2560
2558
|
});
|
|
2561
2559
|
}
|
|
2562
2560
|
app.notFound((c) => {
|
|
2563
|
-
|
|
2561
|
+
// An unknown route is not an upstream answer: the explicit category keeps
|
|
2562
|
+
// the registered not_found code from attributing it to an upstream.
|
|
2563
|
+
const error = new ProviderError("Not found", {
|
|
2564
|
+
code: "not_found",
|
|
2565
|
+
category: "provider_error",
|
|
2566
|
+
retryable: false,
|
|
2567
|
+
});
|
|
2564
2568
|
const observabilityDetails = errorObservabilityDetails(error);
|
|
2565
2569
|
return responseWithErrorObservability(c.json(toErrorResponse(error, undefined, observabilityDetails, undefined, errorEnvelopeLocalization({
|
|
2566
2570
|
catalogs: localeCatalogs,
|
|
@@ -2968,6 +2972,16 @@ export async function serve(provider, options = {}) {
|
|
|
2968
2972
|
// One structured line per process boot, before the listener binds: the fleet
|
|
2969
2973
|
// audit (ADR-0011 migration) counts these.
|
|
2970
2974
|
logger(engineModeLogEvent(provider, engineSelection.resolution, !isUnavailableProviderEngine(engineSelection.engine)));
|
|
2975
|
+
// Same contract for the stores: one line per process boot naming the state
|
|
2976
|
+
// and cache backends this pod resolved, so "which Redis is this provider's
|
|
2977
|
+
// state actually on" is a log grep instead of an inference from the
|
|
2978
|
+
// Deployment spec (apifuse#2302).
|
|
2979
|
+
logger(providerStateBackendLogEvent({
|
|
2980
|
+
providerId: provider.id,
|
|
2981
|
+
sdkVersion: SDK_VERSION,
|
|
2982
|
+
injectedState: options.state !== undefined,
|
|
2983
|
+
allowMemoryFallback: options.allowMemoryStateFallback === true,
|
|
2984
|
+
}));
|
|
2971
2985
|
const servers = [];
|
|
2972
2986
|
try {
|
|
2973
2987
|
servers.push(bunRuntime.serve({
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a provider store resolved to at boot.
|
|
3
|
+
*
|
|
4
|
+
* `endpoint` is deliberately host and port only: the URL can carry a password
|
|
5
|
+
* (`redis://:secret@host:6379`), and the diagnostic inventory already treats
|
|
6
|
+
* those credential components — and only those — as sensitive
|
|
7
|
+
* (`collectDiagnosticSensitiveValues`, `urlCredentialComponents(url, {
|
|
8
|
+
* includePathAndQuery: false })`). A URL that does not parse contributes no
|
|
9
|
+
* endpoint at all rather than a redacted-looking fragment of itself.
|
|
10
|
+
*/
|
|
11
|
+
export type ProviderStoreBackendReport = {
|
|
12
|
+
/**
|
|
13
|
+
* `redis` when a URL resolved, `memory` for the in-process fallback,
|
|
14
|
+
* `unsupported` when state has no backend and every call fails closed,
|
|
15
|
+
* `injected` when the host handed the server a store object.
|
|
16
|
+
*/
|
|
17
|
+
readonly backend: "redis" | "memory" | "unsupported" | "injected";
|
|
18
|
+
/** Env name that supplied the Redis URL. Present only for `redis`. */
|
|
19
|
+
readonly envName?: string;
|
|
20
|
+
/**
|
|
21
|
+
* `host:port` of the resolved Redis. Never includes credentials, path or
|
|
22
|
+
* query. Absent when the URL does not parse, or when its query carries an
|
|
23
|
+
* ioredis option that moves the endpoint (`path`, `port`, `host`, `family`)
|
|
24
|
+
* so the authority is not where the client connects — see
|
|
25
|
+
* `state_redis_url_ambiguous_endpoint`.
|
|
26
|
+
*/
|
|
27
|
+
readonly endpoint?: string;
|
|
28
|
+
/** URL scheme, e.g. `redis` or `rediss`. Present only for `redis`. */
|
|
29
|
+
readonly scheme?: string;
|
|
30
|
+
/** True when the URL came from a shared fallback env rather than the store's own. */
|
|
31
|
+
readonly fallback?: boolean;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* One line per process boot naming the stores the provider resolved. Without
|
|
35
|
+
* it, a provider silently riding the cache Redis for its state is
|
|
36
|
+
* indistinguishable from one on a durable instance, from outside the pod and
|
|
37
|
+
* from the pod's own logs (apifuse#2302, the reason apifuse#2144 could only be
|
|
38
|
+
* verified by inference from the Deployment spec).
|
|
39
|
+
*/
|
|
40
|
+
export type ProviderStateBackendLogEvent = {
|
|
41
|
+
/** `warn` when a warning was raised, otherwise `info`. */
|
|
42
|
+
readonly level: "info" | "warn";
|
|
43
|
+
readonly event: "provider_state_backend";
|
|
44
|
+
readonly providerId: string;
|
|
45
|
+
readonly state: ProviderStoreBackendReport;
|
|
46
|
+
readonly cache: ProviderStoreBackendReport;
|
|
47
|
+
readonly sdkVersion: string;
|
|
48
|
+
/**
|
|
49
|
+
* Non-fatal findings, e.g. `state_redis_url_fallback` (the state store is
|
|
50
|
+
* sharing an instance provisioned for something else),
|
|
51
|
+
* `state_redis_url_unparsed`, or `state_redis_url_ambiguous_endpoint` (the
|
|
52
|
+
* URL's query carries an ioredis option that moves the endpoint, so the
|
|
53
|
+
* authority is not reported as the endpoint). Absent when the resolution
|
|
54
|
+
* was unambiguous.
|
|
55
|
+
*/
|
|
56
|
+
readonly warnings?: readonly string[];
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Build the boot event from the same env precedence the stores themselves use
|
|
60
|
+
* (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
|
|
61
|
+
* so the line can never describe a backend other than the one in use.
|
|
62
|
+
*/
|
|
63
|
+
export declare function providerStateBackendLogEvent(input: {
|
|
64
|
+
readonly providerId: string;
|
|
65
|
+
readonly sdkVersion: string;
|
|
66
|
+
/** The host passed an explicit state store, so no env resolution happened. */
|
|
67
|
+
readonly injectedState: boolean;
|
|
68
|
+
/** Mirrors `createProviderRuntimeStateFromEnv({ allowMemoryFallback })`. */
|
|
69
|
+
readonly allowMemoryFallback: boolean;
|
|
70
|
+
}): ProviderStateBackendLogEvent;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { providerCacheRedisResolutionFromEnv, providerStateRedisResolutionFromEnv, } from "../config/loader.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether ioredis' own URL parsing would move the endpoint away from the
|
|
4
|
+
* authority this function can see. `createProviderRedisClient` hands the raw
|
|
5
|
+
* URL to `new Redis(url, …)`, and `parseURL` ends with
|
|
6
|
+
* `defaults(result, queryOptions)` — lodash `defaults`, so a query key only
|
|
7
|
+
* applies where the authority supplied nothing:
|
|
8
|
+
*
|
|
9
|
+
* - `path` is never derived from the authority of a `redis://` URL (that
|
|
10
|
+
* pathname is the db index), so `?path=` always applies — and
|
|
11
|
+
* `StandaloneConnector` connects to `options.path` INSTEAD of host/port.
|
|
12
|
+
* The client is then on a Unix socket the authority says nothing about.
|
|
13
|
+
* - `port` applies only when the URL carries no explicit port.
|
|
14
|
+
* - `host` never applies here: we return early unless `parsed.hostname` is
|
|
15
|
+
* set, so `result.host` is always already populated.
|
|
16
|
+
* - `family` selects an address family; it does not move `host:port`.
|
|
17
|
+
*
|
|
18
|
+
* Verified against the pinned ioredis 5.11.1. In those cases the endpoint is
|
|
19
|
+
* WITHHELD and the ambiguity named, rather than reported as the authority — a
|
|
20
|
+
* diagnostic whose whole job is to answer "which Redis is this provider on"
|
|
21
|
+
* must not answer it wrongly. The query is never echoed: it can carry a
|
|
22
|
+
* password, which is why `endpoint` is host-and-port only to begin with.
|
|
23
|
+
*/
|
|
24
|
+
function queryMovesEndpoint(parsed) {
|
|
25
|
+
if (parsed.searchParams.has("path"))
|
|
26
|
+
return true;
|
|
27
|
+
return parsed.searchParams.has("port") && parsed.port === "";
|
|
28
|
+
}
|
|
29
|
+
function redisEndpoint(url) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(url);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
// No authority means this is not the `redis://host:port` shape at all (e.g.
|
|
38
|
+
// a bare `host:port`, which parses as a scheme). Report nothing rather than
|
|
39
|
+
// a fragment of a string whose parts we have not identified.
|
|
40
|
+
if (!parsed.hostname)
|
|
41
|
+
return {};
|
|
42
|
+
const scheme = parsed.protocol.replace(/:$/, "");
|
|
43
|
+
if (queryMovesEndpoint(parsed))
|
|
44
|
+
return { scheme, ambiguous: true };
|
|
45
|
+
return {
|
|
46
|
+
scheme,
|
|
47
|
+
endpoint: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function redisReport(resolution) {
|
|
51
|
+
const { endpoint, scheme, ambiguous } = redisEndpoint(resolution.url);
|
|
52
|
+
return {
|
|
53
|
+
parsed: endpoint !== undefined || ambiguous === true,
|
|
54
|
+
ambiguous: ambiguous === true,
|
|
55
|
+
report: {
|
|
56
|
+
backend: "redis",
|
|
57
|
+
envName: resolution.envName,
|
|
58
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
59
|
+
...(scheme === undefined ? {} : { scheme }),
|
|
60
|
+
...(resolution.fallback ? { fallback: true } : {}),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the boot event from the same env precedence the stores themselves use
|
|
66
|
+
* (`providerStateRedisResolutionFromEnv` / `providerCacheRedisResolutionFromEnv`),
|
|
67
|
+
* so the line can never describe a backend other than the one in use.
|
|
68
|
+
*/
|
|
69
|
+
export function providerStateBackendLogEvent(input) {
|
|
70
|
+
const warnings = [];
|
|
71
|
+
let state;
|
|
72
|
+
if (input.injectedState) {
|
|
73
|
+
state = { backend: "injected" };
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const resolution = providerStateRedisResolutionFromEnv();
|
|
77
|
+
if (resolution) {
|
|
78
|
+
const { report, parsed, ambiguous } = redisReport(resolution);
|
|
79
|
+
state = report;
|
|
80
|
+
if (!parsed)
|
|
81
|
+
warnings.push("state_redis_url_unparsed");
|
|
82
|
+
if (ambiguous)
|
|
83
|
+
warnings.push("state_redis_url_ambiguous_endpoint");
|
|
84
|
+
if (resolution.fallback)
|
|
85
|
+
warnings.push("state_redis_url_fallback");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
state = { backend: input.allowMemoryFallback ? "memory" : "unsupported" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const cacheResolution = providerCacheRedisResolutionFromEnv();
|
|
92
|
+
let cache;
|
|
93
|
+
if (cacheResolution) {
|
|
94
|
+
const { report, parsed, ambiguous } = redisReport(cacheResolution);
|
|
95
|
+
cache = report;
|
|
96
|
+
if (!parsed)
|
|
97
|
+
warnings.push("cache_redis_url_unparsed");
|
|
98
|
+
if (ambiguous)
|
|
99
|
+
warnings.push("cache_redis_url_ambiguous_endpoint");
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
cache = { backend: "memory" };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
level: warnings.length > 0 ? "warn" : "info",
|
|
106
|
+
event: "provider_state_backend",
|
|
107
|
+
providerId: input.providerId,
|
|
108
|
+
state,
|
|
109
|
+
cache,
|
|
110
|
+
sdkVersion: input.sdkVersion,
|
|
111
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
112
|
+
};
|
|
113
|
+
}
|
package/package.json
CHANGED
package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl
CHANGED
|
@@ -1,37 +1,142 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: health-checks-and-fail-closed
|
|
3
|
-
description: Writing health checks
|
|
3
|
+
description: Writing health checks the platform monitor actually executes, and fail-closed guards at envelope and row level. Load before writing healthCheck blocks or error handling.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Health checks and fail-closed guards
|
|
7
7
|
|
|
8
|
-
##
|
|
9
|
-
`Array.isArray(data.items)` alone can never fail. Every list operation's
|
|
10
|
-
health check must be able to detect the zero-rows regression.
|
|
8
|
+
## Only `scenario` is monitored in production
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
A `healthCheck` case takes one of two mutually exclusive shapes, and they do
|
|
11
|
+
not run in the same place:
|
|
12
|
+
|
|
13
|
+
| field | executed by | when |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `scenario` | the platform health monitor | every suite interval, in production |
|
|
16
|
+
| `assertions` | the provider runtime's own self-test | only when that endpoint is called |
|
|
17
|
+
|
|
18
|
+
The monitor runs a case's SERIALIZED scenario and nothing else: a closure
|
|
19
|
+
cannot cross the registry's serialization boundary. A case that carries only
|
|
20
|
+
`assertions` is still published as a probe, but its outcome is permanently
|
|
21
|
+
`unknown` / `monitoring_unavailable` — the operation reads as monitored on the
|
|
22
|
+
status page while nothing is ever checked. `apifuse check` warns about this
|
|
23
|
+
(`health-check-assertions-not-monitored`).
|
|
24
|
+
|
|
25
|
+
Anything that must be caught in production belongs in `scenario`. Keep
|
|
26
|
+
`assertions` for a local self-test, or drop it.
|
|
27
|
+
|
|
28
|
+
## A scenario that can actually fail
|
|
29
|
+
|
|
30
|
+
`Array.isArray(data.items)` alone can never fail. Every list operation's probe
|
|
31
|
+
must be able to detect the zero-rows regression AND a mapper regression that
|
|
32
|
+
empties fields on a present row.
|
|
33
|
+
|
|
34
|
+
Step kinds: `operation` invokes, `assert` FAILS the probe (recorded `down`),
|
|
35
|
+
`guard` DEGRADES without failing it.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// health-scenarios.ts
|
|
39
|
+
import { type AssertionExpression, defineHealthScenario } from "@apifuse/provider-sdk/provider";
|
|
40
|
+
|
|
41
|
+
export const PROBE_INPUT = { district: "<a guaranteed-dense, stable id>" } as const;
|
|
42
|
+
|
|
43
|
+
const response = (path: readonly (string | number)[]) => ({
|
|
44
|
+
ref: { namespace: "steps" as const, binding: "response", path: [...path] },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const evidence: AssertionExpression = {
|
|
48
|
+
kind: "all",
|
|
49
|
+
clauses: [
|
|
50
|
+
{ kind: "predicate", operator: "status_2xx", actual: response(["status_code"]) },
|
|
51
|
+
// Dense query MUST return rows; zero rows = upstream contract drift.
|
|
52
|
+
{ kind: "predicate", operator: "array_length_gte", actual: response(["data", "items"]), expected: 1 },
|
|
53
|
+
// A semantic field on a real row, so an emptying mapper regression fails too.
|
|
54
|
+
{ kind: "predicate", operator: "non_empty", actual: response(["data", "items", 0, "name"]) },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const listItemsScenario = defineHealthScenario({
|
|
59
|
+
scenarioVersion: 2,
|
|
60
|
+
id: "list-items.dense-district",
|
|
61
|
+
display: {
|
|
62
|
+
titleKey: "health.operations.list-items.dense-district.title",
|
|
63
|
+
descriptionKey: "health.operations.list-items.dense-district.description",
|
|
64
|
+
},
|
|
65
|
+
schedule: { kind: "interval", intervalMs: 60 * 60_000, jitterMs: 0 },
|
|
66
|
+
timeoutMs: 15_000,
|
|
67
|
+
coversOperations: ["list-items"],
|
|
68
|
+
credentialRefs: [],
|
|
69
|
+
steps: [
|
|
70
|
+
{ id: "read", result: "response", kind: "operation", operationId: "list-items", inputTemplate: { ...PROBE_INPUT } },
|
|
71
|
+
{ id: "verify", result: "verified", kind: "assert", coversOperations: ["list-items"], expression: evidence },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
```
|
|
15
75
|
|
|
16
76
|
```ts
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
throw new Error("<op> missing items array");
|
|
23
|
-
}
|
|
24
|
-
// Dense query MUST return rows; zero rows = upstream contract drift
|
|
25
|
-
if (data.items.length === 0) {
|
|
26
|
-
return { status: "degraded", label: "<op> dense query returned 0 rows" };
|
|
27
|
-
}
|
|
77
|
+
// operations/list-items.ts
|
|
78
|
+
healthCheck: {
|
|
79
|
+
interval: "1h",
|
|
80
|
+
timeoutMs: 15_000,
|
|
81
|
+
cases: [{ name: "dense-district", input: { ...PROBE_INPUT }, scenario: listItemsScenario }],
|
|
28
82
|
}
|
|
29
83
|
```
|
|
30
84
|
|
|
31
|
-
- Choose
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
85
|
+
- Choose probe inputs that are guaranteed-dense (major city district, a stable
|
|
86
|
+
well-known entity id). Verify the id still exists when picking it.
|
|
87
|
+
- `scenario.coversOperations` may only name the operation that owns the case.
|
|
88
|
+
|
|
89
|
+
## Stale-if-error serves must be guarded, or an outage reads green
|
|
90
|
+
|
|
91
|
+
If the operation reads its upstream through `ctx.cache.getOrSet(..., {
|
|
92
|
+
staleIfErrorMs })`, an upstream outage is answered with HTTP 200 and a
|
|
93
|
+
well-formed, fully schema-valid body for the whole stale window. Every clause
|
|
94
|
+
over `status_code` and `data` still passes, so the probe rolls up `ok` while
|
|
95
|
+
the upstream is down, and only flips to `down` after the window expires — the
|
|
96
|
+
first minutes of every outage are reported as healthy.
|
|
97
|
+
|
|
98
|
+
`served_stale_cache` on the operation step result is the only operand that
|
|
99
|
+
separates a stale serve from a live read. It is always present, so the
|
|
100
|
+
reference resolves to `false` rather than raising `reference_unresolvable`.
|
|
101
|
+
Add ONE guard step per stale-if-error probe:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
{
|
|
105
|
+
id: "guard-list-items-freshness",
|
|
106
|
+
result: "freshness-guarded",
|
|
107
|
+
kind: "guard",
|
|
108
|
+
condition: {
|
|
109
|
+
kind: "predicate",
|
|
110
|
+
operator: "not_equals",
|
|
111
|
+
actual: { ref: { namespace: "steps" as const, binding: "response", path: ["served_stale_cache"] } },
|
|
112
|
+
expected: true,
|
|
113
|
+
},
|
|
114
|
+
onFail: {
|
|
115
|
+
attribute: [
|
|
116
|
+
{
|
|
117
|
+
operationId: "list-items",
|
|
118
|
+
status: "degraded",
|
|
119
|
+
reasonCode: "expected_absence",
|
|
120
|
+
reasonKey: "health.operations.list-items.dense-district.servedStaleCache",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
stop: "scenario",
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- `degraded`, not `down`: the cache policy is behaving as promised and callers
|
|
129
|
+
are still served; it is the upstream that is unavailable, and that has to be
|
|
130
|
+
visible instead of green.
|
|
131
|
+
- `expected_absence` is the only `reasonCode` the guard attribution schema
|
|
132
|
+
admits today, and it reads correctly here — the FRESH read is what was
|
|
133
|
+
absent. The tenant-facing wording lives in the localized `reasonKey`.
|
|
134
|
+
- Place the freshness guard BEFORE any row or emptiness guard. A stale serve
|
|
135
|
+
that happens to be empty would otherwise be attributed to the emptiness
|
|
136
|
+
reason, which is a confident answer to the wrong question.
|
|
137
|
+
- Do not change the handler for this. The envelope's `meta.stale` is written
|
|
138
|
+
by the serve layer, so `return result.value` discarding `result.meta` does
|
|
139
|
+
not suppress the signal.
|
|
35
140
|
|
|
36
141
|
## Fail-closed: envelope level
|
|
37
142
|
- Upstream error headers/codes → structured `ProviderError` with a stable
|
|
@@ -51,6 +156,14 @@ array is non-empty, but every row normalizes to nothing.
|
|
|
51
156
|
- Regression-test both layers separately: a bad envelope AND a good envelope
|
|
52
157
|
with unmappable rows.
|
|
53
158
|
|
|
159
|
+
## Self-test assertions (optional, not monitored)
|
|
160
|
+
A case without a `scenario` may carry an `assertions` lambda, executed only by
|
|
161
|
+
the provider runtime's own self-test. THROW to fail the case (recorded
|
|
162
|
+
`down`); return `{ status: "degraded", label }` to flag without failing;
|
|
163
|
+
return nothing for `ok`. There is no `"down"` return value. It MUST NOT touch
|
|
164
|
+
the scheduler, recorder, or any runtime type. Never use it as the only check
|
|
165
|
+
on an operation you need monitored.
|
|
166
|
+
|
|
54
167
|
## Error message hygiene
|
|
55
168
|
`ProviderError.message` reaches the tenant verbatim. Never interpolate
|
|
56
169
|
upstream free text that may contain personal data (names, phone numbers,
|
|
@@ -58,8 +171,10 @@ addresses); allowlist known code tokens and keep raw bodies in server-side
|
|
|
58
171
|
details/logs only.
|
|
59
172
|
|
|
60
173
|
## Checklist
|
|
61
|
-
- [ ] Every
|
|
174
|
+
- [ ] Every monitored operation's case carries `scenario` (not only `assertions`)
|
|
175
|
+
- [ ] Every list op probe fails on 0 rows for a dense query
|
|
62
176
|
- [ ] One semantic field asserted on a real row
|
|
177
|
+
- [ ] Every `staleIfErrorMs` read has a `served_stale_cache` guard, placed first
|
|
63
178
|
- [ ] Swallowed-error test exists (non-ok mock → handler rejects)
|
|
64
179
|
- [ ] Non-empty upstream → zero normalized rows throws
|
|
65
180
|
- [ ] No upstream free text in customer-facing error messages
|
|
@@ -137,8 +137,8 @@ Structured errors return an `error` object with `code`, `message`,
|
|
|
137
137
|
## Next steps
|
|
138
138
|
|
|
139
139
|
1. Replace the sample `ping` operation with real upstream logic.
|
|
140
|
-
2. Once the real operation
|
|
141
|
-
`
|
|
140
|
+
2. Once the real operation calls the upstream through `ctx.http` or `ctx.stealth`
|
|
141
|
+
(absolute URLs, or relative ones under its `upstream.baseUrl`), record a fixture with:
|
|
142
142
|
`bun run record -- --operation <operation> --params '<json-input>'`.
|
|
143
143
|
3. Replace the starter `healthCheckUnsupported` with a real `healthCheck` for read-only upstream operations when safe.
|
|
144
144
|
4. Extend tests and operation metadata until the provider is bounty-ready.
|
|
@@ -158,8 +158,14 @@ Every operation must declare exactly one of:
|
|
|
158
158
|
The generated `ping` operation uses `healthCheckUnsupported` only because it is
|
|
159
159
|
a local scaffold check, not a real upstream API probe.
|
|
160
160
|
|
|
161
|
-
`
|
|
162
|
-
|
|
161
|
+
A case carries either `scenario` or `assertions`, and only `scenario` is
|
|
162
|
+
executed in production: the platform health monitor runs a case's serialized
|
|
163
|
+
scenario and nothing else, because a closure cannot cross the registry's
|
|
164
|
+
serialization boundary. A case with only `assertions` still publishes a probe,
|
|
165
|
+
but its outcome is permanently `unknown` / `monitoring_unavailable` — the
|
|
166
|
+
operation reads as monitored while nothing is checked, and `apifuse check`
|
|
167
|
+
warns about it (`health-check-assertions-not-monitored`). Use this shape in
|
|
168
|
+
real operations:
|
|
163
169
|
|
|
164
170
|
```ts
|
|
165
171
|
healthCheck: {
|
|
@@ -167,11 +173,13 @@ healthCheck: {
|
|
|
167
173
|
cases: [{
|
|
168
174
|
name: "lookup baseline",
|
|
169
175
|
input: { q: "btc" },
|
|
170
|
-
|
|
171
|
-
if (status !== 200 || data.results.length === 0 || durationMs > 3000) {
|
|
172
|
-
return { status: "degraded", label: "lookup baseline changed" };
|
|
173
|
-
}
|
|
174
|
-
},
|
|
176
|
+
scenario: lookupBaselineScenario, // defineHealthScenario({ ... })
|
|
175
177
|
}],
|
|
176
178
|
}
|
|
177
179
|
```
|
|
180
|
+
|
|
181
|
+
If the operation reads through `ctx.cache.getOrSet(..., { staleIfErrorMs })`,
|
|
182
|
+
add a guard step on the operation step result's `served_stale_cache` as well —
|
|
183
|
+
otherwise an upstream outage is answered with a schema-valid 200 and the probe
|
|
184
|
+
reports `ok` for the whole stale window. See
|
|
185
|
+
`.agents/skills/health-checks-and-fail-closed/SKILL.md` for both shapes.
|
package/src/config/loader.ts
CHANGED
|
@@ -255,25 +255,62 @@ const SMARTPROXY_ALLOCATOR_RETRY_BASE_MS = 25;
|
|
|
255
255
|
const SMARTPROXY_EXTRACTION_CACHE_TTL_MS = 15_000;
|
|
256
256
|
const SMARTPROXY_EXTRACTION_SOFT_REFRESH_MS = 10_000;
|
|
257
257
|
|
|
258
|
+
/** Env names that can supply a provider store's Redis URL. */
|
|
259
|
+
export type ProviderRedisUrlEnvName =
|
|
260
|
+
| typeof PROVIDER_STATE_REDIS_URL_ENV
|
|
261
|
+
| typeof PROVIDER_CACHE_REDIS_URL_ENV
|
|
262
|
+
| typeof REDIS_URL_ENV;
|
|
263
|
+
|
|
264
|
+
/** Which env name supplied a store's Redis URL, alongside the URL itself. */
|
|
265
|
+
export type ProviderRedisUrlResolution = {
|
|
266
|
+
readonly url: string;
|
|
267
|
+
/** The env name that actually supplied `url`. */
|
|
268
|
+
readonly envName: ProviderRedisUrlEnvName;
|
|
269
|
+
/**
|
|
270
|
+
* True when `url` came from a shared fallback env rather than the store's
|
|
271
|
+
* own. A state store on `fallback: true` is sharing an instance that was
|
|
272
|
+
* provisioned for something else (apifuse#2144).
|
|
273
|
+
*/
|
|
274
|
+
readonly fallback: boolean;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Precedence is declared once here so the URL accessors, the boot report and
|
|
278
|
+
// any future consumer can never disagree about which env wins.
|
|
279
|
+
const CACHE_REDIS_URL_PRECEDENCE = [PROVIDER_CACHE_REDIS_URL_ENV, REDIS_URL_ENV] as const;
|
|
280
|
+
const STATE_REDIS_URL_PRECEDENCE = [
|
|
281
|
+
PROVIDER_STATE_REDIS_URL_ENV,
|
|
282
|
+
PROVIDER_CACHE_REDIS_URL_ENV,
|
|
283
|
+
REDIS_URL_ENV,
|
|
284
|
+
] as const;
|
|
285
|
+
|
|
286
|
+
function resolveRedisUrlFromEnv(
|
|
287
|
+
precedence: readonly ProviderRedisUrlEnvName[],
|
|
288
|
+
): ProviderRedisUrlResolution | undefined {
|
|
289
|
+
for (const [index, envName] of precedence.entries()) {
|
|
290
|
+
const url = readDiagnosticEnv(envName)?.trim();
|
|
291
|
+
if (url) return { url, envName, fallback: index > 0 };
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
258
296
|
function redisUrlFromEnv(): string | undefined {
|
|
259
|
-
return (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
);
|
|
297
|
+
return providerCacheRedisResolutionFromEnv()?.url;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function providerCacheRedisResolutionFromEnv(): ProviderRedisUrlResolution | undefined {
|
|
301
|
+
return resolveRedisUrlFromEnv(CACHE_REDIS_URL_PRECEDENCE);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function providerStateRedisResolutionFromEnv(): ProviderRedisUrlResolution | undefined {
|
|
305
|
+
return resolveRedisUrlFromEnv(STATE_REDIS_URL_PRECEDENCE);
|
|
264
306
|
}
|
|
265
307
|
|
|
266
308
|
export function providerCacheRedisUrlFromEnv(): string | undefined {
|
|
267
|
-
return
|
|
309
|
+
return providerCacheRedisResolutionFromEnv()?.url;
|
|
268
310
|
}
|
|
269
311
|
|
|
270
312
|
export function providerStateRedisUrlFromEnv(): string | undefined {
|
|
271
|
-
return (
|
|
272
|
-
readDiagnosticEnv(PROVIDER_STATE_REDIS_URL_ENV)?.trim() ||
|
|
273
|
-
readDiagnosticEnv(PROVIDER_CACHE_REDIS_URL_ENV)?.trim() ||
|
|
274
|
-
readDiagnosticEnv(REDIS_URL_ENV)?.trim() ||
|
|
275
|
-
undefined
|
|
276
|
-
);
|
|
313
|
+
return providerStateRedisResolutionFromEnv()?.url;
|
|
277
314
|
}
|
|
278
315
|
|
|
279
316
|
/** @internal Test-only hook for exercising shared proxy-cache behavior. */
|