@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/AUTHORING.md +28 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +8 -4
  4. package/bin/apifuse-record.ts +207 -31
  5. package/bin/apifuse-submit-check.ts +273 -74
  6. package/dist/cli/local-connection.d.ts +111 -0
  7. package/dist/cli/local-connection.js +151 -0
  8. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  9. package/dist/cli/templates/provider/README.md.tpl +17 -9
  10. package/dist/config/loader.d.ts +16 -0
  11. package/dist/config/loader.js +25 -8
  12. package/dist/error-resolution.d.ts +13 -0
  13. package/dist/error-resolution.js +72 -35
  14. package/dist/lint.d.ts +2 -0
  15. package/dist/lint.js +493 -2
  16. package/dist/runtime/executor.js +3 -0
  17. package/dist/runtime/http.d.ts +2 -0
  18. package/dist/runtime/http.js +2 -1
  19. package/dist/runtime/provider.d.ts +7 -0
  20. package/dist/runtime/provider.js +14 -0
  21. package/dist/server/index.d.ts +1 -0
  22. package/dist/server/serve-implementation.d.ts +2 -1
  23. package/dist/server/serve-implementation.js +43 -29
  24. package/dist/server/state-backend-report.d.ts +70 -0
  25. package/dist/server/state-backend-report.js +113 -0
  26. package/package.json +1 -1
  27. package/src/cli/local-connection.ts +221 -0
  28. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  29. package/src/cli/templates/provider/README.md.tpl +17 -9
  30. package/src/config/loader.ts +49 -12
  31. package/src/error-resolution.ts +89 -35
  32. package/src/lint.ts +541 -1
  33. package/src/runtime/executor.ts +3 -0
  34. package/src/runtime/http.ts +2 -1
  35. package/src/runtime/provider.ts +15 -0
  36. package/src/server/index.ts +4 -0
  37. package/src/server/serve-implementation.ts +56 -30
  38. package/src/server/state-backend-report.ts +185 -0
package/AUTHORING.md CHANGED
@@ -680,6 +680,31 @@ The registered mappings are:
680
680
  | Unregistered input `ValidationError` code | 400 |
681
681
  | Other unregistered `ProviderError` code | 500 |
682
682
 
683
+ A registered code that names an upstream outcome also carries a category. When
684
+ a `ProviderError` (or any subclass, `ValidationError` included) with one of
685
+ these codes sets no `category`, the server reports the code's category in
686
+ `X-ApiFuse-Error-Observability`, and the public `source` follows from it:
687
+
688
+ | Error code | Category when unset | `source` |
689
+ | --- | --- | --- |
690
+ | `NOT_FOUND`, `not_found`, `NO_DATA` | `upstream_http` | `upstream_failure` |
691
+ | `RATE_LIMITED`, `UPSTREAM_RATE_LIMIT`, `LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR` | `upstream_rate_limited` | `upstream_rule` |
692
+ | `UPSTREAM_REJECTED` | `upstream_rejected` | `upstream_rule` |
693
+ | `UPSTREAM_ERROR` | `upstream_http` | `upstream_failure` |
694
+ | `BLOCKED` | `anti_bot_blocked` | `upstream_failure` |
695
+ | `UPSTREAM_AUTH_ERROR` | `upstream_auth` | `upstream_failure` |
696
+ | `UPSTREAM_SCHEMA_ERROR` | `upstream_schema_drift` | `upstream_failure` |
697
+
698
+ So `throw new ProviderError("…", { code: "UPSTREAM_SCHEMA_ERROR" })` is enough
699
+ for the health monitor to grade a drifted upstream `down`; without the
700
+ category it would be served as `source: "apifuse"` and graded unknown. An
701
+ explicit `category` always wins, an operation-declared rejection status
702
+ (409/410/422) still classifies as `upstream_rejected`, and retryability does
703
+ not follow the category: it stays the instance option, then the declaration,
704
+ then `false`. Every other code, including `AUTH_REQUIRED`, `INVALID_REQUEST`,
705
+ and your own codes, is classified as before (`provider_error` unless you set a
706
+ category).
707
+
683
708
  An unregistered non-validation `ProviderError` code returns HTTP 500 and emits
684
709
  the greppable `unregistered_provider_error_code` signal with the code in the
685
710
  structured failure log. A matching operation declaration, including one that
@@ -701,7 +726,9 @@ SDK-owned and keep their 502/504 mapping.
701
726
  refused a *platform-managed* service key. The caller holds no credential, so
702
727
  401 ("re-authenticate") asks for something the caller cannot do, and 502
703
728
  ("the upstream is sick") promises a recovery that will never arrive. It is a
704
- deployment/config defect, exactly like `MISSING_SECRET`.
729
+ deployment/config defect, exactly like `MISSING_SECRET`. The status is about
730
+ what the caller can do; the attribution is still the upstream's, since the
731
+ upstream is what refused the key, so the category is `upstream_auth`.
705
732
  - `UPSTREAM_SCHEMA_ERROR` is non-retryable: a retry returns the same payload
706
733
  the provider already could not normalize.
707
734
  - `INVALID_REQUEST` is the spelling for caller-side bad input. `INVALID_INPUT`,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.68
4
+
5
+ - Release candidate for main commit d630a344c18b2b192edfd09ef92bc30a482b6b8d.
6
+
7
+ ## 2.2.0-beta.67
8
+
9
+ - Release candidate for main commit 9ef92b86b293253597801e29764d76beb0a3f3b7.
10
+
3
11
  ## 2.2.0-beta.66
4
12
 
5
13
  - Release candidate for main commit e7f93a6c71e3bca3a680861c0968e22dbe5d1d40.
package/README.md CHANGED
@@ -309,10 +309,14 @@ apifuse bounty-check [path]
309
309
  apifuse perf providers/korea-air-quality --operation realtime --params '{"stationName":"종로구"}'
310
310
  ```
311
311
 
312
- `apifuse record` is for real upstream-backed operations that declare
313
- `upstream.baseUrl` and call the upstream through `ctx.http` or `ctx.stealth`. The
314
- generated local-only `ping` operation intentionally has no upstream and should
315
- be replaced before recording fixtures.
312
+ `apifuse record` is for real upstream-backed operations that call the upstream
313
+ through `ctx.http` or `ctx.stealth`. Absolute request URLs need no declaration; a
314
+ relative URL resolves the way `serve` resolves it — against the operation's
315
+ `upstream.baseUrl`, else the provider's first declared one (`ctx.stealth` then
316
+ falls back to `https://<allowedHosts[0]>`). A relative `ctx.http` URL with no base
317
+ is refused with the `upstream.baseUrl` to declare. The generated local-only `ping`
318
+ operation intentionally has no upstream and should be replaced before recording
319
+ fixtures.
316
320
 
317
321
  ## Bounty submission readiness
318
322
 
@@ -8,6 +8,7 @@ import { pathToFileURL } from "node:url";
8
8
 
9
9
  import {
10
10
  createBypassProviderCache,
11
+ createCredentialContext,
11
12
  createHttpClient,
12
13
  createInProcessProviderEngine,
13
14
  createOcrClientFromEnv,
@@ -27,7 +28,16 @@ import {
27
28
  TransportError,
28
29
  ValidationError,
29
30
  } from "../src/index.js";
31
+ import {
32
+ type LocalConnection,
33
+ LOCAL_CONNECTION_ID,
34
+ LOCAL_CONNECTION_SECRETS_ENV,
35
+ providerRequiresConnection,
36
+ resolveLocalConnection,
37
+ } from "../src/cli/local-connection.js";
30
38
  import { createCliResolverRuntime } from "../src/cli/resolver-runtime.js";
39
+ import { isAbsoluteUrl } from "../src/runtime/http.js";
40
+ import { getProviderBaseUrl, getProviderStealthBaseUrl } from "../src/runtime/provider.js";
31
41
  import { assertProcessEngineModeSupported } from "../src/runtime/engine-mode.js";
32
42
  import type { JsonValue } from "../src/contract-json.js";
33
43
  import {
@@ -71,6 +81,10 @@ type CliArgs = {
71
81
  operation?: string;
72
82
  params: string;
73
83
  sanitize: boolean;
84
+ /** Repeated `--credential key=value`, in order. */
85
+ credentials: string[];
86
+ /** `--no-credential`: run without a connection even when auth declares one. */
87
+ noCredential: boolean;
74
88
  };
75
89
 
76
90
  type ProviderRuntime = ProviderDefinition;
@@ -81,6 +95,11 @@ const HELP_TEXT = `Usage: apifuse record [path] --operation <operation> --params
81
95
 
82
96
  Calls a real upstream-backed operation through ctx.http or ctx.stealth and writes __fixtures__/raw.json.
83
97
 
98
+ Absolute request URLs need no declaration. A relative URL resolves the way serve resolves it: against
99
+ the operation's upstream.baseUrl, else the provider's first declared one (ctx.stealth then falls back
100
+ to https://<allowedHosts[0]>). A relative ctx.http URL with no base anywhere is refused and names
101
+ the upstream.baseUrl to declare.
102
+
84
103
  Streaming responses are recorded as evidence (status, selected headers, full-body SHA-256 and byte
85
104
  count, plus a ${STREAM_PREVIEW_BYTES}-byte base64 preview). Test replay is evidence-only: ctx.http.stream exposes the
86
105
  preview as its body and the original body_sha256/body_bytes as response metadata.
@@ -94,8 +113,18 @@ Options:
94
113
  --append preserve the existing fixture and append this capture
95
114
  --sanitize redact common token/header fields (default)
96
115
  --no-sanitize disable common-field redaction (sensitiveParams are always redacted)
116
+ --credential <k>=<v> credential the gateway would inject (repeatable)
117
+ --no-credential record without a credential even when auth declares one
97
118
  --help, -h show this help
98
119
 
120
+ Credentials:
121
+ A provider whose auth.mode is not "none" reads a credential the gateway
122
+ resolves and injects at invocation time -- for "platform-managed" that is the
123
+ platform-owned key, which is not a provider secret and is not in the
124
+ checkout. Supply it with --credential, or with
125
+ ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}'. The keys are the ones
126
+ the provider passes to ctx.credential.get(...).
127
+
99
128
  Example:
100
129
  apifuse record providers/korea-air-quality --operation realtime --params '{"stationName":"jongno"}'`;
101
130
 
@@ -111,10 +140,13 @@ export async function main() {
111
140
  const operation = provider.operations[operationName];
112
141
  const parsedParams = await parseParams(operation, args.params);
113
142
 
143
+ const connection = resolveRecordConnection(provider, args);
144
+
114
145
  capture = createCaptureContext(
115
146
  provider,
116
- resolveOperationBaseUrl(provider, operationName),
147
+ resolveRecordBaseUrls(provider, operationName),
117
148
  args.sanitize,
149
+ connection,
118
150
  );
119
151
 
120
152
  console.log(`[apifuse record] Calling ${operationName} on ${provider.id}...`);
@@ -123,6 +155,7 @@ export async function main() {
123
155
  try {
124
156
  result = await executeOperation(provider, operationName, capture.ctx, parsedParams);
125
157
  } catch (operationError) {
158
+ capture.throwIfRefused();
126
159
  let partial: unknown;
127
160
  try {
128
161
  partial = await capture.getCapturedRaw();
@@ -147,6 +180,9 @@ export async function main() {
147
180
  );
148
181
  if (telemetryLine) console.log(telemetryLine);
149
182
  }
183
+ // Checked on success too: an operation that caught the refusal and carried
184
+ // on would otherwise save a fixture missing the request it never made.
185
+ capture.throwIfRefused();
150
186
  const captured = await capture.getCapturedRaw();
151
187
 
152
188
  if (captured === undefined) {
@@ -181,6 +217,41 @@ export async function main() {
181
217
  }
182
218
  }
183
219
 
220
+ /**
221
+ * The connection this run attaches, or a hard failure when the provider needs
222
+ * one and none is configured.
223
+ *
224
+ * Failing here rather than at the handler is the whole point. Before this, a
225
+ * platform-managed provider recorded with `credential: { mode: "none" }`, so
226
+ * `ctx.credential.get(...)` returned `undefined` and the run died on the
227
+ * provider's own `MISSING_SECRET` — indistinguishable, to the author, from an
228
+ * upstream outage or a bad key. It reported as a capability the recorder
229
+ * lacked only if you already knew to look.
230
+ */
231
+ function resolveRecordConnection(
232
+ provider: ProviderRuntime,
233
+ args: CliArgs,
234
+ ): LocalConnection | undefined {
235
+ if (args.noCredential) {
236
+ if (providerRequiresConnection(provider)) {
237
+ console.warn(
238
+ `[apifuse record] --no-credential: recording ${provider.id} without the credential its auth.mode "${provider.auth?.mode}" declares.`,
239
+ );
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ const resolution = resolveLocalConnection(provider, {
245
+ overrides: args.credentials,
246
+ });
247
+ if (!resolution.ok)
248
+ throw new ProviderError(resolution.reason, {
249
+ code: "CREDENTIAL_UNAVAILABLE",
250
+ fix: "Supply the credential with --credential or the environment variable named above.",
251
+ });
252
+ return resolution.connection;
253
+ }
254
+
184
255
  function normalizeArgs(argv: string[]): string[] {
185
256
  return argv[0] === "record" ? argv.slice(1) : argv;
186
257
  }
@@ -191,6 +262,8 @@ function parseArgs(argv: string[]): CliArgs {
191
262
  let params = "{}";
192
263
  let sanitize = true;
193
264
  let append = false;
265
+ const credentials: string[] = [];
266
+ let noCredential = false;
194
267
 
195
268
  for (let index = 0; index < argv.length; index += 1) {
196
269
  const arg = argv[index];
@@ -247,6 +320,26 @@ function parseArgs(argv: string[]): CliArgs {
247
320
  continue;
248
321
  }
249
322
 
323
+ if (arg === "--credential") {
324
+ const value = argv[index + 1];
325
+ if (!value) {
326
+ throw new Error("Missing value for --credential.");
327
+ }
328
+ credentials.push(value);
329
+ index += 1;
330
+ continue;
331
+ }
332
+
333
+ if (arg.startsWith("--credential=")) {
334
+ credentials.push(arg.slice("--credential=".length));
335
+ continue;
336
+ }
337
+
338
+ if (arg === "--no-credential") {
339
+ noCredential = true;
340
+ continue;
341
+ }
342
+
250
343
  if (arg.startsWith("-")) {
251
344
  throw new Error(`Unknown option: ${arg}`);
252
345
  }
@@ -259,7 +352,15 @@ function parseArgs(argv: string[]): CliArgs {
259
352
  throw new Error(`Unexpected argument: ${arg}`);
260
353
  }
261
354
 
262
- return { append, providerPath, operation, params, sanitize };
355
+ return {
356
+ append,
357
+ providerPath,
358
+ operation,
359
+ params,
360
+ sanitize,
361
+ credentials,
362
+ noCredential,
363
+ };
263
364
  }
264
365
 
265
366
  /**
@@ -444,22 +545,58 @@ async function parseParams(
444
545
  return operation.input ? parseSchema(operation.input, parsed, "record.params") : parsed;
445
546
  }
446
547
 
447
- function resolveOperationBaseUrl(provider: ProviderRuntime, operationName: string): string {
448
- const baseUrl = provider.operations[operationName]?.upstream?.baseUrl;
449
- if (!baseUrl) {
450
- throw new Error(
451
- `Operation "${operationName}" for provider "${provider.id}" must define upstream.baseUrl.`,
452
- );
453
- }
548
+ /**
549
+ * The bases the recorder's `ctx.http` and `ctx.stealth` resolve relative
550
+ * request URLs against. An absent base means only absolute URLs resolve.
551
+ */
552
+ export type RecordBaseUrls = {
553
+ readonly http?: string;
554
+ readonly stealth?: string;
555
+ };
454
556
 
455
- return baseUrl;
557
+ /**
558
+ * `upstream.baseUrl` only resolves relative request URLs (and seeds the stealth
559
+ * cookie jar's default origin); the recorder uses it for nothing else, so an
560
+ * operation that requests absolute URLs records without one. The operation's
561
+ * own base wins; otherwise the recorder binds the bases `serve` binds, so a
562
+ * relative URL resolves in a recording exactly as it does in production.
563
+ */
564
+ export function resolveRecordBaseUrls(
565
+ provider: ProviderRuntime,
566
+ operationName: string,
567
+ ): RecordBaseUrls {
568
+ const declared = provider.operations[operationName]?.upstream?.baseUrl;
569
+ const own = typeof declared === "string" && declared.length > 0 ? declared : undefined;
570
+ const http = own ?? getProviderBaseUrl(provider);
571
+ const stealth = own ?? getProviderStealthBaseUrl(provider);
572
+ return { ...(http ? { http } : {}), ...(stealth ? { stealth } : {}) };
573
+ }
574
+
575
+ /** A request the recorder cannot resolve without a declaration; fails the run even if the operation catches it. */
576
+ class RecordRefusalError extends Error {
577
+ constructor(message: string) {
578
+ super(message);
579
+ this.name = "RecordRefusalError";
580
+ }
456
581
  }
457
582
 
458
583
  export function createCaptureContext(
459
584
  provider: ProviderRuntime,
460
- baseUrl: string,
585
+ baseUrls: RecordBaseUrls,
461
586
  sanitize: boolean,
587
+ connection?: LocalConnection,
462
588
  ) {
589
+ let refusal: RecordRefusalError | undefined;
590
+ const refuse = (message: string): never => {
591
+ refusal ??= new RecordRefusalError(message);
592
+ throw refusal;
593
+ };
594
+ const assertResolvableHttpUrl = (url: string) => {
595
+ if (baseUrls.http || isAbsoluteUrl(url)) return;
596
+ refuse(
597
+ `ctx.http cannot resolve the relative URL "${requestPathForFixture(url)}": no operation of provider "${provider.id}" declares upstream.baseUrl. Declare upstream.baseUrl on the recorded operation, or request an absolute URL.`,
598
+ );
599
+ };
463
600
  let nextCaptureOrder = 0;
464
601
  let nextStreamOrdinal = 0;
465
602
  let capturedRaw: JsonValue | undefined;
@@ -477,6 +614,25 @@ export function createCaptureContext(
477
614
  provider,
478
615
  cache,
479
616
  );
617
+ // The injected credential is the most sensitive value in the run, and the
618
+ // recorder prints provider errors and writes fixtures. Seed its VALUES into
619
+ // the redaction set before anything can execute, so an upstream error that
620
+ // echoes the key, and any fixture that captured it, are redacted by the same
621
+ // machinery that already covers declared query secrets.
622
+ //
623
+ // Values only, never the key names: `sensitiveParamNames` means "declared
624
+ // query-key position", and `redactFixture` deliberately skips common-field
625
+ // sanitization for a key that appears in it. Seeding `access_token` there
626
+ // would therefore write a *newly issued* upstream `access_token` — which
627
+ // does not equal the injected value — to raw.json in plaintext. A credential
628
+ // key that really is a query parameter is added to the name set by
629
+ // `captureSensitiveRequestValues` when the provider sends it, which is the
630
+ // only place that claim is true.
631
+ for (const value of Object.values(connection?.secrets ?? {})) {
632
+ if (value.length > 0) sensitiveParamValues.add(value);
633
+ }
634
+ registerSensitiveValues([...sensitiveParamValues]);
635
+
480
636
  const captureSensitiveParams = (url: string, options?: SensitiveRequestOptions) => {
481
637
  try {
482
638
  captureSensitiveRequestValues(url, options, sensitiveParamNames, sensitiveParamValues);
@@ -500,7 +656,8 @@ export function createCaptureContext(
500
656
  rawCaptures.push({ order, value: json });
501
657
  };
502
658
 
503
- const http = captureHttpClient(createHttpClient(baseUrl), {
659
+ const http = captureHttpClient(createHttpClient(baseUrls.http), {
660
+ assertResolvableUrl: assertResolvableHttpUrl,
504
661
  reserveOrder: reserveCaptureOrder,
505
662
  reserveStreamOrdinal: () => {
506
663
  nextStreamOrdinal += 1;
@@ -509,7 +666,7 @@ export function createCaptureContext(
509
666
  onSensitiveParams: captureSensitiveParams,
510
667
  onResponse: (order, response) => retainRawCapture(order, response.data),
511
668
  onStreamResponse: (order, ordinal, requestUrl, method, response) => {
512
- const resolvedRequestUrl = new URL(requestUrl, baseUrl).toString();
669
+ const resolvedRequestUrl = new URL(requestUrl, baseUrls.http).toString();
513
670
  const request = {
514
671
  ordinal,
515
672
  method,
@@ -532,36 +689,46 @@ export function createCaptureContext(
532
689
  capturedSse = {
533
690
  order,
534
691
  method,
535
- path: requestPathForFixture(new URL(requestUrl, baseUrl).toString()),
692
+ path: requestPathForFixture(new URL(requestUrl, baseUrls.http).toString()),
536
693
  };
537
694
  },
538
695
  });
539
- const stealth = proxyStealthClient(
540
- createStealthClient(baseUrl, {
541
- ...(provider.stealth ? { stealth: provider.stealth } : {}),
542
- }),
543
- captureSensitiveParams,
544
- (order, response) => retainRawCapture(order, normalizeCapturedStealthResponse(response)),
545
- reserveCaptureOrder,
546
- );
696
+ // Without a base `serve` binds no stealth transport either, so every call is refused.
697
+ const refuseStealth = (): never =>
698
+ refuse(
699
+ `ctx.stealth has no base URL: provider "${provider.id}" declares neither upstream.baseUrl nor allowedHosts. Declare allowedHosts on the provider (serve binds ctx.stealth to https://<allowedHosts[0]>) or upstream.baseUrl on the recorded operation.`,
700
+ );
701
+ const stealth: StealthClient = baseUrls.stealth
702
+ ? proxyStealthClient(
703
+ createStealthClient(baseUrls.stealth, {
704
+ ...(provider.stealth ? { stealth: provider.stealth } : {}),
705
+ }),
706
+ captureSensitiveParams,
707
+ (order, response) => retainRawCapture(order, normalizeCapturedStealthResponse(response)),
708
+ reserveCaptureOrder,
709
+ )
710
+ : { fetch: async () => refuseStealth(), createSession: refuseStealth };
547
711
 
548
712
  const providerEnvironment = createProviderEnvironment(
549
713
  process.env,
550
714
  provider.secrets?.map((secret) => secret.name) ?? [],
551
715
  );
552
716
  const env = { get: (key: string) => readDiagnosticEnv(key, providerEnvironment) };
553
- const credential = {
554
- mode: "none" as const,
555
- get: () => undefined,
556
- getAll: () => ({}),
557
- getAccessToken: () => undefined,
558
- getScopes: () => [],
559
- };
717
+ // Built exactly the way `serve` builds it from a gateway-supplied
718
+ // `request.connection`, so the provider takes its production branch rather
719
+ // than the always-undefined one a stub credential forces.
720
+ const credential = createCredentialContext({
721
+ ...(provider.credential?.keys ? { allowedKeys: provider.credential.keys } : {}),
722
+ ...(connection ? { mode: connection.mode, values: connection.secrets } : {}),
723
+ });
560
724
  const state = createMemoryProviderRuntimeState();
561
725
  const candidates: ProviderEngineBindingCandidates = {
562
726
  env,
563
727
  credential,
564
- request: { headers: {}, connectionId: "local-record" },
728
+ request: {
729
+ headers: {},
730
+ connectionId: connection ? LOCAL_CONNECTION_ID : "local-record",
731
+ },
565
732
  http,
566
733
  cache,
567
734
  state,
@@ -595,7 +762,10 @@ export function createCaptureContext(
595
762
  resolver,
596
763
  handle: createHandleContext({
597
764
  providerId: provider.id,
598
- request: { headers: {}, connectionId: "local-record" },
765
+ request: {
766
+ headers: {},
767
+ connectionId: connection ? LOCAL_CONNECTION_ID : "local-record",
768
+ },
599
769
  state,
600
770
  }),
601
771
  };
@@ -607,6 +777,9 @@ export function createCaptureContext(
607
777
  return {
608
778
  ctx,
609
779
  resolverTelemetry,
780
+ throwIfRefused: () => {
781
+ if (refusal) throw refusal;
782
+ },
610
783
  getCapturedRaw: async () => {
611
784
  if (streamCaptures.length === 0) {
612
785
  if (capturedSse) throw unsupportedSseCaptureError(capturedSse);
@@ -727,6 +900,8 @@ function snapshotRequestOptions<T extends SensitiveRequestOptions>(options: T):
727
900
  }
728
901
 
729
902
  type HttpCaptureCallbacks = {
903
+ /** Throws before the request when the recorder cannot resolve `url`. */
904
+ assertResolvableUrl(url: string): void;
730
905
  reserveOrder(): number;
731
906
  reserveStreamOrdinal(): number;
732
907
  onSensitiveParams(url: string, options?: RequestOptions): void;
@@ -751,6 +926,7 @@ function captureHttpClient(client: HttpClient, callbacks: HttpCaptureCallbacks):
751
926
  return response;
752
927
  };
753
928
  const captureRequestOptions = (method: PropertyKey, args: unknown[]) => {
929
+ callbacks.assertResolvableUrl(String(args[0]));
754
930
  const invocation = parseHttpRequestInvocation(method, args);
755
931
  const options = invocation ? requestOptionsFromHttpInvocation(invocation) : undefined;
756
932
  if (!invocation || !options) return;