@apifuse/provider-sdk 2.2.0-beta.13 → 2.2.0-beta.14

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 CHANGED
@@ -393,6 +393,70 @@ External contributors are expected to submit standalone Provider source plus:
393
393
  Maintainers own monorepo import under `providers/<id>/`, registry generation,
394
394
  deployment projection checks, and release workflows.
395
395
 
396
+ ### Error responses
397
+
398
+ Provider-server failures use a stable public envelope:
399
+
400
+ ```json
401
+ {
402
+ "error": {
403
+ "code": "UPSTREAM_ERROR",
404
+ "message": "The upstream service failed",
405
+ "requestId": "req_123",
406
+ "retryable": true,
407
+ "details": { "providerReason": "temporarily_unavailable" }
408
+ }
409
+ }
410
+ ```
411
+
412
+ `retryable` is always present on responses emitted by the current SDK. Set
413
+ `retryable` in the `ProviderError` options when the provider knows the answer;
414
+ an explicit `true` or `false` wins over SDK derivation. When it is omitted, the
415
+ SDK derives the value for its known error classes and otherwise defaults to
416
+ `false`. During stateful rolling upgrades, the forwarding client also accepts
417
+ an older owner response that omits `retryable` and treats it as `false` without
418
+ loosening the emitted response contract. Existing optional `fix` guidance is
419
+ also preserved when a `ProviderError` supplies it.
420
+
421
+ `details` belongs exclusively to the provider. The server passes
422
+ `ProviderError.options.details` through verbatim, including strings and arrays,
423
+ and never merges, overwrites, or wraps it. Do not put SDK taxonomy fields there.
424
+ SDK-owned validation and masked-internal-error paths retain their own diagnostic
425
+ details.
426
+
427
+ SDK observability is emitted separately in the
428
+ `X-ApiFuse-Error-Observability` response header as compact, single-line JSON:
429
+
430
+ ```json
431
+ {"category":"upstream_http","taxonomyVersion":"2026-05-26","retryable":true,"upstreamStatus":502}
432
+ ```
433
+
434
+ Treat this header as telemetry, not as provider-controlled public error detail.
435
+ Its category, taxonomy version, retryability, and optional upstream status match
436
+ the structured `provider_request_failed` log event.
437
+
438
+ Registered error-code mappings take precedence for every `ProviderError`,
439
+ including `ValidationError`:
440
+
441
+ | Error code or fallback | HTTP status |
442
+ | --- | ---: |
443
+ | `AUTH_REQUIRED`, `reauth_required` | 401 |
444
+ | `MISSING_SECRET` | 400 |
445
+ | `NOT_FOUND`, `not_found`, `NO_DATA` | 404 |
446
+ | `RATE_LIMITED`, `UPSTREAM_RATE_LIMIT`, `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | 429 |
447
+ | `UPSTREAM_ERROR`, `BLOCKED` | 502 |
448
+ | `STT_UNAVAILABLE`, `UNSUPPORTED_STT_BACKEND`, `STATEFUL_FORWARDING_REPLAY_CACHE_FULL` | 503 |
449
+ | Unregistered input `ValidationError` code | 400 |
450
+ | Other unregistered `ProviderError` code | 500 |
451
+
452
+ An unregistered non-validation `ProviderError` code returns HTTP 500 and emits
453
+ the greppable `unregistered_provider_error_code` signal with the code in the
454
+ structured failure log. Before publishing a new code, register its status in
455
+ the SDK mapping and add it to the mapping tests; declaring it only in operation
456
+ documentation does not change runtime status selection. The HTTP 400
457
+ `ValidationError` behavior is only the fallback for unregistered input
458
+ validation codes; a registered code such as `NOT_FOUND` retains its mapped 404.
459
+
396
460
  ### Declared secrets are SDK-enforced
397
461
 
398
462
  Environment/secret presence validation is single-sourced in the SDK. Declare
@@ -411,12 +475,12 @@ secrets: [
411
475
  The runtime validates every `required: true` declaration before any operation
412
476
  handler or auth-flow handler (except `abort`) runs. When a required secret is
413
477
  unset or whitespace-only, the invocation fails with the canonical structured
414
- error — code `MISSING_SECRET`, HTTP 400, `details.category:
415
- "credential_unavailable"`, `retryable: false`, and a `fix` naming every missing
416
- secret across `/v1/{operation}`, self-test probes, `apifuse perf`, and
417
- `apifuse record`. The server also emits a `provider_secrets_missing` warn log
418
- at boot so unprovisioned deployments are visible immediately without crashing
419
- the pod.
478
+ error — code `MISSING_SECRET`, HTTP 400, top-level `retryable: false`, and a
479
+ `fix` naming every missing secret — across `/v1/{operation}`, self-test probes,
480
+ `apifuse perf`, and `apifuse record`. Its error-observability header carries the
481
+ `credential_unavailable` category. The server also emits a
482
+ `provider_secrets_missing` warn log at boot so unprovisioned deployments are
483
+ visible immediately without crashing the pod.
420
484
 
421
485
  Provider-local presence re-validation is **deprecated**: do not write
422
486
  `requireServiceKey`/`requireApiKey`-style guards that re-check `ctx.env.get()`
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.14
4
+
5
+ - Release candidate for main commit 3491acd253ca17b517985e8a618f1c2904a664a9.
6
+
3
7
  ## 2.2.0-beta.13
4
8
 
5
9
  - Release candidate for main commit 75e840d0614aea3b99a1e5cef4f93f8cdccf0507.
@@ -82,6 +86,8 @@
82
86
 
83
87
  ## Unreleased
84
88
 
89
+ - **Breaking:** Provider error `details` is now passed through verbatim; SDK observability fields (`category`, `taxonomyVersion`, `upstreamStatus`, and derived `retryable`) are no longer merged into the public body. Emitted error envelopes now require top-level `retryable`, while inbound stateful forwarding tolerates an older owner response that omits it and defaults it to `false`. The removed observability metadata is available in the new `X-ApiFuse-Error-Observability` response header.
90
+ - Unregistered `ProviderError` codes now default to HTTP 500 instead of 400 and emit an `unregistered_provider_error_code` structured-log signal; registered mappings remain unchanged and take precedence over the HTTP 400 fallback for unregistered input `ValidationError` codes.
85
91
  - Add an opt-in native connection idle read timeout with a typed error, independently from TCP/SOCKS/TLS establishment deadlines.
86
92
  - Add opt-in `maxBodyBytes` enforcement to stealth fetches and redirect hops, aborting oversized decoded response streams with `response_too_large`.
87
93
  - Resolve relative date tokens in fixture requests before input-schema validation, add KST capture-date `fixtures.recordedAt` metadata, and support explicit KST/UTC calendars in the shared health-input resolver.
package/dist/errors.d.ts CHANGED
@@ -47,6 +47,7 @@ export declare class TransportError extends ProviderError {
47
47
  export declare function isProviderError(value: unknown): value is ProviderError;
48
48
  export declare function isSessionExpiredError(value: unknown): value is SessionExpiredError;
49
49
  export declare function isTransportError(value: unknown): value is TransportError;
50
+ export declare function isValidationError(value: unknown): value is ValidationError;
50
51
  export declare class ProviderSecretError extends ProviderError {
51
52
  constructor(message: string, options?: ProviderErrorOptions);
52
53
  }
package/dist/errors.js CHANGED
@@ -8,6 +8,7 @@ const PROVIDER_ERROR_BRAND = Symbol.for("@apifuse/provider-sdk/error-brand@1");
8
8
  const PROVIDER_ERROR_BRAND_VALUE = 1;
9
9
  const SESSION_EXPIRED_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/session-expired@1");
10
10
  const TRANSPORT_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/transport@1");
11
+ const VALIDATION_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/validation@1");
11
12
  // Defines a non-enumerable, non-writable, non-configurable own data property.
12
13
  // Immutable + own means a guard can trust it via a single descriptor read
13
14
  // without invoking attacker-controlled getters or accepting inherited brands.
@@ -96,6 +97,7 @@ export class ValidationError extends ProviderError {
96
97
  super(message, options);
97
98
  this.name = "ValidationError";
98
99
  this.zodError = options?.zodError;
100
+ defineErrorBrand(this, VALIDATION_BRAND, true);
99
101
  }
100
102
  }
101
103
  export class TransportError extends ProviderError {
@@ -122,6 +124,10 @@ export function isSessionExpiredError(value) {
122
124
  export function isTransportError(value) {
123
125
  return isProviderError(value) && hasOwnBrand(value, TRANSPORT_BRAND, true);
124
126
  }
127
+ export function isValidationError(value) {
128
+ return (isProviderError(value) &&
129
+ (hasOwnBrand(value, VALIDATION_BRAND, true) || value.name === "ValidationError"));
130
+ }
125
131
  export class ProviderSecretError extends ProviderError {
126
132
  constructor(message, options) {
127
133
  super(message, { code: "provider_secret_error", ...options });
package/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export { createStealthClient } from "./runtime/stealth.js";
34
34
  export { APIFUSE__STT__BACKEND_ENV, APIFUSE__STT__CLOUDFLARE_API_TOKEN_ENV, APIFUSE__STT__MODEL_ENV, createSttClientFromEnv, createUnsupportedSttClient, extractVerificationCode, resolveSttPrompt, } from "./runtime/stt.js";
35
35
  export { type CreateTraceContextOptions, createTraceContext, type Span, type TraceContext, } from "./runtime/trace.js";
36
36
  export { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_REDACTION_MARKER, APIFUSE_SENSITIVE_KIND_META_KEY, APIFUSE_SENSITIVE_META_KEY, collectSensitivePaths, describeKey, field, fields, isSensitiveSchema, redactPayload, type SensitiveFieldKind, type SensitiveFieldOptions, type SensitivePath, sensitive, z, } from "./schema.js";
37
- export { createServerApp, type ServeOptions, serve } from "./server/index.js";
37
+ export { createServerApp, ERROR_OBSERVABILITY_HEADER, type ServeOptions, serve, } from "./server/index.js";
38
38
  export { getStealthProfile, listStealthProfiles } from "./stealth/profiles.js";
39
39
  export * from "./stream.js";
40
40
  export type { ApiFuseResponse, AuthConfig, AuthContext, AuthFlowDefinition, AuthFlowInputHandler, AuthFlowStartHandler, AuthMode, AuthTurn, Bcp47Locale, BrowserEngine, BrowserOptions, BrowserResourceBody, BrowserResourceDecision, BrowserResourceMethod, BrowserResourcePolicy, BrowserResourceRequest, BrowserResourceRoute, ConnectionMode, ContextDeclaration, CookieJar, ContextScratchpad, CredentialContext, CredentialDeclaration, E164PhoneNumber, EnvContext, FlowContext, FlowContextStore, HealthCheckAssertionContext, HealthCheckCase, HealthCheckCaseResult, HealthCheckSuite, HealthCheckUnsupported, HealthJourneyDefinition, HealthJourneyEventContext, HealthJourneyGatewayContext, HealthJourneyJournalContext, HealthJourneyManualTriggerPolicy, HealthJourneyRunContext, HealthJourneyRunResult, HealthJourneySchedule, HealthScheduleRandomization, HealthJourneySmsContext, HealthJourneyStep, HttpClient, HttpMethod, HttpResponse, HttpRetryOptions, HttpRetrySummary, HttpStreamResponse, IanaTimeZone, InferSchemaOutput, Iso3166Alpha2CountryCode, Iso4217CurrencyCode, Iso8601Duration, NativeContext, NativeNetworkClient, NativeNetworkCloseReason, NativeNetworkConnection, NativeNetworkConnectInput, NativeNetworkConnectOptions, NativeNetworkDynamicGrantOptions, NativeNetworkEgressGrant, NativeProviderConfig, NativeProviderContext, NativeProxyDrainHandler, NativeProxyEgressInfo, NativeProxyExpiringEvent, NativeProxyExpiringReason, NativeTcpDynamicEgressRule, NativeTcpEgressGrant, NativeTcpEgressRule, NativeTcpPortRange, NativeTcpTlsMode, NativeTlsConnectOptions, OperationAnnotations, OperationApprovalPolicy, OperationContractMetadata, OperationDefinition, OperationDeprecationMetadata, OperationDocMeta, OperationErrorCode, OperationHandlerResult, OperationInputExample, OperationLifecycle, OperationObservabilityConfig, OperationObservabilitySensitiveConfig, OperationRelationships, OperationRiskClass, OperationSensitivePath, OperationToolRouterMetadata, OperationTransport, OperationTransportKind, ProbeInterval, ProviderAccessConfig, ProviderAccessVisibility, ProviderCache, ProviderCacheGetOrSetOptions, ProviderCacheKeyOptions, ProviderCacheLookupMeta, ProviderCacheResponseMeta, ProviderCacheResult, ProviderChoiceBindingOptions, ProviderChoiceContext, ProviderChoiceIssueOptions, ProviderChoiceParseOptions, ProviderContext, ProviderDefinition, ProviderDeploymentOverrides, ProviderFileRef, ProviderFilesContext, ProviderHealthMonitorConfig, ProviderHealthProbeConfig, ProviderLocale, ProviderLocaleKey, ProviderLocaleKeyInput, ProviderLogoProfile, ProviderLogoSource, ProviderMeta, ProviderProxyConfig, ProviderProxyMode, ProviderProxyPolicy, ProviderProxyProvider, ProviderProxySessionAffinity, ProviderPublicConnectionMode, ProviderPublicProfile, ProviderReviewed, ProviderResolvedFile, ProviderRuntimeState, ProviderSecretDeclaration, ProviderStateDurationString, ProviderStateNamespace, ProviderStreamEvent, ProviderSttConfig, ProviderSttMode, ProviderSupportLevel, RequestOptions, Rfc3339Instant, SchemaLike, SmsOrigin, SmsOtpExtractionPattern, SmsOtpMatcherDefinition, SseMessage, StandardSchemaV1, StateCasResult, StateNamespaceOptions, StateValue, StateWriteOptions, StealthClient, StealthCookieStore, StealthCookieStoreV1, StealthFetchOptions, StealthPlatform, StealthProfile, StealthRedirectHop, StealthRedirectRunOptions, StealthRedirectRunResult, StealthResponse, StealthSession, StealthSessionCookies, SttAudioInput, SttContext, SttPromptPolicy, SttSegment, SttTranscribeMode, SttTranscribeRequest, SttTranscript, SttUnsupportedOptionPolicy, SttUsage, SttVerificationCodeOptions, SttWarning, TraceConfig, TraceSpan, VerificationCodeCandidate, VerificationCodeCandidateSource, VerificationCodeExtractionResult, } from "./types.js";
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ export { createStealthClient } from "./runtime/stealth.js";
31
31
  export { APIFUSE__STT__BACKEND_ENV, APIFUSE__STT__CLOUDFLARE_API_TOKEN_ENV, APIFUSE__STT__MODEL_ENV, createSttClientFromEnv, createUnsupportedSttClient, extractVerificationCode, resolveSttPrompt, } from "./runtime/stt.js";
32
32
  export { createTraceContext, } from "./runtime/trace.js";
33
33
  export { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_REDACTION_MARKER, APIFUSE_SENSITIVE_KIND_META_KEY, APIFUSE_SENSITIVE_META_KEY, collectSensitivePaths, describeKey, field, fields, isSensitiveSchema, redactPayload, sensitive, z, } from "./schema.js";
34
- export { createServerApp, serve } from "./server/index.js";
34
+ export { createServerApp, ERROR_OBSERVABILITY_HEADER, serve, } from "./server/index.js";
35
35
  export { getStealthProfile, listStealthProfiles } from "./stealth/profiles.js";
36
36
  export * from "./stream.js";
37
37
  export { DEFAULT_OPERATION_TRANSPORT, HttpRetryAfterPolicy, HttpRetryDelayStrategy, HttpRetryJitter, HttpRetryPreset, HttpRetryUnsafeMethodPolicy, PROBE_INTERVALS, STREAM_CHUNK_BYTES_MAX, STREAM_CHUNK_BYTES_MIN, STREAM_HEARTBEAT_MS_MAX, STREAM_HEARTBEAT_MS_MIN, STREAM_IDLE_TIMEOUT_MS_MAX, STREAM_IDLE_TIMEOUT_MS_MIN, STREAM_MAX_DURATION_MS_MAX, STREAM_MAX_DURATION_MS_MIN, } from "./types.js";
@@ -1,4 +1,5 @@
1
- import { isSessionExpiredError, ProviderError, SessionExpiredError } from "../errors.js";
1
+ import { isSessionExpiredError, isValidationError, ProviderError, SessionExpiredError, ValidationError, } from "../errors.js";
2
+ import { z } from "zod";
2
3
  import { parseSchema } from "../schema.js";
3
4
  import { assertRequiredSecretsPresent } from "./secrets.js";
4
5
  export function isStreamingOperation(provider, operationId) {
@@ -59,5 +60,19 @@ export async function executeOperation(provider, operationId, ctx, input, _optio
59
60
  if (isStreamingOperation(provider, operationId)) {
60
61
  return result;
61
62
  }
62
- return parseSchema(operation.output, result, `operations.${operationId}.output`);
63
+ try {
64
+ return await parseSchema(operation.output, result, `operations.${operationId}.output`);
65
+ }
66
+ catch (cause) {
67
+ if (!(cause instanceof z.ZodError) && !isValidationError(cause)) {
68
+ throw cause;
69
+ }
70
+ throw new ValidationError(`Operation handler output failed schema validation.`, {
71
+ code: "OUTPUT_VALIDATION_FAILED",
72
+ category: "output_validation",
73
+ retryable: false,
74
+ zodError: isValidationError(cause) ? cause.zodError : cause,
75
+ ...(cause instanceof Error ? { cause } : {}),
76
+ });
77
+ }
63
78
  }
@@ -1,4 +1,4 @@
1
- export { createServerApp, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
1
+ export { createServerApp, ERROR_OBSERVABILITY_HEADER, type ErrorObservabilityDetails, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
2
2
  export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestAuthFlowInvoke, type SelfTestAuthFlowRoute, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
3
3
  export { type InputDateTokenCalendar, resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
4
4
  export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
@@ -1,4 +1,4 @@
1
- export { createServerApp, serve, } from "./serve.js";
1
+ export { createServerApp, ERROR_OBSERVABILITY_HEADER, serve, } from "./serve.js";
2
2
  export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, SelfTestRequestSchema, } from "./self-test.js";
3
3
  export { resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
4
4
  export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
@@ -3,6 +3,14 @@ import { z } from "zod";
3
3
  import { type ProviderErrorCategory } from "../observability.js";
4
4
  import type { ProviderContext, ProviderDefinition, ProviderRuntimeState, SttContext } from "../types.js";
5
5
  import { type OperationRequest } from "./types.js";
6
+ /** Compact SDK-owned error classification emitted separately from the public response body. */
7
+ export declare const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
8
+ export type ErrorObservabilityDetails = {
9
+ category: ProviderErrorCategory;
10
+ taxonomyVersion: string;
11
+ retryable: boolean;
12
+ upstreamStatus?: number;
13
+ };
6
14
  export declare const ProviderServerStatefulForwardEnvelopeSchema: z.ZodObject<{
7
15
  requestId: z.ZodString;
8
16
  providerId: z.ZodString;
@@ -78,6 +86,7 @@ export type ProviderServerLogEvent = (ProviderServerLogEventBase & {
78
86
  errorCategory?: ProviderErrorCategory;
79
87
  taxonomyVersion?: string;
80
88
  retryable?: boolean;
89
+ signal?: "unregistered_provider_error_code";
81
90
  issues?: Array<{
82
91
  path: string;
83
92
  code: string;
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { z } from "zod";
5
5
  import { AuthAbortError, createAuthFlowHelpers } from "../auth.js";
6
- import { AuthError, isProviderError, isSessionExpiredError, isTransportError, ProviderError, } from "../errors.js";
6
+ import { AuthError, isProviderError, isSessionExpiredError, isTransportError, isValidationError, ProviderError, } from "../errors.js";
7
7
  import { loadProviderLocaleCatalogs, localizeAuthTurn, } from "../i18n/catalog.js";
8
8
  import { categoryForStatus, isRetryableCategory, PROVIDER_OBSERVABILITY_TAXONOMY_VERSION, } from "../observability.js";
9
9
  import { createScratchpad } from "../runtime/auth-flow.js";
@@ -33,6 +33,8 @@ import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
33
33
  import { AuthFlowRequestSchema, OperationConnectionSchema, OperationRequestSchema, } from "./types.js";
34
34
  const DEFAULT_HOST = "0.0.0.0";
35
35
  const DEFAULT_PORT = 3000;
36
+ /** Compact SDK-owned error classification emitted separately from the public response body. */
37
+ export const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
36
38
  const AUTH_FLOW_LOCALES = ["en", "ko", "ja"];
37
39
  const retryResponseMeta = new WeakMap();
38
40
  const STATEFUL_INTERNAL_OPERATIONS_ROUTE = "/__apifuse/stateful/operations";
@@ -320,25 +322,27 @@ function zodDetails(error) {
320
322
  }));
321
323
  }
322
324
  function toErrorResponse(error, requestId) {
325
+ const observability = errorObservabilityDetails(error);
323
326
  if (error instanceof StatefulRoutingDeadlineError) {
324
327
  return {
325
328
  error: {
326
329
  code: "STATEFUL_FORWARDING_DEADLINE_EXPIRED",
327
330
  message: "Stateful forwarding deadline expired.",
328
331
  ...(requestId ? { requestId } : {}),
329
- details: { retryable: false },
332
+ retryable: observability.retryable,
330
333
  },
331
334
  };
332
335
  }
333
336
  if (isProviderError(error)) {
334
- const details = publicProviderErrorDetails(error);
337
+ const details = error.details;
335
338
  return {
336
339
  error: {
337
340
  code: error.code ?? "provider_error",
338
341
  message: publicProviderErrorMessage(error),
339
342
  ...(requestId ? { requestId } : {}),
343
+ retryable: observability.retryable,
340
344
  ...(error.fix ? { fix: error.fix } : {}),
341
- ...(details ? { details } : {}),
345
+ ...(details !== undefined ? { details } : {}),
342
346
  },
343
347
  };
344
348
  }
@@ -348,6 +352,7 @@ function toErrorResponse(error, requestId) {
348
352
  code: "invalid_request",
349
353
  message: "Invalid request body",
350
354
  ...(requestId ? { requestId } : {}),
355
+ retryable: observability.retryable,
351
356
  details: zodDetails(error),
352
357
  },
353
358
  };
@@ -363,6 +368,7 @@ function toErrorResponse(error, requestId) {
363
368
  code: "internal_error",
364
369
  message: "Internal error",
365
370
  ...(requestId ? { requestId } : {}),
371
+ retryable: observability.retryable,
366
372
  details: {
367
373
  retryable: false,
368
374
  category: "internal_error",
@@ -371,26 +377,6 @@ function toErrorResponse(error, requestId) {
371
377
  },
372
378
  };
373
379
  }
374
- function publicProviderErrorDetails(error) {
375
- const providerDetails = error.details;
376
- const observabilityDetails = providerObservabilityDetails(error);
377
- if (providerDetails === undefined) {
378
- return observabilityDetails;
379
- }
380
- if (observabilityDetails === undefined) {
381
- return providerDetails;
382
- }
383
- if (isPlainRecord(providerDetails) && isPlainRecord(observabilityDetails)) {
384
- return { ...providerDetails, ...observabilityDetails };
385
- }
386
- return {
387
- provider: providerDetails,
388
- observability: observabilityDetails,
389
- };
390
- }
391
- function isPlainRecord(value) {
392
- return value !== null && typeof value === "object" && !Array.isArray(value);
393
- }
394
380
  // Accepts `unknown` so the branded guards narrow cleanly from the top: the
395
381
  // subtype error classes are structurally compatible with ProviderError, so
396
382
  // narrowing from a ProviderError-typed value would collapse the negative branch
@@ -449,6 +435,48 @@ function providerObservabilityDetails(error) {
449
435
  ...(error.upstreamStatus ? { upstreamStatus: error.upstreamStatus } : {}),
450
436
  };
451
437
  }
438
+ function errorObservabilityDetails(error) {
439
+ const providerDetails = providerObservabilityDetails(error);
440
+ if (providerDetails)
441
+ return providerDetails;
442
+ if (error instanceof z.ZodError || isValidationError(error)) {
443
+ return {
444
+ category: isProviderError(error) && error.options?.category
445
+ ? error.options.category
446
+ : "input_validation",
447
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
448
+ retryable: isProviderError(error) ? (error.options?.retryable ?? false) : false,
449
+ };
450
+ }
451
+ if (error instanceof StatefulRoutingDeadlineError) {
452
+ return {
453
+ category: "timeout",
454
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
455
+ retryable: false,
456
+ };
457
+ }
458
+ if (isProviderError(error)) {
459
+ return {
460
+ category: error.options?.category ?? "provider_error",
461
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
462
+ retryable: error.options?.retryable ?? false,
463
+ };
464
+ }
465
+ return {
466
+ category: "internal_error",
467
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
468
+ retryable: false,
469
+ };
470
+ }
471
+ function responseWithErrorObservability(response, error) {
472
+ const headers = new Headers(response.headers);
473
+ headers.set(ERROR_OBSERVABILITY_HEADER, JSON.stringify(errorObservabilityDetails(error)));
474
+ return new Response(response.body, {
475
+ status: response.status,
476
+ statusText: response.statusText,
477
+ headers,
478
+ });
479
+ }
452
480
  function publicProviderErrorMessage(error) {
453
481
  if (isTransportError(error)) {
454
482
  if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
@@ -481,9 +509,6 @@ function toStatusCode(error) {
481
509
  if (error instanceof StatefulRoutingDeadlineError) {
482
510
  return 504;
483
511
  }
484
- if (isTransportError(error)) {
485
- return error.code === "transport_timeout" ? 504 : 502;
486
- }
487
512
  if (isProviderError(error)) {
488
513
  switch (error.code) {
489
514
  case "AUTH_REQUIRED":
@@ -509,10 +534,87 @@ function toStatusCode(error) {
509
534
  case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
510
535
  return 503;
511
536
  }
512
- return 400;
537
+ if (isTransportError(error)) {
538
+ return error.code === "transport_timeout" ? 504 : 502;
539
+ }
540
+ if (isValidationError(error)) {
541
+ return error.options?.category === "output_validation" ? 500 : 400;
542
+ }
543
+ return 500;
513
544
  }
514
545
  return 500;
515
546
  }
547
+ // Codes emitted by SDK-owned paths must never be attributed to provider
548
+ // authors by the unregistered-code signal, even when their intentional status
549
+ // is 500. Provider-authored codes not in this registry retain the signal.
550
+ const SDK_OWNED_PROVIDER_ERROR_CODES = new Set([
551
+ "AUTH_PROMPT_UNAVAILABLE",
552
+ "BROWSER_CDP_POOL_REQUIRED",
553
+ "BROWSER_RUNTIME_UNSUPPORTED",
554
+ "STEALTH_RUNTIME_UNSUPPORTED",
555
+ "SSE_EVENT_UNDECLARED",
556
+ "STREAM_EVENT_TOO_LARGE",
557
+ "STREAM_CHUNK_TOO_LARGE",
558
+ "SSE_RESULT_UNSUPPORTED",
559
+ "STREAM_RESULT_UNSUPPORTED",
560
+ "AUTH_FLOW_NOT_CONFIGURED",
561
+ "refresh_not_supported",
562
+ "RUNTIME_UNSUPPORTED",
563
+ "PROVIDER_STATE_UNSUPPORTED",
564
+ "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
565
+ "CHOICE_STATE_PAYLOAD_TOO_LARGE",
566
+ "CHOICE_STATE_UNAVAILABLE",
567
+ "CHOICE_CONTEXT_REQUIRED",
568
+ "unsupported_stealth_cookie_store_version",
569
+ "provider_secret_error",
570
+ "credential_key_error",
571
+ "credential_mode_error",
572
+ "flow_expired",
573
+ "turn_validation_error",
574
+ "context_access_error",
575
+ "UNSUPPORTED_STT_OPTION",
576
+ "INVALID_STT_AUDIO",
577
+ "STT_AUDIO_TOO_LARGE",
578
+ "STT_UPSTREAM_FAILED",
579
+ "INVALID_STT_VERIFICATION_CODE_OPTIONS",
580
+ "NO_CODE_FOUND",
581
+ "AMBIGUOUS_CODE",
582
+ "retry_invalid_policy",
583
+ "retry_unsafe_method",
584
+ "stealth_cookie_store_serialize_failed",
585
+ "response_too_large",
586
+ "transport_stream_unavailable",
587
+ "transport_invalid_method",
588
+ "http_transport_override_unsupported",
589
+ "transport_invalid_url",
590
+ "retry_exhausted",
591
+ "auth_abort_unsafe_data",
592
+ "credentials_auth_missing_credential_keys",
593
+ "credentials_auth_missing_credential",
594
+ "credentials_auth_invalid_login_result",
595
+ "credentials_auth_unknown_challenge",
596
+ "credentials_auth_unknown_pending_challenge",
597
+ "STATEFUL_FORWARDING_NOT_CONFIGURED",
598
+ "STATEFUL_FORWARDING_SIGNATURE_MISSING",
599
+ "STATEFUL_FORWARDING_NONCE_INVALID",
600
+ "STATEFUL_FORWARDING_TIMESTAMP_INVALID",
601
+ "STATEFUL_FORWARDING_SIGNATURE_INVALID",
602
+ "STATEFUL_FORWARDING_REPLAY_DETECTED",
603
+ "STATEFUL_FORWARDING_REPLAY_CACHE_FULL",
604
+ "STATEFUL_FORWARDING_ENVELOPE_INVALID",
605
+ "STATEFUL_FORWARDING_PROVIDER_MISMATCH",
606
+ "STATEFUL_FORWARDING_SOURCE_POD_MISMATCH",
607
+ "STATEFUL_FORWARDING_OWNER_FENCE_INVALID",
608
+ "STATEFUL_FORWARDING_REQUEST_FAILED",
609
+ "STATEFUL_FORWARDING_CONTEXT_MISSING",
610
+ "STATEFUL_FORWARDING_BAD_RESPONSE",
611
+ "STATEFUL_INTERNAL_EXECUTOR_NOT_CONFIGURED",
612
+ "STATEFUL_FILE_FORWARDING_UNSUPPORTED",
613
+ "STATEFUL_CONTROL_PLANE_OPERATION_AMBIGUOUS",
614
+ "STATEFUL_CONTROL_PLANE_REQUEST_FAILED",
615
+ "STATEFUL_CONTROL_PLANE_HTTP_ERROR",
616
+ "STATEFUL_CONTROL_PLANE_INVALID_RESPONSE",
617
+ ]);
516
618
  function extractRequestId(raw) {
517
619
  if (!raw || typeof raw !== "object") {
518
620
  return undefined;
@@ -530,7 +632,12 @@ function logProviderError(logger, provider, kind, route, requestId, error, statu
530
632
  : "internal_error";
531
633
  const errorClass = error instanceof Error ? error.name : typeof error;
532
634
  const message = error instanceof Error ? error.message : String(error);
533
- const details = isProviderError(error) ? providerObservabilityDetails(error) : undefined;
635
+ const details = errorObservabilityDetails(error);
636
+ const isUnregisteredProviderErrorCode = status === 500 &&
637
+ isProviderError(error) &&
638
+ !isValidationError(error) &&
639
+ typeof error.code === "string" &&
640
+ !SDK_OWNED_PROVIDER_ERROR_CODES.has(error.code);
534
641
  const emit = typeof logger === "function" ? logger : defaultProviderServerLogger;
535
642
  emit({
536
643
  level: status >= 500 ? "error" : "warn",
@@ -544,15 +651,12 @@ function logProviderError(logger, provider, kind, route, requestId, error, statu
544
651
  code,
545
652
  errorClass,
546
653
  message,
547
- ...(isTransportError(error) && error.upstreamStatus
548
- ? { upstreamStatus: error.upstreamStatus }
549
- : {}),
550
- ...(details
551
- ? {
552
- errorCategory: details.category,
553
- taxonomyVersion: details.taxonomyVersion,
554
- retryable: details.retryable,
555
- }
654
+ ...(details.upstreamStatus ? { upstreamStatus: details.upstreamStatus } : {}),
655
+ errorCategory: details.category,
656
+ taxonomyVersion: details.taxonomyVersion,
657
+ retryable: details.retryable,
658
+ ...(isUnregisteredProviderErrorCode
659
+ ? { signal: "unregistered_provider_error_code" }
556
660
  : {}),
557
661
  ...(error instanceof z.ZodError ? { issues: zodDetails(error) } : {}),
558
662
  });
@@ -1150,12 +1254,10 @@ export function createServerApp(provider, options = {}) {
1150
1254
  missingSecrets: missingSecretsAtBoot,
1151
1255
  });
1152
1256
  }
1153
- app.notFound((c) => c.json({
1154
- error: {
1155
- code: "not_found",
1156
- message: "Not found",
1157
- },
1158
- }, 404));
1257
+ app.notFound((c) => {
1258
+ const error = new ProviderError("Not found", { code: "not_found", retryable: false });
1259
+ return responseWithErrorObservability(c.json(toErrorResponse(error), 404), error);
1260
+ });
1159
1261
  app.get("/health", (c) => c.json({
1160
1262
  status: "ok",
1161
1263
  provider: provider.id,
@@ -1272,7 +1374,7 @@ export function createServerApp(provider, options = {}) {
1272
1374
  }
1273
1375
  const requestId = extractRequestId(rawBody);
1274
1376
  logProviderError(logger, provider, "operation", operation, requestId, error, status, finishRequestCost(requestCost));
1275
- return c.json(toErrorResponse(error, requestId), status);
1377
+ return responseWithErrorObservability(c.json(toErrorResponse(error, requestId), status), error);
1276
1378
  }
1277
1379
  });
1278
1380
  app.post("/v1/:operation", async (c) => {
@@ -1306,7 +1408,7 @@ export function createServerApp(provider, options = {}) {
1306
1408
  const telemetryHeader = proxyTelemetry.toHeaderValue();
1307
1409
  if (telemetryHeader)
1308
1410
  c.header(PROVIDER_TELEMETRY_HEADER, telemetryHeader);
1309
- return c.json(toErrorResponse(error, requestId), status);
1411
+ return responseWithErrorObservability(c.json(toErrorResponse(error, requestId), status), error);
1310
1412
  }
1311
1413
  });
1312
1414
  app.post("/auth/start", async (c) => {
@@ -1326,7 +1428,7 @@ export function createServerApp(provider, options = {}) {
1326
1428
  const status = toStatusCode(error);
1327
1429
  const requestId = extractRequestId(rawBody);
1328
1430
  logProviderError(logger, provider, "auth", "start", requestId, error, status, finishRequestCost(requestCost));
1329
- return c.json(toErrorResponse(error, requestId), status);
1431
+ return responseWithErrorObservability(c.json(toErrorResponse(error, requestId), status), error);
1330
1432
  }
1331
1433
  });
1332
1434
  app.post("/auth/continue", async (c) => {
@@ -1346,7 +1448,7 @@ export function createServerApp(provider, options = {}) {
1346
1448
  const status = toStatusCode(error);
1347
1449
  const requestId = extractRequestId(rawBody);
1348
1450
  logProviderError(logger, provider, "auth", "continue", requestId, error, status, finishRequestCost(requestCost));
1349
- return c.json(toErrorResponse(error, requestId), status);
1451
+ return responseWithErrorObservability(c.json(toErrorResponse(error, requestId), status), error);
1350
1452
  }
1351
1453
  });
1352
1454
  app.post("/auth/poll", async (c) => {
@@ -1366,7 +1468,7 @@ export function createServerApp(provider, options = {}) {
1366
1468
  const status = toStatusCode(error);
1367
1469
  const requestId = extractRequestId(rawBody);
1368
1470
  logProviderError(logger, provider, "auth", "poll", requestId, error, status, finishRequestCost(requestCost));
1369
- return c.json(toErrorResponse(error, requestId), status);
1471
+ return responseWithErrorObservability(c.json(toErrorResponse(error, requestId), status), error);
1370
1472
  }
1371
1473
  });
1372
1474
  app.post("/auth/refresh", async (c) => {
@@ -1386,7 +1488,7 @@ export function createServerApp(provider, options = {}) {
1386
1488
  const status = toStatusCode(error);
1387
1489
  const requestId = extractRequestId(rawBody);
1388
1490
  logProviderError(logger, provider, "auth", "refresh", requestId, error, status, finishRequestCost(requestCost));
1389
- return c.json(toErrorResponse(error, requestId), status);
1491
+ return responseWithErrorObservability(c.json(toErrorResponse(error, requestId), status), error);
1390
1492
  }
1391
1493
  });
1392
1494
  app.post("/auth/disconnect", async (c) => {
@@ -1406,7 +1508,7 @@ export function createServerApp(provider, options = {}) {
1406
1508
  const status = toStatusCode(error);
1407
1509
  const requestId = extractRequestId(rawBody);
1408
1510
  logProviderError(logger, provider, "auth", "disconnect", requestId, error, status, finishRequestCost(requestCost));
1409
- return c.json(toErrorResponse(error, requestId), status);
1511
+ return responseWithErrorObservability(c.json(toErrorResponse(error, requestId), status), error);
1410
1512
  }
1411
1513
  });
1412
1514
  return app;
@@ -42,6 +42,7 @@ export declare const ErrorEnvelopeSchema: z.ZodObject<{
42
42
  code: z.ZodString;
43
43
  message: z.ZodString;
44
44
  requestId: z.ZodOptional<z.ZodString>;
45
+ retryable: z.ZodBoolean;
45
46
  fix: z.ZodOptional<z.ZodString>;
46
47
  details: z.ZodOptional<z.ZodUnknown>;
47
48
  }, z.core.$strip>;
@@ -84,6 +85,7 @@ export declare const OperationErrorResponseSchema: z.ZodObject<{
84
85
  code: z.ZodString;
85
86
  message: z.ZodString;
86
87
  requestId: z.ZodOptional<z.ZodString>;
88
+ retryable: z.ZodBoolean;
87
89
  fix: z.ZodOptional<z.ZodString>;
88
90
  details: z.ZodOptional<z.ZodUnknown>;
89
91
  }, z.core.$strip>;
@@ -121,6 +123,7 @@ export declare const AuthFlowErrorResponseSchema: z.ZodObject<{
121
123
  code: z.ZodString;
122
124
  message: z.ZodString;
123
125
  requestId: z.ZodOptional<z.ZodString>;
126
+ retryable: z.ZodBoolean;
124
127
  fix: z.ZodOptional<z.ZodString>;
125
128
  details: z.ZodOptional<z.ZodUnknown>;
126
129
  }, z.core.$strip>;
@@ -21,6 +21,7 @@ export const ErrorEnvelopeSchema = z.object({
21
21
  code: z.string(),
22
22
  message: z.string(),
23
23
  requestId: z.string().optional(),
24
+ retryable: z.boolean(),
24
25
  fix: z.string().optional(),
25
26
  details: z.unknown().optional(),
26
27
  });
@@ -9,6 +9,14 @@ export const STATEFUL_FORWARDING_NONCE_HEADER = "x-apifuse-stateful-nonce";
9
9
  export const STATEFUL_FORWARDING_SOURCE_POD_HEADER = "x-apifuse-stateful-source-pod";
10
10
  const MAX_FORWARDED_HEADERS = 32;
11
11
  const MAX_FORWARDED_HEADER_BYTES = 8 * 1024;
12
+ // Inbound compatibility boundary for rolling deploys: older owner pods omit
13
+ // top-level retryable. Emitted responses remain strict via
14
+ // OperationErrorResponseSchema.
15
+ const ForwardedOperationErrorResponseSchema = OperationErrorResponseSchema.extend({
16
+ error: OperationErrorResponseSchema.shape.error.extend({
17
+ retryable: z.boolean().optional().default(false),
18
+ }),
19
+ });
12
20
  const SENSITIVE_HEADER_NAMES = new Set([
13
21
  "authorization",
14
22
  "cookie",
@@ -183,7 +191,7 @@ async function parseForwardedResponse(response) {
183
191
  if (response.ok && success.success) {
184
192
  return { output: success.data.data };
185
193
  }
186
- const error = OperationErrorResponseSchema.safeParse(body);
194
+ const error = ForwardedOperationErrorResponseSchema.safeParse(body);
187
195
  if (error.success) {
188
196
  throw new StatefulOwnerForwardingError({
189
197
  code: error.data.error.code,
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.13",
2
+ "version": "2.2.0-beta.14",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
package/src/errors.ts CHANGED
@@ -10,6 +10,7 @@ const PROVIDER_ERROR_BRAND = Symbol.for("@apifuse/provider-sdk/error-brand@1");
10
10
  const PROVIDER_ERROR_BRAND_VALUE = 1;
11
11
  const SESSION_EXPIRED_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/session-expired@1");
12
12
  const TRANSPORT_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/transport@1");
13
+ const VALIDATION_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/validation@1");
13
14
 
14
15
  // Defines a non-enumerable, non-writable, non-configurable own data property.
15
16
  // Immutable + own means a guard can trust it via a single descriptor read
@@ -125,6 +126,7 @@ export class ValidationError extends ProviderError {
125
126
  super(message, options);
126
127
  this.name = "ValidationError";
127
128
  this.zodError = options?.zodError;
129
+ defineErrorBrand(this, VALIDATION_BRAND, true);
128
130
  }
129
131
  }
130
132
 
@@ -162,6 +164,13 @@ export function isTransportError(value: unknown): value is TransportError {
162
164
  return isProviderError(value) && hasOwnBrand(value, TRANSPORT_BRAND, true);
163
165
  }
164
166
 
167
+ export function isValidationError(value: unknown): value is ValidationError {
168
+ return (
169
+ isProviderError(value) &&
170
+ (hasOwnBrand(value, VALIDATION_BRAND, true) || value.name === "ValidationError")
171
+ );
172
+ }
173
+
165
174
  export class ProviderSecretError extends ProviderError {
166
175
  constructor(message: string, options?: ProviderErrorOptions) {
167
176
  super(message, { code: "provider_secret_error", ...options });
package/src/index.ts CHANGED
@@ -135,7 +135,12 @@ export {
135
135
  sensitive,
136
136
  z,
137
137
  } from "./schema.js";
138
- export { createServerApp, type ServeOptions, serve } from "./server/index.js";
138
+ export {
139
+ createServerApp,
140
+ ERROR_OBSERVABILITY_HEADER,
141
+ type ServeOptions,
142
+ serve,
143
+ } from "./server/index.js";
139
144
  export { getStealthProfile, listStealthProfiles } from "./stealth/profiles.js";
140
145
  export * from "./stream.js";
141
146
  export type {
@@ -1,4 +1,11 @@
1
- import { isSessionExpiredError, ProviderError, SessionExpiredError } from "../errors.js";
1
+ import {
2
+ isSessionExpiredError,
3
+ isValidationError,
4
+ ProviderError,
5
+ SessionExpiredError,
6
+ ValidationError,
7
+ } from "../errors.js";
8
+ import { z } from "zod";
2
9
  import { parseSchema } from "../schema.js";
3
10
  import type { ProviderContext, ProviderDefinition } from "../types.js";
4
11
  import { assertRequiredSecretsPresent } from "./secrets.js";
@@ -81,5 +88,18 @@ export async function executeOperation(
81
88
  return result;
82
89
  }
83
90
 
84
- return parseSchema(operation.output, result, `operations.${operationId}.output`);
91
+ try {
92
+ return await parseSchema(operation.output, result, `operations.${operationId}.output`);
93
+ } catch (cause) {
94
+ if (!(cause instanceof z.ZodError) && !isValidationError(cause)) {
95
+ throw cause;
96
+ }
97
+ throw new ValidationError(`Operation handler output failed schema validation.`, {
98
+ code: "OUTPUT_VALIDATION_FAILED",
99
+ category: "output_validation",
100
+ retryable: false,
101
+ zodError: isValidationError(cause) ? cause.zodError : cause,
102
+ ...(cause instanceof Error ? { cause } : {}),
103
+ });
104
+ }
85
105
  }
@@ -1,5 +1,7 @@
1
1
  export {
2
2
  createServerApp,
3
+ ERROR_OBSERVABILITY_HEADER,
4
+ type ErrorObservabilityDetails,
3
5
  type ProviderServerCloseOptions,
4
6
  type ProviderServerHandle,
5
7
  type ProviderServerLogEvent,
@@ -9,6 +9,7 @@ import {
9
9
  isProviderError,
10
10
  isSessionExpiredError,
11
11
  isTransportError,
12
+ isValidationError,
12
13
  ProviderError,
13
14
  } from "../errors.js";
14
15
  import {
@@ -108,6 +109,14 @@ import {
108
109
 
109
110
  const DEFAULT_HOST = "0.0.0.0";
110
111
  const DEFAULT_PORT = 3000;
112
+ /** Compact SDK-owned error classification emitted separately from the public response body. */
113
+ export const ERROR_OBSERVABILITY_HEADER = "X-ApiFuse-Error-Observability";
114
+ export type ErrorObservabilityDetails = {
115
+ category: ProviderErrorCategory;
116
+ taxonomyVersion: string;
117
+ retryable: boolean;
118
+ upstreamStatus?: number;
119
+ };
111
120
  const AUTH_FLOW_LOCALES = ["en", "ko", "ja"] as const;
112
121
  const retryResponseMeta = new WeakMap<ProviderContext, HttpRetrySummary>();
113
122
  const STATEFUL_INTERNAL_OPERATIONS_ROUTE = "/__apifuse/stateful/operations";
@@ -486,6 +495,7 @@ export type ProviderServerLogEvent =
486
495
  errorCategory?: ProviderErrorCategory;
487
496
  taxonomyVersion?: string;
488
497
  retryable?: boolean;
498
+ signal?: "unregistered_provider_error_code";
489
499
  issues?: Array<{ path: string; code: string; message: string }>;
490
500
  })
491
501
  | {
@@ -604,26 +614,28 @@ function zodDetails(error: z.ZodError): Array<{
604
614
  }
605
615
 
606
616
  function toErrorResponse(error: unknown, requestId?: string): OperationErrorResponse {
617
+ const observability = errorObservabilityDetails(error);
607
618
  if (error instanceof StatefulRoutingDeadlineError) {
608
619
  return {
609
620
  error: {
610
621
  code: "STATEFUL_FORWARDING_DEADLINE_EXPIRED",
611
622
  message: "Stateful forwarding deadline expired.",
612
623
  ...(requestId ? { requestId } : {}),
613
- details: { retryable: false },
624
+ retryable: observability.retryable,
614
625
  },
615
626
  };
616
627
  }
617
628
 
618
629
  if (isProviderError(error)) {
619
- const details = publicProviderErrorDetails(error);
630
+ const details = error.details;
620
631
  return {
621
632
  error: {
622
633
  code: error.code ?? "provider_error",
623
634
  message: publicProviderErrorMessage(error),
624
635
  ...(requestId ? { requestId } : {}),
636
+ retryable: observability.retryable,
625
637
  ...(error.fix ? { fix: error.fix } : {}),
626
- ...(details ? { details } : {}),
638
+ ...(details !== undefined ? { details } : {}),
627
639
  },
628
640
  };
629
641
  }
@@ -634,6 +646,7 @@ function toErrorResponse(error: unknown, requestId?: string): OperationErrorResp
634
646
  code: "invalid_request",
635
647
  message: "Invalid request body",
636
648
  ...(requestId ? { requestId } : {}),
649
+ retryable: observability.retryable,
637
650
  details: zodDetails(error),
638
651
  },
639
652
  };
@@ -650,6 +663,7 @@ function toErrorResponse(error: unknown, requestId?: string): OperationErrorResp
650
663
  code: "internal_error",
651
664
  message: "Internal error",
652
665
  ...(requestId ? { requestId } : {}),
666
+ retryable: observability.retryable,
653
667
  details: {
654
668
  retryable: false,
655
669
  category: "internal_error",
@@ -659,42 +673,12 @@ function toErrorResponse(error: unknown, requestId?: string): OperationErrorResp
659
673
  };
660
674
  }
661
675
 
662
- function publicProviderErrorDetails(error: ProviderError): unknown {
663
- const providerDetails = error.details;
664
- const observabilityDetails = providerObservabilityDetails(error);
665
-
666
- if (providerDetails === undefined) {
667
- return observabilityDetails;
668
- }
669
- if (observabilityDetails === undefined) {
670
- return providerDetails;
671
- }
672
- if (isPlainRecord(providerDetails) && isPlainRecord(observabilityDetails)) {
673
- return { ...providerDetails, ...observabilityDetails };
674
- }
675
- return {
676
- provider: providerDetails,
677
- observability: observabilityDetails,
678
- };
679
- }
680
-
681
- function isPlainRecord(value: unknown): value is Record<string, unknown> {
682
- return value !== null && typeof value === "object" && !Array.isArray(value);
683
- }
684
-
685
676
  // Accepts `unknown` so the branded guards narrow cleanly from the top: the
686
677
  // subtype error classes are structurally compatible with ProviderError, so
687
678
  // narrowing from a ProviderError-typed value would collapse the negative branch
688
679
  // to `never`. Narrowing from unknown avoids that while still recognizing errors
689
680
  // from a duplicate SDK module instance.
690
- function providerObservabilityDetails(error: unknown):
691
- | {
692
- category: ProviderErrorCategory;
693
- taxonomyVersion: string;
694
- retryable: boolean;
695
- upstreamStatus?: number;
696
- }
697
- | undefined {
681
+ function providerObservabilityDetails(error: unknown): ErrorObservabilityDetails | undefined {
698
682
  // Session-expiry surfaces the credential_expired category + the opt-in
699
683
  // retryable signal so Gateway/Credential Service can refresh and re-drive the
700
684
  // operation (see design.md §4.3 D3). Without this branch the auth error would
@@ -751,6 +735,54 @@ function providerObservabilityDetails(error: unknown):
751
735
  };
752
736
  }
753
737
 
738
+ function errorObservabilityDetails(error: unknown): ErrorObservabilityDetails {
739
+ const providerDetails = providerObservabilityDetails(error);
740
+ if (providerDetails) return providerDetails;
741
+
742
+ if (error instanceof z.ZodError || isValidationError(error)) {
743
+ return {
744
+ category:
745
+ isProviderError(error) && error.options?.category
746
+ ? error.options.category
747
+ : "input_validation",
748
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
749
+ retryable: isProviderError(error) ? (error.options?.retryable ?? false) : false,
750
+ };
751
+ }
752
+
753
+ if (error instanceof StatefulRoutingDeadlineError) {
754
+ return {
755
+ category: "timeout",
756
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
757
+ retryable: false,
758
+ };
759
+ }
760
+
761
+ if (isProviderError(error)) {
762
+ return {
763
+ category: error.options?.category ?? "provider_error",
764
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
765
+ retryable: error.options?.retryable ?? false,
766
+ };
767
+ }
768
+
769
+ return {
770
+ category: "internal_error",
771
+ taxonomyVersion: PROVIDER_OBSERVABILITY_TAXONOMY_VERSION,
772
+ retryable: false,
773
+ };
774
+ }
775
+
776
+ function responseWithErrorObservability(response: Response, error: unknown): Response {
777
+ const headers = new Headers(response.headers);
778
+ headers.set(ERROR_OBSERVABILITY_HEADER, JSON.stringify(errorObservabilityDetails(error)));
779
+ return new Response(response.body, {
780
+ status: response.status,
781
+ statusText: response.statusText,
782
+ headers,
783
+ });
784
+ }
785
+
754
786
  function publicProviderErrorMessage(error: ProviderError): string {
755
787
  if (isTransportError(error)) {
756
788
  if (error.code === PROXY_AUTH_IP_DENIED_CODE) {
@@ -782,11 +814,6 @@ function toStatusCode(error: unknown): 400 | 401 | 404 | 429 | 500 | 502 | 503 |
782
814
  if (error instanceof StatefulRoutingDeadlineError) {
783
815
  return 504;
784
816
  }
785
-
786
- if (isTransportError(error)) {
787
- return error.code === "transport_timeout" ? 504 : 502;
788
- }
789
-
790
817
  if (isProviderError(error)) {
791
818
  switch (error.code) {
792
819
  case "AUTH_REQUIRED":
@@ -812,13 +839,91 @@ function toStatusCode(error: unknown): 400 | 401 | 404 | 429 | 500 | 502 | 503 |
812
839
  case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
813
840
  return 503;
814
841
  }
842
+ if (isTransportError(error)) {
843
+ return error.code === "transport_timeout" ? 504 : 502;
844
+ }
845
+ if (isValidationError(error)) {
846
+ return error.options?.category === "output_validation" ? 500 : 400;
847
+ }
815
848
 
816
- return 400;
849
+ return 500;
817
850
  }
818
851
 
819
852
  return 500;
820
853
  }
821
854
 
855
+ // Codes emitted by SDK-owned paths must never be attributed to provider
856
+ // authors by the unregistered-code signal, even when their intentional status
857
+ // is 500. Provider-authored codes not in this registry retain the signal.
858
+ const SDK_OWNED_PROVIDER_ERROR_CODES = new Set([
859
+ "AUTH_PROMPT_UNAVAILABLE",
860
+ "BROWSER_CDP_POOL_REQUIRED",
861
+ "BROWSER_RUNTIME_UNSUPPORTED",
862
+ "STEALTH_RUNTIME_UNSUPPORTED",
863
+ "SSE_EVENT_UNDECLARED",
864
+ "STREAM_EVENT_TOO_LARGE",
865
+ "STREAM_CHUNK_TOO_LARGE",
866
+ "SSE_RESULT_UNSUPPORTED",
867
+ "STREAM_RESULT_UNSUPPORTED",
868
+ "AUTH_FLOW_NOT_CONFIGURED",
869
+ "refresh_not_supported",
870
+ "RUNTIME_UNSUPPORTED",
871
+ "PROVIDER_STATE_UNSUPPORTED",
872
+ "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
873
+ "CHOICE_STATE_PAYLOAD_TOO_LARGE",
874
+ "CHOICE_STATE_UNAVAILABLE",
875
+ "CHOICE_CONTEXT_REQUIRED",
876
+ "unsupported_stealth_cookie_store_version",
877
+ "provider_secret_error",
878
+ "credential_key_error",
879
+ "credential_mode_error",
880
+ "flow_expired",
881
+ "turn_validation_error",
882
+ "context_access_error",
883
+ "UNSUPPORTED_STT_OPTION",
884
+ "INVALID_STT_AUDIO",
885
+ "STT_AUDIO_TOO_LARGE",
886
+ "STT_UPSTREAM_FAILED",
887
+ "INVALID_STT_VERIFICATION_CODE_OPTIONS",
888
+ "NO_CODE_FOUND",
889
+ "AMBIGUOUS_CODE",
890
+ "retry_invalid_policy",
891
+ "retry_unsafe_method",
892
+ "stealth_cookie_store_serialize_failed",
893
+ "response_too_large",
894
+ "transport_stream_unavailable",
895
+ "transport_invalid_method",
896
+ "http_transport_override_unsupported",
897
+ "transport_invalid_url",
898
+ "retry_exhausted",
899
+ "auth_abort_unsafe_data",
900
+ "credentials_auth_missing_credential_keys",
901
+ "credentials_auth_missing_credential",
902
+ "credentials_auth_invalid_login_result",
903
+ "credentials_auth_unknown_challenge",
904
+ "credentials_auth_unknown_pending_challenge",
905
+ "STATEFUL_FORWARDING_NOT_CONFIGURED",
906
+ "STATEFUL_FORWARDING_SIGNATURE_MISSING",
907
+ "STATEFUL_FORWARDING_NONCE_INVALID",
908
+ "STATEFUL_FORWARDING_TIMESTAMP_INVALID",
909
+ "STATEFUL_FORWARDING_SIGNATURE_INVALID",
910
+ "STATEFUL_FORWARDING_REPLAY_DETECTED",
911
+ "STATEFUL_FORWARDING_REPLAY_CACHE_FULL",
912
+ "STATEFUL_FORWARDING_ENVELOPE_INVALID",
913
+ "STATEFUL_FORWARDING_PROVIDER_MISMATCH",
914
+ "STATEFUL_FORWARDING_SOURCE_POD_MISMATCH",
915
+ "STATEFUL_FORWARDING_OWNER_FENCE_INVALID",
916
+ "STATEFUL_FORWARDING_REQUEST_FAILED",
917
+ "STATEFUL_FORWARDING_CONTEXT_MISSING",
918
+ "STATEFUL_FORWARDING_BAD_RESPONSE",
919
+ "STATEFUL_INTERNAL_EXECUTOR_NOT_CONFIGURED",
920
+ "STATEFUL_FILE_FORWARDING_UNSUPPORTED",
921
+ "STATEFUL_CONTROL_PLANE_OPERATION_AMBIGUOUS",
922
+ "STATEFUL_CONTROL_PLANE_REQUEST_FAILED",
923
+ "STATEFUL_CONTROL_PLANE_HTTP_ERROR",
924
+ "STATEFUL_CONTROL_PLANE_INVALID_RESPONSE",
925
+ ]);
926
+
822
927
  function extractRequestId(raw: unknown): string | undefined {
823
928
  if (!raw || typeof raw !== "object") {
824
929
  return undefined;
@@ -847,7 +952,13 @@ function logProviderError(
847
952
  : "internal_error";
848
953
  const errorClass = error instanceof Error ? error.name : typeof error;
849
954
  const message = error instanceof Error ? error.message : String(error);
850
- const details = isProviderError(error) ? providerObservabilityDetails(error) : undefined;
955
+ const details = errorObservabilityDetails(error);
956
+ const isUnregisteredProviderErrorCode =
957
+ status === 500 &&
958
+ isProviderError(error) &&
959
+ !isValidationError(error) &&
960
+ typeof error.code === "string" &&
961
+ !SDK_OWNED_PROVIDER_ERROR_CODES.has(error.code);
851
962
  const emit = typeof logger === "function" ? logger : defaultProviderServerLogger;
852
963
  emit({
853
964
  level: status >= 500 ? "error" : "warn",
@@ -861,15 +972,12 @@ function logProviderError(
861
972
  code,
862
973
  errorClass,
863
974
  message,
864
- ...(isTransportError(error) && error.upstreamStatus
865
- ? { upstreamStatus: error.upstreamStatus }
866
- : {}),
867
- ...(details
868
- ? {
869
- errorCategory: details.category,
870
- taxonomyVersion: details.taxonomyVersion,
871
- retryable: details.retryable,
872
- }
975
+ ...(details.upstreamStatus ? { upstreamStatus: details.upstreamStatus } : {}),
976
+ errorCategory: details.category,
977
+ taxonomyVersion: details.taxonomyVersion,
978
+ retryable: details.retryable,
979
+ ...(isUnregisteredProviderErrorCode
980
+ ? { signal: "unregistered_provider_error_code" as const }
873
981
  : {}),
874
982
  ...(error instanceof z.ZodError ? { issues: zodDetails(error) } : {}),
875
983
  });
@@ -1614,17 +1722,10 @@ export function createServerApp(
1614
1722
  });
1615
1723
  }
1616
1724
 
1617
- app.notFound((c) =>
1618
- c.json(
1619
- {
1620
- error: {
1621
- code: "not_found",
1622
- message: "Not found",
1623
- },
1624
- },
1625
- 404,
1626
- ),
1627
- );
1725
+ app.notFound((c) => {
1726
+ const error = new ProviderError("Not found", { code: "not_found", retryable: false });
1727
+ return responseWithErrorObservability(c.json(toErrorResponse(error), 404), error);
1728
+ });
1628
1729
 
1629
1730
  app.get("/health", (c) =>
1630
1731
  c.json({
@@ -1786,7 +1887,10 @@ export function createServerApp(
1786
1887
  status,
1787
1888
  finishRequestCost(requestCost),
1788
1889
  );
1789
- return c.json(toErrorResponse(error, requestId), status);
1890
+ return responseWithErrorObservability(
1891
+ c.json(toErrorResponse(error, requestId), status),
1892
+ error,
1893
+ );
1790
1894
  }
1791
1895
  });
1792
1896
 
@@ -1850,7 +1954,10 @@ export function createServerApp(
1850
1954
  );
1851
1955
  const telemetryHeader = proxyTelemetry.toHeaderValue();
1852
1956
  if (telemetryHeader) c.header(PROVIDER_TELEMETRY_HEADER, telemetryHeader);
1853
- return c.json(toErrorResponse(error, requestId), status);
1957
+ return responseWithErrorObservability(
1958
+ c.json(toErrorResponse(error, requestId), status),
1959
+ error,
1960
+ );
1854
1961
  }
1855
1962
  });
1856
1963
 
@@ -1887,7 +1994,10 @@ export function createServerApp(
1887
1994
  status,
1888
1995
  finishRequestCost(requestCost),
1889
1996
  );
1890
- return c.json(toErrorResponse(error, requestId), status);
1997
+ return responseWithErrorObservability(
1998
+ c.json(toErrorResponse(error, requestId), status),
1999
+ error,
2000
+ );
1891
2001
  }
1892
2002
  });
1893
2003
 
@@ -1924,7 +2034,10 @@ export function createServerApp(
1924
2034
  status,
1925
2035
  finishRequestCost(requestCost),
1926
2036
  );
1927
- return c.json(toErrorResponse(error, requestId), status);
2037
+ return responseWithErrorObservability(
2038
+ c.json(toErrorResponse(error, requestId), status),
2039
+ error,
2040
+ );
1928
2041
  }
1929
2042
  });
1930
2043
 
@@ -1961,7 +2074,10 @@ export function createServerApp(
1961
2074
  status,
1962
2075
  finishRequestCost(requestCost),
1963
2076
  );
1964
- return c.json(toErrorResponse(error, requestId), status);
2077
+ return responseWithErrorObservability(
2078
+ c.json(toErrorResponse(error, requestId), status),
2079
+ error,
2080
+ );
1965
2081
  }
1966
2082
  });
1967
2083
 
@@ -1998,7 +2114,10 @@ export function createServerApp(
1998
2114
  status,
1999
2115
  finishRequestCost(requestCost),
2000
2116
  );
2001
- return c.json(toErrorResponse(error, requestId), status);
2117
+ return responseWithErrorObservability(
2118
+ c.json(toErrorResponse(error, requestId), status),
2119
+ error,
2120
+ );
2002
2121
  }
2003
2122
  });
2004
2123
 
@@ -2035,7 +2154,10 @@ export function createServerApp(
2035
2154
  status,
2036
2155
  finishRequestCost(requestCost),
2037
2156
  );
2038
- return c.json(toErrorResponse(error, requestId), status);
2157
+ return responseWithErrorObservability(
2158
+ c.json(toErrorResponse(error, requestId), status),
2159
+ error,
2160
+ );
2039
2161
  }
2040
2162
  });
2041
2163
 
@@ -26,6 +26,7 @@ export const ErrorEnvelopeSchema = z.object({
26
26
  code: z.string(),
27
27
  message: z.string(),
28
28
  requestId: z.string().optional(),
29
+ retryable: z.boolean(),
29
30
  fix: z.string().optional(),
30
31
  details: z.unknown().optional(),
31
32
  });
@@ -35,6 +35,14 @@ type FetchTransport = (url: string | URL | Request, init?: RequestInit) => Promi
35
35
 
36
36
  const MAX_FORWARDED_HEADERS = 32;
37
37
  const MAX_FORWARDED_HEADER_BYTES = 8 * 1024;
38
+ // Inbound compatibility boundary for rolling deploys: older owner pods omit
39
+ // top-level retryable. Emitted responses remain strict via
40
+ // OperationErrorResponseSchema.
41
+ const ForwardedOperationErrorResponseSchema = OperationErrorResponseSchema.extend({
42
+ error: OperationErrorResponseSchema.shape.error.extend({
43
+ retryable: z.boolean().optional().default(false),
44
+ }),
45
+ });
38
46
  const SENSITIVE_HEADER_NAMES = new Set([
39
47
  "authorization",
40
48
  "cookie",
@@ -253,7 +261,7 @@ async function parseForwardedResponse(response: Response): Promise<StatefulOpera
253
261
  return { output: success.data.data };
254
262
  }
255
263
 
256
- const error = OperationErrorResponseSchema.safeParse(body);
264
+ const error = ForwardedOperationErrorResponseSchema.safeParse(body);
257
265
  if (error.success) {
258
266
  throw new StatefulOwnerForwardingError({
259
267
  code: error.data.error.code,