@apifuse/provider-sdk 2.2.0-beta.60 → 2.2.0-beta.62

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.
Files changed (85) hide show
  1. package/AUTHORING.md +17 -16
  2. package/CHANGELOG.md +8 -0
  3. package/bin/apifuse-dev.ts +5 -7
  4. package/bin/apifuse-pack-types.ts +26 -0
  5. package/bin/apifuse-perf.ts +4 -6
  6. package/bin/apifuse-record.ts +4 -7
  7. package/bin/apifuse-submit-check.ts +2 -2
  8. package/dist/define.d.ts +3 -2
  9. package/dist/define.js +1 -1
  10. package/dist/dev.js +3 -1
  11. package/dist/engine.d.ts +7 -6
  12. package/dist/engine.js +3 -4
  13. package/dist/error-resolution.js +21 -4
  14. package/dist/errors.d.ts +3 -0
  15. package/dist/errors.js +9 -0
  16. package/dist/handle-meta.d.ts +55 -0
  17. package/dist/handle-meta.js +57 -0
  18. package/dist/handle.d.ts +208 -0
  19. package/dist/handle.js +299 -0
  20. package/dist/i18n/catalog.js +21 -1
  21. package/dist/index.d.ts +7 -4
  22. package/dist/index.js +6 -3
  23. package/dist/lint.d.ts +3 -0
  24. package/dist/lint.js +372 -47
  25. package/dist/provider.d.ts +5 -3
  26. package/dist/provider.js +4 -2
  27. package/dist/runtime/handle-wordlist.d.ts +43 -0
  28. package/dist/runtime/{choice-wordlist.js → handle-wordlist.js} +127 -9
  29. package/dist/runtime/handle.d.ts +71 -0
  30. package/dist/runtime/handle.js +900 -0
  31. package/dist/runtime/http-telemetry-binding.d.ts +10 -0
  32. package/dist/runtime/http-telemetry-binding.js +152 -0
  33. package/dist/runtime/http-telemetry-guard.d.ts +6 -0
  34. package/dist/runtime/http-telemetry-guard.js +150 -0
  35. package/dist/runtime/http-telemetry.d.ts +104 -0
  36. package/dist/runtime/http-telemetry.js +190 -0
  37. package/dist/runtime/http.d.ts +31 -0
  38. package/dist/runtime/http.js +222 -146
  39. package/dist/runtime/native-network.d.ts +10 -1
  40. package/dist/runtime/native-network.js +230 -29
  41. package/dist/runtime/native-telemetry.d.ts +142 -0
  42. package/dist/runtime/native-telemetry.js +231 -0
  43. package/dist/runtime/state.d.ts +4 -1
  44. package/dist/runtime/state.js +32 -17
  45. package/dist/server/index.d.ts +3 -0
  46. package/dist/server/index.js +2 -0
  47. package/dist/server/sensitive-values.d.ts +1 -1
  48. package/dist/server/sensitive-values.js +0 -3
  49. package/dist/server/serve-implementation.d.ts +6 -11
  50. package/dist/server/serve-implementation.js +119 -52
  51. package/dist/testing/run.js +10 -7
  52. package/dist/types.d.ts +4 -107
  53. package/package.json +1 -1
  54. package/src/define.ts +4 -3
  55. package/src/dev.ts +3 -1
  56. package/src/engine.ts +5 -6
  57. package/src/error-resolution.ts +21 -4
  58. package/src/errors.ts +13 -0
  59. package/src/handle-meta.ts +93 -0
  60. package/src/handle.ts +612 -0
  61. package/src/i18n/catalog.ts +23 -1
  62. package/src/index.ts +45 -13
  63. package/src/lint.ts +437 -55
  64. package/src/provider.ts +19 -20
  65. package/src/runtime/{choice-wordlist.ts → handle-wordlist.ts} +130 -9
  66. package/src/runtime/handle.ts +1172 -0
  67. package/src/runtime/http-telemetry-binding.ts +162 -0
  68. package/src/runtime/http-telemetry-guard.ts +155 -0
  69. package/src/runtime/http-telemetry.ts +292 -0
  70. package/src/runtime/http.ts +295 -173
  71. package/src/runtime/native-network.ts +281 -33
  72. package/src/runtime/native-telemetry.ts +366 -0
  73. package/src/runtime/state.ts +44 -16
  74. package/src/server/index.ts +30 -0
  75. package/src/server/sensitive-values.ts +0 -3
  76. package/src/server/serve-implementation.ts +127 -89
  77. package/src/testing/run.ts +11 -7
  78. package/src/types.ts +4 -137
  79. package/dist/choice-token.d.ts +0 -24
  80. package/dist/choice-token.js +0 -74
  81. package/dist/runtime/choice-wordlist.d.ts +0 -9
  82. package/dist/runtime/choice.d.ts +0 -27
  83. package/dist/runtime/choice.js +0 -801
  84. package/src/choice-token.ts +0 -165
  85. package/src/runtime/choice.ts +0 -1197
@@ -2,9 +2,34 @@ import { policyRotatesTransportVendorChain, resolvePolicyTransportAttemptCap, re
2
2
  import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
3
3
  import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
4
4
  import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, proxyTransportRetryErrorCode, proxyTransportRetryErrorStatus, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
5
+ import { httpTelemetryErrorCode, } from "./http-telemetry.js";
5
6
  import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
6
7
  import { normalizeHttpRequestBody, redactSensitiveError, redactSensitiveRequestError, serializeRequestUrl, } from "./request-options.js";
8
+ import { startHttpTelemetry } from "./http-telemetry-guard.js";
9
+ import { scopedHttpTelemetry } from "./http-telemetry-binding.js";
7
10
  const DEFAULT_HTTP_BASE_URL = "http://localhost";
11
+ function httpAttemptFailure(error, status) {
12
+ try {
13
+ return {
14
+ e: httpTelemetryErrorCode(proxyTransportRetryErrorCode(error)),
15
+ status: proxyTransportRetryErrorStatus(error) ?? status,
16
+ ...(error instanceof Error
17
+ ? {
18
+ diagnostics: {
19
+ name: error.name,
20
+ message: error.message,
21
+ ...(error.cause instanceof Error
22
+ ? { cause: { name: error.cause.name, message: error.cause.message } }
23
+ : {}),
24
+ },
25
+ }
26
+ : {}),
27
+ };
28
+ }
29
+ catch {
30
+ return { e: "other" };
31
+ }
32
+ }
8
33
  function isHttpStatusOutcome(outcome) {
9
34
  return "kind" in outcome && outcome.kind === "http-status";
10
35
  }
@@ -527,7 +552,7 @@ function serializeHttpRequestUrl(baseUrl, url, options) {
527
552
  throw redactSensitiveRequestError(error, url, options.sensitiveParams);
528
553
  }
529
554
  }
530
- async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0, dedupe) {
555
+ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, warn, statusRetryCodes, proxyAttemptOffset = 0, dedupe, observeProxy) {
531
556
  const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
532
557
  const { requestUrl } = serializedUrl;
533
558
  const controller = options.timeout ? new AbortController() : undefined;
@@ -542,6 +567,7 @@ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, war
542
567
  // failures are branded as TransportErrors and count against the request
543
568
  // deadline, exactly as an inline resolve would.
544
569
  proxy = await resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset);
570
+ observeProxy?.(Boolean(proxy));
545
571
  throwIfAmbientAborted(clientOptions.signal);
546
572
  // For a registry allocator chain, skip an endpoint a prior attempt already
547
573
  // tried rather than re-issuing the same request. Returning the sentinel
@@ -563,6 +589,7 @@ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, war
563
589
  requestInit.body = normalizeNativeFetchBody(options.body);
564
590
  }
565
591
  const response = await fetchWithHttpRedirectPolicy(requestUrl, requestInit, options.redirectPolicy);
592
+ observeProxy?.(Boolean(proxy), response.status);
566
593
  const headers = Object.fromEntries(response.headers.entries());
567
594
  if (statusRetryCodes && response.status >= 400) {
568
595
  await drainNativeResponseBody(response);
@@ -596,7 +623,7 @@ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, war
596
623
  clearTimeout(timeoutHandle);
597
624
  }
598
625
  }
599
- async function fetchNativeHttpStream(baseUrl, url, method, options, clientOptions, warn) {
626
+ async function fetchNativeHttpStream(baseUrl, url, method, options, clientOptions, warn, observeProxy) {
600
627
  const serializedUrl = serializeHttpRequestUrl(baseUrl, url, options);
601
628
  const { requestUrl } = serializedUrl;
602
629
  const controller = options.timeout ? new AbortController() : undefined;
@@ -607,6 +634,7 @@ async function fetchNativeHttpStream(baseUrl, url, method, options, clientOption
607
634
  try {
608
635
  throwIfAmbientAborted(clientOptions.signal);
609
636
  const proxy = await resolveNativeProxy(options, clientOptions, warn);
637
+ observeProxy?.(Boolean(proxy));
610
638
  throwIfAmbientAborted(clientOptions.signal);
611
639
  const requestInit = {
612
640
  headers: options.headers,
@@ -676,164 +704,189 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
676
704
  throw redactSensitiveRequestError(error, url, options.sensitiveParams);
677
705
  }
678
706
  })();
679
- const retryEnabled = Boolean(retryOptions &&
680
- retryOptions.attempts > 1 &&
681
- isProxyTransportRetryMethod(methodName, retryOptions));
682
- const statusRetryEnabled = Boolean(retryEnabled &&
683
- explicitRetry &&
684
- retryOptions &&
685
- retryOptions.statusCodes.length > 0 &&
686
- headersOptions.throwOnHttpError !== false);
687
- const attemptOptions = statusRetryEnabled
688
- ? { ...headersOptions, throwOnHttpError: false }
689
- : headersOptions;
690
- // Span the whole vendor chain on transport failures. Like ctx.stealth, a
691
- // policy-managed proxy resolves a *different* endpoint/vendor per attempt
692
- // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
693
- // spans), so a transport failure should advance to the next endpoint —
694
- // potentially crossing into the fallback vendor — rather than stopping at
695
- // the per-endpoint retry budget and stranding the request on the primary
696
- // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
697
- // only for implicit, safe-method allocator requests; explicit retry
698
- // policies (their documented `attempts` ceiling), unsafe methods, and
699
- // static/non-registry vendors keep the retry budget. Status-code retries
700
- // stay bounded by the retry budget regardless; only transport rotation gets
701
- // the full span.
702
- const policyProxy = (() => {
703
- const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
704
- return policy && typeof policy === "object" ? policy : undefined;
705
- })();
706
- const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
707
- const transportAttemptCap = retryOptions
708
- ? resolvePolicyTransportAttemptCap({
707
+ const requestStarted = performance.now();
708
+ const telemetry = startHttpTelemetry(scopedHttpTelemetry(clientOptions.httpTelemetry), retryOptions?.preset);
709
+ try {
710
+ return await runRequest();
711
+ }
712
+ finally {
713
+ telemetry.finish(performance.now() - requestStarted);
714
+ }
715
+ async function runRequest() {
716
+ const retryEnabled = Boolean(retryOptions &&
717
+ retryOptions.attempts > 1 &&
718
+ isProxyTransportRetryMethod(methodName, retryOptions));
719
+ const statusRetryEnabled = Boolean(retryEnabled &&
720
+ explicitRetry &&
721
+ retryOptions &&
722
+ retryOptions.statusCodes.length > 0 &&
723
+ headersOptions.throwOnHttpError !== false);
724
+ const attemptOptions = statusRetryEnabled
725
+ ? { ...headersOptions, throwOnHttpError: false }
726
+ : headersOptions;
727
+ // Span the whole vendor chain on transport failures. Like ctx.stealth, a
728
+ // policy-managed proxy resolves a *different* endpoint/vendor per attempt
729
+ // (the flat proxyAttemptOffset rotates across the concatenated vendor pool
730
+ // spans), so a transport failure should advance to the next endpoint —
731
+ // potentially crossing into the fallback vendor — rather than stopping at
732
+ // the per-endpoint retry budget and stranding the request on the primary
733
+ // vendor. resolvePolicyTransportAttemptCap widens the cap to the chain span
734
+ // only for implicit, safe-method allocator requests; explicit retry
735
+ // policies (their documented `attempts` ceiling), unsafe methods, and
736
+ // static/non-registry vendors keep the retry budget. Status-code retries
737
+ // stay bounded by the retry budget regardless; only transport rotation gets
738
+ // the full span.
739
+ const policyProxy = (() => {
740
+ const policy = clientOptions.proxyPolicy ?? clientOptions.upstream?.proxy;
741
+ return policy && typeof policy === "object" ? policy : undefined;
742
+ })();
743
+ const usesPolicyAllocator = Boolean(policyProxy) && !options.proxy && !clientOptions.proxy;
744
+ const transportAttemptCap = retryOptions
745
+ ? resolvePolicyTransportAttemptCap({
746
+ policy: policyProxy,
747
+ usesPolicyAllocator,
748
+ retryAttempts: retryOptions.attempts,
749
+ explicitRetry,
750
+ method: methodName,
751
+ })
752
+ : 1;
753
+ // Track resolved endpoints across a policy-allocator chain. Successive
754
+ // attempts rotate the flat offset across the concatenated vendor pool
755
+ // spans, but an under-filled allocation (fewer live endpoints than the
756
+ // configured pool size) makes the modulo mapping repeat endpoints before
757
+ // the offset reaches the next vendor. Rather than re-hammering an
758
+ // already-tried endpoint under backoff, fetchNativeHttp returns a skip
759
+ // sentinel for a duplicate; the loop then advances the flat offset without
760
+ // issuing the request, so it keeps walking toward — and into — the fallback
761
+ // vendor's pool span instead of stalling on the primary vendor.
762
+ // De-duplication is gated on the SAME predicate that widens the attempt cap
763
+ // (implicit, safe-method, registry-chain rotation). It must NOT engage for
764
+ // an explicit retry policy: there the caller's `attempts` count is the
765
+ // contract and each attempt must issue against whatever endpoint it resolves
766
+ // — even a repeat — instead of being silently skipped (which would collapse
767
+ // a `poolSize: 1` + `attempts: 3` request to a single fetch).
768
+ const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
709
769
  policy: policyProxy,
710
770
  usesPolicyAllocator,
711
- retryAttempts: retryOptions.attempts,
712
771
  explicitRetry,
713
772
  method: methodName,
714
- })
715
- : 1;
716
- // Track resolved endpoints across a policy-allocator chain. Successive
717
- // attempts rotate the flat offset across the concatenated vendor pool
718
- // spans, but an under-filled allocation (fewer live endpoints than the
719
- // configured pool size) makes the modulo mapping repeat endpoints before
720
- // the offset reaches the next vendor. Rather than re-hammering an
721
- // already-tried endpoint under backoff, fetchNativeHttp returns a skip
722
- // sentinel for a duplicate; the loop then advances the flat offset without
723
- // issuing the request, so it keeps walking toward — and into — the fallback
724
- // vendor's pool span instead of stalling on the primary vendor.
725
- // De-duplication is gated on the SAME predicate that widens the attempt cap
726
- // (implicit, safe-method, registry-chain rotation). It must NOT engage for
727
- // an explicit retry policy: there the caller's `attempts` count is the
728
- // contract and each attempt must issue against whatever endpoint it resolves
729
- // — even a repeat — instead of being silently skipped (which would collapse
730
- // a `poolSize: 1` + `attempts: 3` request to a single fetch).
731
- const dedupeAllocatorEndpoints = policyRotatesTransportVendorChain({
732
- policy: policyProxy,
733
- usesPolicyAllocator,
734
- explicitRetry,
735
- method: methodName,
736
- });
737
- const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set() } : undefined;
738
- const executeOnce = (proxyAttemptOffset = 0) => fetchNativeHttp(baseUrl, url, methodName, attemptOptions, clientOptions, warnOnce, statusRetryEnabled ? retryOptions?.statusCodes : undefined, proxyAttemptOffset, dedupeContext);
739
- if (!retryEnabled || !retryOptions) {
740
- throwIfAmbientAborted(clientOptions.signal);
741
- const outcome = await executeOnce();
742
- if (isDedupeSkipOutcome(outcome)) {
743
- // Single-shot path never de-duplicates (dedupeContext is undefined),
744
- // but keep the union total.
745
- throw new TransportError("HTTP request produced no terminal result", {
746
- code: "retry_exhausted",
747
- });
748
- }
749
- if (isHttpStatusOutcome(outcome)) {
750
- throw toUpstreamHttpError(outcome.status);
751
- }
752
- return outcome;
753
- }
754
- let lastError;
755
- let lastErrorCode;
756
- let lastStatus;
757
- // `attempt` walks the flat proxy offset across the full chain span; `issued`
758
- // counts requests that were actually sent (skipped duplicate offsets do not
759
- // increment it). Retry summaries and the status-retry budget must reflect
760
- // issued requests, not the raw offset, so they stay accurate when partial
761
- // allocations skip offsets.
762
- let issued = 0;
763
- for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
764
- throwIfAmbientAborted(clientOptions.signal);
765
- // Whether this offset actually issued a request (vs. a skipped duplicate),
766
- // so the catch counts a thrown *transport* failure once without
767
- // double-counting a status outcome that already incremented before it
768
- // re-threw as an upstream HTTP error.
769
- let issuedThisAttempt = false;
770
- try {
771
- const outcome = await executeOnce(attempt - 1);
773
+ });
774
+ const dedupeContext = dedupeAllocatorEndpoints ? { attempted: new Set() } : undefined;
775
+ const executeOnce = async (proxyAttemptOffset = 0) => {
776
+ const started = performance.now();
777
+ let proxyUsed = false;
778
+ let observedStatus;
779
+ const record = (event) => {
780
+ telemetry.recordAttempt({ ...event, ms: performance.now() - started, proxyUsed });
781
+ };
782
+ try {
783
+ const outcome = await fetchNativeHttp(baseUrl, url, methodName, attemptOptions, clientOptions, warnOnce, statusRetryEnabled ? retryOptions?.statusCodes : undefined, proxyAttemptOffset, dedupeContext, (used, status) => {
784
+ proxyUsed = used;
785
+ observedStatus = status;
786
+ });
787
+ if (!isDedupeSkipOutcome(outcome))
788
+ record({
789
+ status: outcome.status,
790
+ ...(isHttpStatusOutcome(outcome)
791
+ ? { e: "upstream_http_error", statusRetry: true }
792
+ : {}),
793
+ });
794
+ return outcome;
795
+ }
796
+ catch (error) {
797
+ // Record before the retry loop can catch/rethrow a terminal status.
798
+ record(httpAttemptFailure(error, observedStatus));
799
+ throw error;
800
+ }
801
+ };
802
+ if (!retryEnabled || !retryOptions) {
803
+ throwIfAmbientAborted(clientOptions.signal);
804
+ const outcome = await executeOnce();
772
805
  if (isDedupeSkipOutcome(outcome)) {
773
- // Duplicate endpoint from a partial allocation: advance the flat
774
- // offset without issuing the request (no backoff, not a failure) so
775
- // the loop keeps rotating toward the fallback vendor.
776
- continue;
806
+ // Single-shot path never de-duplicates (dedupeContext is undefined),
807
+ // but keep the union total.
808
+ throw new TransportError("HTTP request produced no terminal result", {
809
+ code: "retry_exhausted",
810
+ });
777
811
  }
778
- issued += 1;
779
- issuedThisAttempt = true;
780
812
  if (isHttpStatusOutcome(outcome)) {
781
- lastStatus = outcome.status;
782
- if (outcome.retryable && issued < retryOptions.attempts) {
783
- await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers), clientOptions.signal);
784
- continue;
785
- }
786
813
  throw toUpstreamHttpError(outcome.status);
787
814
  }
788
- const response = outcome;
789
- if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
790
- throw toUpstreamHttpError(response.status);
791
- }
792
- if (issued > 1) {
793
- const summary = {
794
- attempts: issued,
795
- retries: issued - 1,
796
- ...(retryOptions.preset ? { preset: retryOptions.preset } : {}),
797
- transport: "native",
798
- ...(lastErrorCode ? { lastErrorCode } : {}),
799
- ...(lastStatus ? { lastStatus } : {}),
800
- };
801
- clientOptions.onRetrySummary?.(summary);
802
- }
803
- return response;
815
+ return outcome;
804
816
  }
805
- catch (error) {
817
+ let lastError;
818
+ // `attempt` walks the flat proxy offset across the full chain span; `issued`
819
+ // counts requests that were actually sent (skipped duplicate offsets do not
820
+ // increment it). Retry summaries and the status-retry budget must reflect
821
+ // issued requests, not the raw offset, so they stay accurate when partial
822
+ // allocations skip offsets.
823
+ let issued = 0;
824
+ for (let attempt = 1; attempt <= transportAttemptCap; attempt += 1) {
806
825
  throwIfAmbientAborted(clientOptions.signal);
807
- if (!issuedThisAttempt)
826
+ // Whether this offset actually issued a request (vs. a skipped duplicate),
827
+ // so the catch counts a thrown *transport* failure once without
828
+ // double-counting a status outcome that already incremented before it
829
+ // re-threw as an upstream HTTP error.
830
+ let issuedThisAttempt = false;
831
+ try {
832
+ const outcome = await executeOnce(attempt - 1);
833
+ if (isDedupeSkipOutcome(outcome)) {
834
+ // Duplicate endpoint from a partial allocation: advance the flat
835
+ // offset without issuing the request (no backoff, not a failure) so
836
+ // the loop keeps rotating toward the fallback vendor.
837
+ continue;
838
+ }
808
839
  issued += 1;
809
- lastError = error;
810
- lastErrorCode = proxyTransportRetryErrorCode(error);
811
- lastStatus = proxyTransportRetryErrorStatus(error);
812
- const proxyUsed = Boolean(error.proxyUsed);
813
- if (attempt < transportAttemptCap &&
814
- shouldRetryProxyTransportAttempt({
815
- error,
816
- explicitRetry,
817
- method: methodName,
818
- options: retryOptions,
819
- proxyUsed,
820
- })) {
821
- await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt), clientOptions.signal);
822
- continue;
840
+ issuedThisAttempt = true;
841
+ if (isHttpStatusOutcome(outcome)) {
842
+ if (outcome.retryable && issued < retryOptions.attempts) {
843
+ await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt, outcome.headers), clientOptions.signal);
844
+ continue;
845
+ }
846
+ throw toUpstreamHttpError(outcome.status);
847
+ }
848
+ const response = outcome;
849
+ if (response.status >= 400 && headersOptions.throwOnHttpError !== false) {
850
+ throw toUpstreamHttpError(response.status);
851
+ }
852
+ if (issued > 1) {
853
+ const summary = telemetry.toTenantRetryPayload();
854
+ if (summary)
855
+ telemetry.observe(() => clientOptions.onRetrySummary?.(summary));
856
+ }
857
+ return response;
858
+ }
859
+ catch (error) {
860
+ throwIfAmbientAborted(clientOptions.signal);
861
+ if (!issuedThisAttempt)
862
+ issued += 1;
863
+ lastError = error;
864
+ const proxyUsed = Boolean(error.proxyUsed);
865
+ if (attempt < transportAttemptCap &&
866
+ shouldRetryProxyTransportAttempt({
867
+ error,
868
+ explicitRetry,
869
+ method: methodName,
870
+ options: retryOptions,
871
+ proxyUsed,
872
+ })) {
873
+ await sleep(computeProxyTransportRetryDelayMs(retryOptions, attempt), clientOptions.signal);
874
+ continue;
875
+ }
876
+ throw error;
823
877
  }
824
- throw error;
825
878
  }
879
+ // Reached when the attempt cap is consumed without a terminal outcome —
880
+ // e.g. the final offsets of a partial allocation all resolved to
881
+ // already-tried endpoints and were skipped. Surface the last real transport
882
+ // failure rather than a synthetic exhaustion error.
883
+ if (lastError !== undefined) {
884
+ throw lastError;
885
+ }
886
+ throw new TransportError("HTTP retry exhausted without a terminal result", {
887
+ code: "retry_exhausted",
888
+ });
826
889
  }
827
- // Reached when the attempt cap is consumed without a terminal outcome —
828
- // e.g. the final offsets of a partial allocation all resolved to
829
- // already-tried endpoints and were skipped. Surface the last real transport
830
- // failure rather than a synthetic exhaustion error.
831
- if (lastError !== undefined) {
832
- throw lastError;
833
- }
834
- throw new TransportError("HTTP retry exhausted without a terminal result", {
835
- code: "retry_exhausted",
836
- });
837
890
  }
838
891
  async function streamRequest(url, method, options = {}) {
839
892
  const { headersOptions, methodName } = (() => {
@@ -855,7 +908,30 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
855
908
  throw redactSensitiveRequestError(error, url, options.sensitiveParams);
856
909
  }
857
910
  })();
858
- return fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce);
911
+ // Streams never retry. Observe the header fetch without consuming its body.
912
+ const telemetry = startHttpTelemetry(scopedHttpTelemetry(clientOptions.httpTelemetry), "off");
913
+ const started = performance.now();
914
+ const alreadyAborted = clientOptions.signal?.aborted === true;
915
+ let proxyUsed = false;
916
+ const record = (event) => {
917
+ if (alreadyAborted)
918
+ return;
919
+ telemetry.recordAttempt({ ...event, ms: performance.now() - started, proxyUsed });
920
+ };
921
+ try {
922
+ const response = await fetchNativeHttpStream(baseUrl, url, methodName, headersOptions, clientOptions, warnOnce, (used) => {
923
+ proxyUsed = used;
924
+ });
925
+ record({ status: response.status });
926
+ return response;
927
+ }
928
+ catch (error) {
929
+ record(httpAttemptFailure(error));
930
+ throw error;
931
+ }
932
+ finally {
933
+ telemetry.finish(performance.now() - started);
934
+ }
859
935
  }
860
936
  return {
861
937
  request: async (url, options = {}) => request(url, options.method ?? "GET", options),
@@ -1,8 +1,9 @@
1
1
  import { Socket } from "node:net";
2
2
  import { type TLSSocket } from "node:tls";
3
- import { type ProxyProtocol } from "../config/loader.js";
3
+ import { type ProxyProtocol, type ProxyTelemetrySink } from "../config/loader.js";
4
4
  import { type DynamicEgressRuleSnapshot } from "../native-egress-policy.js";
5
5
  import type { NativeNetworkClient, NativeNetworkConnectInput, NativeNetworkConnection, NativeNetworkDynamicGrantOptions, NativeNetworkEgressGrant, NativeProviderConfig, NativeProxyEgressInfo, ProviderProxyPolicy, ProviderProxyProvider, EnvContext } from "../types.js";
6
+ import type { NativeTelemetrySink } from "./native-telemetry.js";
6
7
  export { NativeEgressGrantExpiredError, NativeEgressNotDeclaredError, NativeIdleTimeoutError, NativeNetworkError, NativeProxyExpiredError, } from "./native-network-errors.js";
7
8
  export type { NativeNetworkErrorCode } from "./native-network-errors.js";
8
9
  export type NativeGatewayProxy = NativeProxyEgressInfo & {
@@ -15,6 +16,8 @@ export type NativeGatewayProxySynthesisInput = {
15
16
  readonly now: number;
16
17
  readonly protocol: ProxyProtocol;
17
18
  readonly credentials: VendorCredentialResolver;
19
+ /** Existing proxy lease telemetry; adapters may report detailed allocation facts. */
20
+ readonly telemetry?: ProxyTelemetrySink;
18
21
  };
19
22
  export type VendorCredentialLookup = {
20
23
  readonly kind: "present";
@@ -50,8 +53,14 @@ export type NativeGatewayProxyResolutionInput = {
50
53
  readonly protocol?: ProxyProtocol;
51
54
  readonly credentials?: VendorCredentialResolver;
52
55
  readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
56
+ readonly telemetry?: ProxyTelemetrySink;
57
+ readonly nativeTelemetry?: NativeTelemetrySink;
53
58
  };
54
59
  export type NativeNetworkClientOptions = {
60
+ /** Existing proxy lease contributor, shared with HTTP and stealth. */
61
+ readonly telemetry?: ProxyTelemetrySink;
62
+ /** Request-scoped connection, byte and lifecycle observations. */
63
+ readonly nativeTelemetry?: NativeTelemetrySink;
55
64
  readonly proxyPolicy?: ProviderProxyPolicy;
56
65
  readonly affinityKey?: string;
57
66
  /** Stable credential/account identity; hashed before vendor synthesis. */