-
Notifications
You must be signed in to change notification settings - Fork 113
Expand file tree
/
Copy pathhttp.ts
More file actions
1250 lines (1173 loc) · 48.9 KB
/
Copy pathhttp.ts
File metadata and controls
1250 lines (1173 loc) · 48.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { randomUUID } from 'node:crypto';
import * as v from 'valibot';
import type { ErrorCode } from './errors.js';
import { ApiError, InterruptError, RequestTimeoutError, TransportError } from './errors.js';
import { VERSION } from '../version.js';
import {
BATCH_RERUN_RESPONSE_SCHEMA,
BATCH_RUN_FRESH_RESPONSE_SCHEMA,
LIST_RUNS_RESPONSE_SCHEMA,
RERUN_RESPONSE_SCHEMA,
RUN_RESPONSE_SCHEMA,
TRIGGER_RUN_RESPONSE_SCHEMA,
} from './response-schemas.js';
import type {
TriggerRunBody,
TriggerRunResponse,
RunResponse,
RerunRequest,
RerunResponse,
BatchRerunRequest,
BatchRerunResponse,
BatchRunFreshRequest,
BatchRunFreshResponse,
ListRunsQuery,
ListRunsResponse,
CancelRunResponse,
} from './runs.types.js';
export type FetchImpl = typeof globalThis.fetch;
export type DebugEventKind = 'request' | 'response' | 'retry' | 'error';
export interface DebugEvent {
kind: DebugEventKind;
method: string;
url: string;
attempt: number;
status?: number;
errorCode?: ErrorCode | 'TRANSPORT';
durationMs?: number;
requestId: string;
delayMs?: number;
}
/**
* Default per-request timeout (120s). Comfortably covers every metadata/read
* request and every <=25s long-poll request (`?waitSeconds` is capped at 25
* by the polling layer), while still failing fast on a dead backend.
*
* The 15-minute test-execution ceiling is enforced separately by the `--timeout`
* / polling path (`poll.ts`) which supplies its own `AbortSignal` per iteration.
* That path is unaffected — each 25s long-poll request still falls well within
* this 120s per-request guard.
*
* Override via `--request-timeout <s>` (flag, in seconds) or
* `TESTSPRITE_REQUEST_TIMEOUT_MS` (env var, in milliseconds).
* Precedence: flag > env > default.
*/
export const REQUEST_TIMEOUT_DEFAULT_MS = 120_000;
/**
* Minimum accepted value: 1 second. Below this the timeout fires before a
* TLS handshake can complete on a healthy connection.
*/
export const REQUEST_TIMEOUT_MIN_MS = 1_000;
/**
* Maximum accepted value: 10 minutes. Values above this cap are clamped
* rather than rejected, so scripts that forward large numbers still work.
*/
export const REQUEST_TIMEOUT_MAX_MS = 600_000;
export interface HttpClientOptions {
baseUrl: string;
apiKey?: string;
fetchImpl?: FetchImpl;
sleep?: (ms: number) => Promise<void>;
random?: () => number;
onDebug?: (event: DebugEvent) => void;
/**
* Optional callback for user-relevant state transitions (verbose tier).
* Emits human-readable messages for HTTP retries, rate-limit backoff,
* and polling-mode switches without dumping the full debug JSON.
* Stays silent when absent; wired to stderr at `--verbose` level.
*/
onTransition?: (msg: string) => void;
/**
* Optional callback fired with the backend's supported-floor header
* (`X-TestSprite-CLI-Min-Version`) when present on a response. Fires on both
* success and error responses. The client forwards the value verbatim — it
* applies no policy itself; the caller (client-factory) decides whether to
* warn the user.
*/
onServerVersion?: (info: { minVersion?: string }) => void;
/**
* Per-request wall-clock timeout in milliseconds applied to every outgoing
* fetch. The signal fires independently of any caller-supplied signal — the
* request aborts on whichever fires first.
*
* Defaults to {@link REQUEST_TIMEOUT_DEFAULT_MS} (120 000 ms). Override
* via `--request-timeout <s>` flag or `TESTSPRITE_REQUEST_TIMEOUT_MS` env var.
*
* This timeout is intentionally NOT applied to the polling path's own
* long-poll `AbortSignal` (which is deadline-aware); each 25s long-poll
* request is well within the 120s default.
*/
requestTimeoutMs?: number;
/**
* Process-lifetime shutdown signal (DEV-331 piece 1). Composed into every
* outgoing fetch so an armed SIGINT/SIGTERM aborts an in-flight request
* (a `--wait` long-poll can sit inside a single fetch for minutes) instead
* of waiting out its window. Aborts with an `InterruptError` reason, which
* is classified before the timeout-vs-caller logic and is never retried or
* re-wrapped as `TransportError`. Defaults off; production wiring passes
* `globalShutdown.signal` via the client factory.
*/
shutdownSignal?: AbortSignal;
/**
* Upper bound (bytes) on a successful JSON response body the client will
* buffer before parsing. A response whose declared `Content-Length` — or
* actual streamed size — exceeds this fails fast with a typed
* `PAYLOAD_TOO_LARGE` error instead of growing the heap without limit.
* Defaults to {@link MAX_RESPONSE_BYTES_DEFAULT} (64 MiB).
*/
maxResponseBytes?: number;
}
export interface RequestOptions<T = unknown> {
query?: Record<string, string | number | boolean | undefined>;
signal?: AbortSignal;
requestId?: string;
/**
* Optional valibot schema for the parsed 2xx response body (issue #102).
*
* When present, `requestWithMeta` runs `v.safeParse` on the OK-path JSON:
* success returns the parsed output (unknown extra keys preserved via
* `looseObject`); failure throws an INTERNAL `ApiError` envelope naming the
* request path and the first {@link MAX_SCHEMA_ISSUES_IN_DETAILS} mismatched
* field paths (never the body itself). When absent, behavior is unchanged:
* the body is returned via the historical blind `as T` cast.
*
* Wired by the typed run helpers only (`triggerRun`, `triggerRunWithMeta`,
* `triggerRerun`, `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`,
* `listTestRuns`); generic `get`/`post`/... callers stay opt-in.
* sourceRef: response-schemas.ts.
*/
schema?: v.GenericSchema<unknown, T>;
/**
* Optional JSON body for non-GET requests. Serialized with
* `JSON.stringify`; `Content-Type: application/json` is auto-attached
* when present.
*/
body?: unknown;
/**
* Per-request header overrides merged on top of the defaults
* (`x-api-key`, `x-request-id`, `accept`, `user-agent`). Used for
* mutation routes that need `Idempotency-Key` / `If-Match`.
*/
headers?: Record<string, string>;
/**
* Whether to retry on 409 CONFLICT.
*
* The shared retry policy retries CONFLICT once by default (read paths
* where 409 = mid-mutation snapshot). Set to `false` for write paths
* where 409 is a persistent condition (e.g. POST /tests/{testId}/runs
* when another run is already in flight) — retrying there would enqueue
* a second run once the first finishes.
*
* Defaults to `true` to preserve the original M2 read-path behavior.
*/
retryOnConflict?: boolean;
/**
* Whether the HTTP layer should retry internally on 429 RATE_LIMITED.
*
* Set to `false` for the batch-run trigger path where the outer
* `runBatchRun` loop is the single owner of rate-limit handling —
* keeping the HTTP layer from adding up to 3 extra retries per outer
* attempt, which would multiply trigger POSTs per spec (e.g. 50×3 = 150/min)
* instead of staying within the client throttle's 50/min.
*
* Defaults to `true` to preserve backward-compatible behavior for all
* other callers.
*/
retryOnRateLimit?: boolean;
}
const RETRY_BASE_MS = 250;
const RETRY_JITTER_MS = 250;
const RETRY_MAX_DELAY_MS = 4000;
const MAX_ATTEMPTS_TRANSPORT = 4;
const MAX_ATTEMPTS_UNAVAILABLE = 4;
const MAX_ATTEMPTS_RATE_LIMITED = 3;
const MAX_ATTEMPTS_CONFLICT = 2;
const MAX_ATTEMPTS_INTERNAL = 2;
// Cap server-directed RATE_LIMITED waits so a hostile or misconfigured
// `Retry-After` (e.g. 86400) can't hang the CLI inside the retry sleep.
const MAX_RATE_LIMITED_DELAY_MS = 60_000;
const CONFLICT_DELAY_MS = 1000;
const INTERNAL_DELAY_MS = 500;
// Upper bound on the size of a successful JSON response body the client will
// buffer into memory. `response.json()` reads the ENTIRE body before parsing,
// with no limit, so a large `test result --history` page or a run with a long
// `steps[]` array (getRun `includeSteps`) would grow the heap in proportion to
// the payload. 64 MiB sits far above any legitimate metadata/history/steps
// response yet still bounds a pathological — or hostile — one. Override per
// client via `HttpClientOptions.maxResponseBytes`.
const MAX_RESPONSE_BYTES_DEFAULT = 64 * 1024 * 1024;
// Cap on how many valibot issues a shape-mismatch INTERNAL envelope carries in
// `details.issues` (path + message each). Keeps the envelope readable and
// guarantees the response body itself is never echoed back to the operator.
const MAX_SCHEMA_ISSUES_IN_DETAILS = 3;
/**
* Result of a successful HTTP request, including the parsed body and the
* `x-request-id` that was sent (useful for surfacing in happy-path output).
*/
export interface RequestResult<T> {
body: T;
requestId: string;
status: number;
}
export class HttpClient {
private readonly baseUrl: string;
private readonly apiKey?: string;
private readonly fetchImpl: FetchImpl;
private readonly sleep: (ms: number) => Promise<void>;
private readonly random: () => number;
private readonly onDebug?: (event: DebugEvent) => void;
private readonly onTransition?: (msg: string) => void;
private readonly onServerVersion?: (info: { minVersion?: string }) => void;
private readonly requestTimeoutMs: number;
private readonly shutdownSignal?: AbortSignal;
private readonly maxResponseBytes: number;
constructor(options: HttpClientOptions) {
this.baseUrl = trimTrailingSlash(options.baseUrl);
this.apiKey = options.apiKey;
this.shutdownSignal = options.shutdownSignal;
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
this.sleep = options.sleep ?? defaultSleep;
this.random = options.random ?? Math.random;
this.onDebug = options.onDebug;
this.onTransition = options.onTransition;
this.onServerVersion = options.onServerVersion;
this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_DEFAULT_MS;
this.maxResponseBytes = options.maxResponseBytes ?? MAX_RESPONSE_BYTES_DEFAULT;
}
/**
* Read the backend's supported-floor header off a response and forward it to
* `onServerVersion` when present. Never throws — a bad header must not break
* the request. Called on every response (success or error) so the advisory
* covers all paths. (The backend advertises only the floor; "latest" is
* resolved client-side via the npm update-notice.)
*/
private captureServerVersion(response: Response): void {
if (!this.onServerVersion) return;
try {
const minVersion = response.headers.get('x-testsprite-cli-min-version') ?? undefined;
if (minVersion !== undefined) {
this.onServerVersion({ minVersion });
}
} catch {
// Header parsing is best-effort; never let it affect the request.
}
}
async get<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
return this.requestWithMeta<T>('GET', path, options).then(r => r.body);
}
async post<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
return this.requestWithMeta<T>('POST', path, options).then(r => r.body);
}
async put<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
return this.requestWithMeta<T>('PUT', path, options).then(r => r.body);
}
async patch<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
return this.requestWithMeta<T>('PATCH', path, options).then(r => r.body);
}
async delete<T>(path: string, options: RequestOptions<T> = {}): Promise<T> {
return this.requestWithMeta<T>('DELETE', path, options).then(r => r.body);
}
/**
* Like `get` / `post` / etc. but returns the full `RequestResult` including
* `requestId` and `status`, so callers can surface the requestId in
* happy-path output (dogfood item 1).
*/
async getWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
return this.requestWithMeta<T>('GET', path, options);
}
async postWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
return this.requestWithMeta<T>('POST', path, options);
}
async putWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
return this.requestWithMeta<T>('PUT', path, options);
}
async patchWithMeta<T>(path: string, options: RequestOptions<T> = {}): Promise<RequestResult<T>> {
return this.requestWithMeta<T>('PATCH', path, options);
}
async deleteWithMeta<T>(
path: string,
options: RequestOptions<T> = {},
): Promise<RequestResult<T>> {
return this.requestWithMeta<T>('DELETE', path, options);
}
/**
* POST /api/cli/v1/tests/{testId}/runs
* Trigger a run and return the queued-run envelope.
* The caller must supply an `idempotencyKey` which is sent as the
* `Idempotency-Key` header per M3.2 piece-1 §2 contract.
*/
async triggerRun(
testId: string,
body: TriggerRunBody,
options: { idempotencyKey: string; signal?: AbortSignal },
): Promise<TriggerRunResponse> {
return this.postWithMeta<TriggerRunResponse>(`/tests/${encodeURIComponent(testId)}/runs`, {
body,
headers: { 'idempotency-key': options.idempotencyKey },
signal: options.signal,
schema: TRIGGER_RUN_RESPONSE_SCHEMA,
// 409 on POST /runs means "another run is already in flight" — a
// persistent condition, not a transient snapshot conflict. Retrying
// would enqueue a second run once the first finishes.
retryOnConflict: false,
}).then(r => r.body);
}
/**
* Like `triggerRun` but returns the full `RequestResult` so the caller
* can surface the `requestId` in happy-path CLI output.
*
* `retryOnRateLimit` defaults to `true` so single `test run` and
* `test create --run` retain the standard HTTP-layer 429 retry budget.
*
* Pass `retryOnRateLimit: false` ONLY at the batch fan-out call site
* (`runBatchRun` / `triggerOne`) where the outer loop is the single owner
* of rate-limit handling — preventing the HTTP layer from adding up to 3
* extra retries per outer attempt, which would multiply POSTs per spec (e.g. 50×3 = 150/min).
*/
async triggerRunWithMeta(
testId: string,
body: TriggerRunBody,
options: { idempotencyKey: string; signal?: AbortSignal; retryOnRateLimit?: boolean },
): Promise<RequestResult<TriggerRunResponse>> {
return this.postWithMeta<TriggerRunResponse>(`/tests/${encodeURIComponent(testId)}/runs`, {
body,
headers: { 'idempotency-key': options.idempotencyKey },
signal: options.signal,
schema: TRIGGER_RUN_RESPONSE_SCHEMA,
retryOnConflict: false,
// Default true: single `test run` / `test create --run` retain 429 retry.
// Batch call site passes false to keep outer-loop as sole rate-limit owner.
retryOnRateLimit: options.retryOnRateLimit ?? true,
});
}
/**
* POST /api/cli/v1/tests/{testId}/runs/rerun
* Trigger a rerun (replay) for a single test. FE: verbatim script replay (no credits).
* BE: dependency-closure re-run. Returns `runId` + optional `closure` (BE).
*
* `retryOnConflict: false` — 409 on rerun means the test is already in-flight,
* a persistent condition. Retrying would race against the running test.
*/
async triggerRerun(
testId: string,
body: RerunRequest,
options: { idempotencyKey: string; signal?: AbortSignal },
): Promise<RerunResponse> {
return this.postWithMeta<RerunResponse>(`/tests/${encodeURIComponent(testId)}/runs/rerun`, {
body,
headers: { 'idempotency-key': options.idempotencyKey },
signal: options.signal,
schema: RERUN_RESPONSE_SCHEMA,
retryOnConflict: false,
}).then(r => r.body);
}
/**
* POST /api/cli/v1/tests/batch/rerun
* Trigger a batch rerun across multiple tests (mixed FE/BE allowed).
* BE closure is deduped server-side per project.
*
* `retryOnConflict: false` — 409 on batch rerun is persistent ("in flight").
*/
async triggerBatchRerun(
body: BatchRerunRequest,
options: { idempotencyKey: string; signal?: AbortSignal },
): Promise<BatchRerunResponse> {
return this.postWithMeta<BatchRerunResponse>('/tests/batch/rerun', {
body,
headers: { 'idempotency-key': options.idempotencyKey },
signal: options.signal,
schema: BATCH_RERUN_RESPONSE_SCHEMA,
retryOnConflict: false,
}).then(r => r.body);
}
/**
* POST /api/cli/v1/tests/batch/run
* Trigger a fresh wave-ordered batch run across all (or a subset of) BE tests
* in a project. FE tests in the set are skipped server-side (advisory).
* `testIds` absent / empty → run ALL BE tests in the project.
*
* `retryOnConflict: false` — 409 on batch run means a run is already in flight.
*/
async triggerBatchRunFresh(
body: BatchRunFreshRequest,
options: { idempotencyKey: string; signal?: AbortSignal },
): Promise<BatchRunFreshResponse> {
return this.postWithMeta<BatchRunFreshResponse>('/tests/batch/run', {
body,
headers: { 'idempotency-key': options.idempotencyKey },
signal: options.signal,
schema: BATCH_RUN_FRESH_RESPONSE_SCHEMA,
retryOnConflict: false,
}).then(r => r.body);
}
/**
* GET /api/cli/v1/tests/{testId}/runs
* List a test's prior run history, newest-first.
*
* Limit-before-filter caveat: a `source`-filtered page may return fewer
* than `pageSize` rows while still yielding a non-null `nextCursor`.
* That means "none in THIS window", not end-of-history.
*/
async listTestRuns(testId: string, query: ListRunsQuery): Promise<ListRunsResponse> {
const q: Record<string, string | number | undefined> = {};
if (query.cursor !== undefined) q.cursor = query.cursor;
if (query.pageSize !== undefined) q.pageSize = query.pageSize;
if (query.source !== undefined) q.source = query.source;
if (query.since !== undefined) q.since = query.since;
return this.get<ListRunsResponse>(`/tests/${encodeURIComponent(testId)}/runs`, {
query: q,
schema: LIST_RUNS_RESPONSE_SCHEMA,
});
}
/**
* GET /api/cli/v1/runs/{runId}
* Fetch the current state of a run. When `waitSeconds` is provided
* (1–25) the server performs a bounded long-poll and returns when the
* run is terminal or when `waitSeconds` elapses, whichever comes
* first. On `400 VALIDATION_ERROR` (server doesn't support
* `waitSeconds`) the caller should retry without the param and switch
* to client-side backoff — see `src/lib/poll.ts`.
*
* When `includeSteps` is `true`, the server appends the full ordered
* `steps[]` array (M3.4 piece-4). Default (absent/false) is byte-
* identical to the M3.3 summary shape — the polling path is unaffected.
*/
async getRun(
runId: string,
options?: { waitSeconds?: number; includeSteps?: boolean; signal?: AbortSignal },
): Promise<RunResponse> {
const query: Record<string, number | boolean | undefined> = {};
if (options?.waitSeconds !== undefined) {
query.waitSeconds = options.waitSeconds;
}
if (options?.includeSteps === true) {
query.includeSteps = true;
}
return this.get<RunResponse>(`/runs/${encodeURIComponent(runId)}`, {
query: Object.keys(query).length > 0 ? query : undefined,
signal: options?.signal,
schema: RUN_RESPONSE_SCHEMA,
});
}
/**
* POST /api/cli/v1/runs/{runId}/cancel
* User-initiated cancel of a queued/running run (DEV-331 piece 3). No
* body, no `Idempotency-Key` — the endpoint is naturally idempotent (D10):
* re-cancel → 200 `alreadyCancelled: true`; already-terminal (passed/
* failed/blocked) → 409 CONFLICT; unknown/cross-tenant runId → 404.
*
* `retryOnConflict: false` — same rationale as `triggerRun`: a 409 here is
* a truthful terminal answer ("already finished"), not a transient
* snapshot conflict to paper over with a retry.
*/
async cancelRun(runId: string, options?: { signal?: AbortSignal }): Promise<CancelRunResponse> {
return this.post<CancelRunResponse>(`/runs/${encodeURIComponent(runId)}/cancel`, {
signal: options?.signal,
retryOnConflict: false,
});
}
/**
* Classify an error thrown while issuing OR reading a request. When it is an
* abort/timeout and our per-request timeout signal fired (and the caller had
* not already aborted), surface a clear RequestTimeoutError; when it is a
* caller-supplied abort (SIGINT, poll-iteration deadline), rethrow it
* unmodified. Returns normally when `err` is not an abort, so the caller can
* continue its own error handling (transport retry / envelope parse).
*/
private rethrowIfAbort(
err: unknown,
timeoutSignal: AbortSignal,
callerSignal: AbortSignal | undefined,
requestId: string,
effectiveSignal: AbortSignal = timeoutSignal,
): void {
// A user interrupt (DEV-331) outranks every other classification: once the
// shutdown signal fired, whatever error surfaced from the aborted fetch or
// body read is the interrupt. Throw its InterruptError reason so the wait
// paths can render the honest detach UX — never a RequestTimeoutError, and
// never fall through to the transport retry loop.
if (this.shutdownSignal?.aborted) {
throw this.shutdownSignal.reason;
}
if (isAbortError(err) || isTimeoutError(err)) {
const timeoutWon =
timeoutSignal.aborted &&
(callerSignal == null ||
!callerSignal.aborted ||
effectiveSignal.reason === timeoutSignal.reason);
if (timeoutWon) {
throw new RequestTimeoutError(this.requestTimeoutMs, requestId);
}
throw err;
}
}
async requestWithMeta<T>(
method: string,
path: string,
options: RequestOptions<T> = {},
): Promise<RequestResult<T>> {
if (!this.apiKey) throw ApiError.authRequired();
const url = buildUrl(this.baseUrl, path, options.query);
const requestId = options.requestId ?? newRequestId();
const allowTransportRetry = canRetryTransport(method, options);
let attempt = 0;
while (true) {
// Bail before issuing (or re-issuing after a retry sleep) a request the
// user has already interrupted; the composed signal below would abort it
// immediately anyway, this just skips the wasted dispatch.
if (this.shutdownSignal?.aborted) throw this.shutdownSignal.reason;
attempt += 1;
this.debug({ kind: 'request', method, url, attempt, requestId });
const startedAt = Date.now();
let response: Response;
// Compose the per-request timeout signal with any caller-supplied signal.
// The fetch aborts on whichever fires first. This ensures every one-shot
// request (test create/update/delete/list/get, auth whoami, code put/get,
// plan put) has a client-side deadline even when the caller supplies no
// signal. The polling path supplies its own deadline-aware signal per
// iteration — this timeout (120s default) is safely larger than any single
// long-poll window (<=25s via ?waitSeconds), so it never bites polling.
const requestTimeout = createRequestTimeout(this.requestTimeoutMs);
const timeoutSignal = requestTimeout.signal;
const composedSignals = [timeoutSignal];
if (options.signal != null) composedSignals.push(options.signal);
// Shutdown composition (DEV-331): an armed SIGINT/SIGTERM aborts the
// in-flight fetch immediately (reason: InterruptError) instead of
// letting a long-poll drain its window before the interrupt surfaces.
if (this.shutdownSignal != null) composedSignals.push(this.shutdownSignal);
const effectiveSignal =
composedSignals.length > 1 ? AbortSignal.any(composedSignals) : timeoutSignal;
try {
try {
response = await this.fetchImpl(url, {
method,
headers: this.buildHeaders(requestId, options),
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
signal: effectiveSignal,
});
} catch (err) {
// Distinguish a client-side request timeout from a caller-supplied abort.
//
// The request-timeout controller aborts with an Error/DOMException whose
// `name === 'TimeoutError'` (not 'AbortError') when the signal fires.
// A caller-supplied abort sets `name === 'AbortError'`.
// We treat both abort variants together: if the timeout signal fired and
// the caller hadn't already aborted, surface a clear RequestTimeoutError.
// A user interrupt is never retried and never re-wrapped as a
// TransportError (same passthrough discipline as RequestTimeoutError).
// The instanceof check matters even without `this.shutdownSignal`:
// the polling path composes the shutdown signal into its per-
// iteration caller signal, so the fetch can reject with the
// InterruptError reason directly (DEV-331).
if (err instanceof InterruptError) throw err;
// A timeout/abort during the fetch itself: classify it (RequestTimeoutError
// when our deadline fired; otherwise rethrow the caller's abort unmodified).
this.rethrowIfAbort(err, timeoutSignal, options.signal, requestId, effectiveSignal);
// If a RequestTimeoutError already propagated from somewhere (e.g. from a
// nested call or from a test-injected fetchImpl), pass it through unchanged
// rather than re-wrapping it as a TransportError.
if (err instanceof RequestTimeoutError) throw err;
const message = err instanceof Error ? err.message : String(err);
this.debug({
kind: 'error',
method,
url,
attempt,
requestId,
errorCode: 'TRANSPORT',
durationMs: Date.now() - startedAt,
});
const decision = allowTransportRetry
? transportRetryDecision(attempt, this.random)
: { retry: false, delayMs: 0 };
if (!decision.retry) throw new TransportError(message, requestId);
this.transition(
`Network error on ${shortPath(path)} — retrying in ${Math.round(decision.delayMs / 1000)}s (attempt ${attempt})`,
);
this.debug({
kind: 'retry',
method,
url,
attempt,
requestId,
errorCode: 'TRANSPORT',
delayMs: decision.delayMs,
});
requestTimeout.clear();
await this.sleepBeforeRetry(decision.delayMs);
continue;
}
const durationMs = Date.now() - startedAt;
// Surface the backend version-compatibility headers on every response
// (success or error) before branching, so the caller's advisory covers
// all paths.
this.captureServerVersion(response);
if (response.ok) {
this.debug({
kind: 'response',
method,
url,
attempt,
status: response.status,
requestId,
durationMs,
});
let raw: unknown;
try {
// Bounded read, then parse — assigning to `raw` rather than returning
// here keeps the schema validation below on the success path.
const text = await readBoundedText(response, this.maxResponseBytes, requestId);
raw = JSON.parse(text);
} catch (err) {
// Interrupt passthrough (see the fetch catch above).
if (err instanceof InterruptError) throw err;
// A bounded-read rejection (PAYLOAD_TOO_LARGE) — or any typed ApiError —
// is a real, actionable outcome; surface it unchanged rather than
// masking it as a malformed-body error below.
if (err instanceof ApiError) throw err;
// A timeout/abort can fire mid-body-read (headers received, stream stalls).
this.rethrowIfAbort(err, timeoutSignal, options.signal, requestId, effectiveSignal);
// Otherwise the successful response body was not valid JSON — a
// misconfigured endpoint, a proxy / captive-portal / login page that
// returns HTML with a 200 status, or an empty body. Surface a typed
// error carrying the requestId instead of letting the raw SyntaxError
// escape to index.ts, where it would print a bare `{"error":"..."}`
// and break the --output json envelope contract.
throw malformedResponseError(response, requestId, err);
}
if (options.schema !== undefined) {
const parsed = v.safeParse(options.schema, raw);
if (!parsed.success) {
const issues = parsed.issues.slice(0, MAX_SCHEMA_ISSUES_IN_DETAILS).map(issue => ({
path: v.getDotPath(issue) ?? '(root)',
message: issue.message,
}));
// Shape drift is a server-side contract break: surface a typed
// INTERNAL envelope (requestId + the first mismatched paths,
// never the body) instead of letting a blind cast poison
// downstream output with undefined fields or a raw TypeError.
throw ApiError.fromEnvelope(
{
error: {
code: 'INTERNAL',
message: `Response shape mismatch from ${shortPath(path)}.`,
nextAction:
'Retry; if it persists, report this requestId (the server returned an unexpected shape).',
requestId,
details: { issues },
},
},
response.status,
);
}
return { body: parsed.output as T, requestId, status: response.status };
}
return { body: raw as T, requestId, status: response.status };
}
let rawBody: unknown;
try {
rawBody = await safeReadJson(response);
} catch (err) {
// Interrupt passthrough (see the fetch catch above).
if (err instanceof InterruptError) throw err;
// safeReadJson rethrows aborts/timeouts (it swallows only non-abort parse
// errors), so a timeout fired mid-body-read on a non-OK response lands here.
this.rethrowIfAbort(err, timeoutSignal, options.signal, requestId, effectiveSignal);
throw err;
}
// Edge proxies / load balancers return 408/502/504 without our error
// envelope on transient outages. These are transport-level retries,
// not facade errors — fold them in here so we get the bounded backoff
// budget instead of a single INTERNAL bail.
if (rawBody === null && isTransportEdgeStatus(response.status)) {
this.debug({
kind: 'error',
method,
url,
attempt,
status: response.status,
requestId,
errorCode: 'TRANSPORT',
durationMs,
});
const decision = allowTransportRetry
? transportRetryDecision(attempt, this.random)
: { retry: false, delayMs: 0 };
if (!decision.retry) {
throw new TransportError(`HTTP ${response.status} from ${url}`, requestId);
}
this.transition(
`HTTP ${response.status} from ${shortPath(path)} — transport error, retrying in ${Math.round(decision.delayMs / 1000)}s (attempt ${attempt})`,
);
this.debug({
kind: 'retry',
method,
url,
attempt,
requestId,
errorCode: 'TRANSPORT',
delayMs: decision.delayMs,
});
requestTimeout.clear();
await this.sleepBeforeRetry(decision.delayMs);
continue;
}
const retryAfterSec = parseRetryAfter(response.headers.get('retry-after'));
// Clamp server-directed Retry-After to [1s, 300s] and surface on the
// thrown error so outer callers (e.g. runBatchRun outer retry loop)
// can honor it without re-reading the now-consumed HTTP response.
const retryAfterMsForError =
retryAfterSec !== undefined
? Math.min(Math.max(retryAfterSec, 1), 300) * 1000
: undefined;
const apiError = ApiError.fromEnvelope(
rawBody,
response.status,
retryAfterMsForError,
// Lets synthesized nextAction text (e.g. INSUFFICIENT_CREDITS billing
// links) resolve the environment-correct portal domain.
this.baseUrl,
);
this.debug({
kind: 'error',
method,
url,
attempt,
status: response.status,
errorCode: apiError.code,
requestId,
durationMs,
});
const retryOnConflict = options.retryOnConflict !== false;
const retryOnRateLimit = options.retryOnRateLimit !== false;
const decision = apiRetryDecision(
apiError.code,
attempt,
retryAfterSec,
this.random,
retryOnConflict,
retryOnRateLimit,
);
if (!decision.retry) throw apiError;
const delaySec = Math.round(decision.delayMs / 1000);
if (apiError.code === 'RATE_LIMITED') {
this.transition(
`Rate limited (HTTP 429) — waiting ${delaySec}s before retry (attempt ${attempt})`,
);
} else if (apiError.code === 'INTERNAL') {
this.transition(
`Server error (HTTP 5xx, requestId: ${requestId}) — retrying in ${delaySec}s (attempt ${attempt})`,
);
} else if (apiError.code === 'UNAVAILABLE') {
this.transition(
`Service unavailable (HTTP 503) — retrying in ${delaySec}s (attempt ${attempt})`,
);
}
this.debug({
kind: 'retry',
method,
url,
attempt,
requestId,
errorCode: apiError.code,
delayMs: decision.delayMs,
});
requestTimeout.clear();
await this.sleepBeforeRetry(decision.delayMs);
} finally {
requestTimeout.clear();
}
}
}
private buildHeaders(requestId: string, options: RequestOptions): Record<string, string> {
const headers: Record<string, string> = {
'x-request-id': requestId,
accept: 'application/json',
'user-agent': `testsprite-cli/${VERSION}`,
};
// The CLI v1 facade authenticates via `x-api-key`.
// (securitySchemes.ApiKeyAuth). Sending only Authorization Bearer would be
// treated as a missing key by the backend.
if (this.apiKey) headers['x-api-key'] = this.apiKey;
if (options.body !== undefined) headers['content-type'] = 'application/json';
// Mutation-route headers (Idempotency-Key, If-Match) get merged last
// so callers cannot accidentally strip the auth or request-id keys.
if (options.headers) {
for (const [name, value] of Object.entries(options.headers)) {
headers[name.toLowerCase()] = value;
}
}
return headers;
}
/**
* Retry-delay sleep that bails the moment the shutdown signal fires
* (DEV-331, codex finding 1): a RATE_LIMITED `Retry-After: 60` or a
* transport backoff must not delay the honest-detach exit by up to a
* minute — reject with the InterruptError reason immediately. Mirrors
* poll.ts::sleepUnlessInterrupted.
*/
private sleepBeforeRetry(ms: number): Promise<void> {
const signal = this.shutdownSignal;
if (signal == null) return this.sleep(ms);
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const onAbort = (): void => reject(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
this.sleep(ms).then(
() => {
signal.removeEventListener('abort', onAbort);
resolve();
},
err => {
signal.removeEventListener('abort', onAbort);
reject(err instanceof Error ? err : new Error(String(err)));
},
);
});
}
private debug(event: DebugEvent): void {
if (this.onDebug) this.onDebug(event);
}
private transition(msg: string): void {
if (this.onTransition) this.onTransition(msg);
}
/**
* Legacy alias kept for backward-compat with test files that call
* `client.request(...)` directly. New callers should use
* `requestWithMeta` or the typed helpers (`get`, `post`, etc.).
*/
async request<T>(method: string, path: string, options: RequestOptions<T> = {}): Promise<T> {
return this.requestWithMeta<T>(method, path, options).then(r => r.body);
}
}
function trimTrailingSlash(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url;
}
/** Extracts the path component from a full URL for concise log messages. */
function shortPath(pathOrUrl: string): string {
try {
return new URL(pathOrUrl).pathname;
} catch {
return pathOrUrl;
}
}
export function buildUrl(
baseUrl: string,
path: string,
query?: Record<string, string | number | boolean | undefined>,
): string {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${trimTrailingSlash(baseUrl)}${normalizedPath}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function newRequestId(): string {
return `cli_${randomUUID()}`;
}
interface RequestTimeoutHandle {
signal: AbortSignal;
clear: () => void;
}
function createRequestTimeout(timeoutMs: number): RequestTimeoutHandle {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(makeTimeoutReason());
}, timeoutMs);
unrefTimer(timer);
return {
signal: controller.signal,
clear: () => clearTimeout(timer),
};
}
function makeTimeoutReason(): Error {
if (typeof DOMException !== 'undefined') {
return new DOMException('The operation timed out.', 'TimeoutError');
}
const err = new Error('The operation timed out.');
err.name = 'TimeoutError';
return err;
}
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
if (typeof timer !== 'object' || timer === null || !('unref' in timer)) return;
const unref = (timer as { unref?: () => void }).unref;
if (typeof unref === 'function') unref.call(timer);
}
async function safeReadJson(response: Response): Promise<unknown> {
try {
return await response.json();
} catch (err) {
// Don't swallow client-side aborts/timeouts as a null body — the caller must
// be able to classify a mid-body-read timeout as a RequestTimeoutError.
if (isAbortError(err) || isTimeoutError(err)) throw err;
return null;
}
}
/**
* Build a typed error for a successful response whose body could not be parsed
* as JSON.
*
* The CLI expects every API response to be a JSON envelope. When a `200 OK`
* carries a non-JSON body — a misconfigured endpoint, a proxy / captive-portal
* / SSO login page returning HTML with a success status, or an empty body —
* `response.json()` throws a raw `SyntaxError`. Left unhandled it escapes to
* the top-level handler in `index.ts`, which prints a bare
* `{"error":"<parse message>"}` under `--output json` (breaking the
* typed-envelope contract every other error honors) and gives the operator no
* actionable context.
*
* This wraps it in a typed `INTERNAL` `ApiError` (exit 1, unchanged) that
* carries the `requestId`, names the likely cause, and points the operator at
* their endpoint configuration. `details` includes the HTTP status, the
* response `content-type` (when present), and the underlying parse message.
*/
export function malformedResponseError(
response: Response,
requestId: string,
cause: unknown,
): ApiError {
const contentType = response.headers.get('content-type') ?? undefined;
const parseError = cause instanceof Error ? cause.message : String(cause);
const contentTypeNote = contentType ? ` (content-type: ${contentType})` : '';
return new ApiError(
{
code: 'INTERNAL',