@oxy.so/core 1.6.0 → 1.7.0

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.
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Cancellation and timeout regressions.
3
+ *
4
+ * `HttpService` used to build its `AbortController` INSIDE the function
5
+ * `retryAsync` re-invokes, and link the caller's signal to each new controller
6
+ * with a bare `addEventListener`. Since `abort` is a once-only event, a signal
7
+ * that had already fired could not abort the controller built for attempt 2 —
8
+ * so a request the caller had CANCELLED was re-issued for real. A caller abort
9
+ * also reached the retry predicate as an `AbortError` carrying `status: 0`,
10
+ * which is not 4xx, so the predicate read it as a transient failure worth
11
+ * another try. With the 5s default timeout and 3 retries that is ~28s of work
12
+ * after the caller stopped waiting, inside a request queue ten slots deep.
13
+ *
14
+ * Every test here pins one half of "a cancellation is an instruction, not a
15
+ * failure": nothing retries it, nothing queues it, and nothing leaks after it.
16
+ */
17
+ import { HttpService } from '../HttpService';
18
+ import { ErrorCodes } from '../utils/errorUtils';
19
+
20
+ function jsonResponse(data: unknown, status = 200): Response {
21
+ return new Response(JSON.stringify({ data }), {
22
+ status,
23
+ headers: { 'content-type': 'application/json' },
24
+ });
25
+ }
26
+
27
+ /**
28
+ * A fetch that never responds, but DOES honour its abort signal — rejecting
29
+ * with an `AbortError` exactly as the real one does.
30
+ *
31
+ * Honouring the signal is not harness politeness, it is the whole point: the
32
+ * behaviour under test is what happens after an abort, so a fake that ignores
33
+ * the signal would hang instead of exercising it.
34
+ */
35
+ function hangingFetch(): { fetch: typeof globalThis.fetch; calls: () => number } {
36
+ let calls = 0;
37
+ return {
38
+ fetch: ((_input: RequestInfo | URL, init?: RequestInit) => {
39
+ calls++;
40
+ return new Promise<Response>((_resolve, reject) => {
41
+ const signal = init?.signal;
42
+ const fail = (): void => reject(new DOMException('The operation was aborted.', 'AbortError'));
43
+ if (!signal) return;
44
+ if (signal.aborted) { fail(); return; }
45
+ signal.addEventListener('abort', fail, { once: true });
46
+ });
47
+ }) as typeof globalThis.fetch,
48
+ calls: () => calls,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Pin the retry backoff's jitter to zero.
54
+ *
55
+ * `retryAsync`'s delay is `baseDelay * 2**attempt + Math.random() * 1000`, so
56
+ * with real timers a retry can land up to a second later. A test that waits a
57
+ * fixed 80ms to prove "no retry happened" would then pass whether the bug was
58
+ * present or not — it would just be measuring the jitter. Pinning it makes the
59
+ * retry window exactly `baseDelay * (1 + 2 + 4)`, which a short wait covers
60
+ * decisively.
61
+ */
62
+ function pinJitter(): void {
63
+ jest.spyOn(Math, 'random').mockReturnValue(0);
64
+ }
65
+
66
+ describe('HttpService cancellation', () => {
67
+ const originalFetch = globalThis.fetch;
68
+
69
+ afterEach(() => {
70
+ globalThis.fetch = originalFetch;
71
+ jest.restoreAllMocks();
72
+ jest.useRealTimers();
73
+ });
74
+
75
+ it('never calls fetch for a signal that was already aborted', async () => {
76
+ let calls = 0;
77
+ globalThis.fetch = (async () => { calls++; return jsonResponse({}); }) as typeof globalThis.fetch;
78
+
79
+ const http = new HttpService({ baseURL: 'https://api.test' });
80
+ const controller = new AbortController();
81
+ controller.abort();
82
+
83
+ await expect(http.get('/thing', { signal: controller.signal })).rejects.toMatchObject({
84
+ code: ErrorCodes.CANCELLED,
85
+ });
86
+ expect(calls).toBe(0);
87
+ });
88
+
89
+ it('issues exactly one fetch when a request is cancelled mid-flight, with retry on', async () => {
90
+ pinJitter();
91
+ const { fetch, calls } = hangingFetch();
92
+ globalThis.fetch = fetch;
93
+
94
+ const http = new HttpService({
95
+ baseURL: 'https://api.test',
96
+ enableRetry: true,
97
+ maxRetries: 3,
98
+ retryDelay: 1,
99
+ });
100
+
101
+ const controller = new AbortController();
102
+ const pending = http.get('/thing', { signal: controller.signal });
103
+ const assertion = expect(pending).rejects.toMatchObject({ code: ErrorCodes.CANCELLED });
104
+
105
+ // Let the request reach fetch, then cancel it.
106
+ await new Promise((resolve) => setTimeout(resolve, 10));
107
+ expect(calls()).toBe(1);
108
+ controller.abort();
109
+ await assertion;
110
+
111
+ // The retry budget was 3 more attempts, whose backoff with jitter pinned is
112
+ // 1+2+4ms. Waiting well past that window is what proves none of them ran,
113
+ // and it is the crux of the defect: the re-issued request went out AFTER
114
+ // the rejection the caller saw, so a test that stopped at the rejection
115
+ // would have passed with the bug fully present.
116
+ await new Promise((resolve) => setTimeout(resolve, 80));
117
+ expect(calls()).toBe(1);
118
+ });
119
+
120
+ it('leaves no abort listener on a caller signal that outlives the request', async () => {
121
+ globalThis.fetch = (async () => jsonResponse({ ok: true })) as typeof globalThis.fetch;
122
+
123
+ const controller = new AbortController();
124
+ const added: unknown[] = [];
125
+ const removed: unknown[] = [];
126
+ const realAdd = controller.signal.addEventListener.bind(controller.signal);
127
+ const realRemove = controller.signal.removeEventListener.bind(controller.signal);
128
+ jest.spyOn(controller.signal, 'addEventListener').mockImplementation(((type: string, fn: never, opts: never) => {
129
+ if (type === 'abort') added.push(fn);
130
+ return realAdd(type as 'abort', fn, opts);
131
+ }) as never);
132
+ jest.spyOn(controller.signal, 'removeEventListener').mockImplementation(((type: string, fn: never, opts: never) => {
133
+ if (type === 'abort') removed.push(fn);
134
+ return realRemove(type as 'abort', fn, opts);
135
+ }) as never);
136
+
137
+ const http = new HttpService({ baseURL: 'https://api.test' });
138
+ // A React Query query signal is reused across the query's whole lifetime,
139
+ // so a listener left behind per request is an unbounded leak, not a nit.
140
+ for (let i = 0; i < 5; i++) {
141
+ await http.get(`/thing-${i}`, { signal: controller.signal, cache: false, deduplicate: false });
142
+ }
143
+
144
+ expect(added.length).toBeGreaterThan(0);
145
+ expect(removed.length).toBe(added.length);
146
+ });
147
+
148
+ it('does not consume a queue slot for a cancelled request, so a live one still runs', async () => {
149
+ const live: string[] = [];
150
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
151
+ const url = String(input);
152
+ if (url.includes('/live')) {
153
+ live.push(url);
154
+ return jsonResponse({ ok: true });
155
+ }
156
+ // Cancelled requests never get here; if one does, it would hold a slot.
157
+ return new Promise<Response>(() => { /* never settles */ });
158
+ }) as typeof globalThis.fetch;
159
+
160
+ const http = new HttpService({
161
+ baseURL: 'https://api.test',
162
+ maxConcurrentRequests: 2,
163
+ enableRetry: false,
164
+ });
165
+
166
+ // Fill more than the pool with requests whose callers have already gone.
167
+ const dead = Array.from({ length: 6 }, (_, i) => {
168
+ const controller = new AbortController();
169
+ controller.abort();
170
+ return http.get(`/dead-${i}`, { signal: controller.signal }).catch(() => 'cancelled');
171
+ });
172
+ await Promise.all(dead);
173
+
174
+ // With only two slots, this is the assertion that matters: the live request
175
+ // is not queued behind six abandoned ones.
176
+ await expect(http.get('/live', { cache: false })).resolves.toBeDefined();
177
+ expect(live).toHaveLength(1);
178
+ });
179
+ });
180
+
181
+ describe('HttpService timeout', () => {
182
+ const originalFetch = globalThis.fetch;
183
+
184
+ afterEach(() => {
185
+ globalThis.fetch = originalFetch;
186
+ jest.restoreAllMocks();
187
+ jest.useRealTimers();
188
+ });
189
+
190
+ it('does not retry a timeout by default, and reports it as a timeout not a cancellation', async () => {
191
+ pinJitter();
192
+ const { fetch, calls } = hangingFetch();
193
+ globalThis.fetch = fetch;
194
+
195
+ const http = new HttpService({
196
+ baseURL: 'https://api.test',
197
+ enableRetry: true,
198
+ maxRetries: 3,
199
+ retryDelay: 1,
200
+ requestTimeout: 20,
201
+ });
202
+
203
+ await expect(http.get('/slow', { cache: false })).rejects.toMatchObject({
204
+ code: ErrorCodes.TIMEOUT,
205
+ timeout: true,
206
+ });
207
+
208
+ await new Promise((resolve) => setTimeout(resolve, 80));
209
+ // One attempt, not four. Four behind a short timeout is how a single slow
210
+ // endpoint used to cost tens of seconds of wall clock.
211
+ expect(calls()).toBe(1);
212
+ });
213
+
214
+ it('retries a timeout when the caller opts in', async () => {
215
+ const { fetch, calls } = hangingFetch();
216
+ globalThis.fetch = fetch;
217
+
218
+ const http = new HttpService({
219
+ baseURL: 'https://api.test',
220
+ enableRetry: true,
221
+ maxRetries: 2,
222
+ retryDelay: 1,
223
+ requestTimeout: 20,
224
+ });
225
+
226
+ await expect(
227
+ http.get('/slow', { cache: false, retryOnTimeout: true }),
228
+ ).rejects.toMatchObject({ code: ErrorCodes.TIMEOUT });
229
+
230
+ expect(calls()).toBe(3);
231
+ });
232
+
233
+ it('bounds total wall clock with a deadline, even with retries on', async () => {
234
+ pinJitter();
235
+ const { fetch } = hangingFetch();
236
+ globalThis.fetch = fetch;
237
+
238
+ const http = new HttpService({
239
+ baseURL: 'https://api.test',
240
+ enableRetry: true,
241
+ maxRetries: 5,
242
+ retryDelay: 100,
243
+ requestTimeout: 20,
244
+ });
245
+
246
+ const started = Date.now();
247
+ await expect(
248
+ http.get('/slow', { cache: false, retryOnTimeout: true, deadline: 150 }),
249
+ ).rejects.toBeDefined();
250
+
251
+ // `timeout` bounds an attempt; `deadline` bounds the call. Without it the
252
+ // total is attempts x timeout + backoff, which no call site computes.
253
+ expect(Date.now() - started).toBeLessThan(400);
254
+ });
255
+
256
+ it('still retries a 5xx — the fix must not disable retry wholesale', async () => {
257
+ let calls = 0;
258
+ globalThis.fetch = (async () => {
259
+ calls++;
260
+ return jsonResponse({ error: 'boom' }, 503);
261
+ }) as typeof globalThis.fetch;
262
+
263
+ const http = new HttpService({
264
+ baseURL: 'https://api.test',
265
+ enableRetry: true,
266
+ maxRetries: 2,
267
+ retryDelay: 1,
268
+ });
269
+
270
+ await expect(http.get('/flaky', { cache: false })).rejects.toBeDefined();
271
+ expect(calls).toBe(3);
272
+ });
273
+
274
+ it('still refuses to retry a 4xx', async () => {
275
+ let calls = 0;
276
+ globalThis.fetch = (async () => {
277
+ calls++;
278
+ return jsonResponse({ error: 'nope' }, 404);
279
+ }) as typeof globalThis.fetch;
280
+
281
+ const http = new HttpService({
282
+ baseURL: 'https://api.test',
283
+ enableRetry: true,
284
+ maxRetries: 3,
285
+ retryDelay: 1,
286
+ });
287
+
288
+ await expect(http.get('/missing', { cache: false })).rejects.toBeDefined();
289
+ expect(calls).toBe(1);
290
+ });
291
+
292
+ it('arms no timer past a request that threw before returning', async () => {
293
+ globalThis.fetch = (async () => jsonResponse({ error: 'nope' }, 404)) as typeof globalThis.fetch;
294
+ jest.useFakeTimers({ doNotFake: ['nextTick'] });
295
+
296
+ const http = new HttpService({
297
+ baseURL: 'https://api.test',
298
+ enableRetry: false,
299
+ requestTimeout: 30_000,
300
+ });
301
+
302
+ await expect(http.get('/missing', { cache: false })).rejects.toBeDefined();
303
+ // `clearTimeout` used to sit on the success path only, so any throw between
304
+ // the fetch and the return left a 30s timer armed.
305
+ expect(jest.getTimerCount()).toBe(0);
306
+ jest.useRealTimers();
307
+ });
308
+ });
@@ -202,8 +202,18 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
202
202
 
203
203
  /**
204
204
  * Search user profiles
205
+ *
206
+ * Takes an `AbortSignal` because people search is typed into: without one
207
+ * every keystroke started a search that ran to completion and had its
208
+ * result discarded, while holding one of the request queue's slots. It was
209
+ * the only search lane in Mention that could not be cancelled, purely
210
+ * because this signature had no way to say so.
205
211
  */
206
- async searchProfiles(query: string, pagination?: PaginationParams): Promise<SearchProfilesResponse> {
212
+ async searchProfiles(
213
+ query: string,
214
+ pagination?: PaginationParams,
215
+ options?: { signal?: AbortSignal }
216
+ ): Promise<SearchProfilesResponse> {
207
217
  try {
208
218
  const response = await this.makeRequest<SearchProfilesResponse>(
209
219
  'GET',
@@ -212,6 +222,7 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
212
222
  {
213
223
  cache: true,
214
224
  cacheTTL: 2 * 60 * 1000, // 2 minutes cache
225
+ signal: options?.signal,
215
226
  }
216
227
  );
217
228
 
@@ -1,6 +1,61 @@
1
1
  import { createEcosystemTraffic } from '../traffic';
2
2
 
3
- jest.mock('../../OxyServices', () => ({ OxyServices: jest.fn(() => ({})) }));
3
+ /**
4
+ * Every client the publisher builds, so a test can ask what it was handed.
5
+ *
6
+ * Named `mock*` because ts-jest hoists `jest.mock` above the imports and only
7
+ * lets a factory close over a binding with that prefix.
8
+ */
9
+ const mockClients: Array<{ configureServiceAuth: jest.Mock; getServiceToken: jest.Mock }> = [];
10
+
11
+ jest.mock('../../OxyServices', () => ({
12
+ OxyServices: jest.fn(() => {
13
+ const client = {
14
+ configureServiceAuth: jest.fn(),
15
+ getServiceToken: jest.fn(async () => 'token-from-workload'),
16
+ };
17
+ mockClients.push(client);
18
+ return client;
19
+ }),
20
+ }));
21
+
22
+ function lastClient() {
23
+ const client = mockClients[mockClients.length - 1];
24
+ if (!client) throw new Error('No OxyServices was built.');
25
+ return client;
26
+ }
27
+
28
+ /** Accepts every publish and remembers the headers, so nothing reaches a network. */
29
+ function recordingFetch(sent: RequestInit[]) {
30
+ return jest.fn(async (_input, init) => {
31
+ sent.push(init as RequestInit);
32
+ return new Response(null, { status: 204 });
33
+ }) as unknown as typeof fetch;
34
+ }
35
+
36
+ function authorizations(sent: RequestInit[]): string[] {
37
+ return sent.map(init => (init.headers as Record<string, string>).Authorization);
38
+ }
39
+
40
+ /**
41
+ * A process with nothing configured and nothing to attest: a laptop.
42
+ *
43
+ * Emptied by assignment rather than `delete`, which Biome refuses on a property
44
+ * access — and `= undefined` would be worse than either, because Node stores the
45
+ * STRING "undefined" and every check here would read it as a configured value.
46
+ */
47
+ function clearCredentialEnvironment() {
48
+ process.env.OXY_ACTIVITY_API_KEY = '';
49
+ process.env.OXY_ACTIVITY_API_SECRET = '';
50
+ process.env.OXY_SERVICE_API_KEY = '';
51
+ process.env.OXY_SERVICE_API_SECRET = '';
52
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '';
53
+ process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = '';
54
+ }
55
+
56
+ beforeEach(() => {
57
+ mockClients.length = 0;
58
+ });
4
59
 
5
60
  it('publishes independently of viewers and waits for registration before removing a stopped instance', async () => {
6
61
  const originalFetch = globalThis.fetch;
@@ -47,3 +102,79 @@ it('rejects a partial activity credential instead of mixing it with service cred
47
102
  process.env = previous;
48
103
  }
49
104
  });
105
+
106
+ /**
107
+ * Who may publish ecosystem activity, and who is still refused.
108
+ *
109
+ * ADR 0026 made "no api key, no secret" a legitimate shape for a first-party
110
+ * service: it attests its ECS task role instead. This constructor used to throw
111
+ * on exactly that shape, so the migration it asks for — delete two variables
112
+ * from a task definition — killed the process at boot, the circuit breaker
113
+ * rolled the deploy back, and the operator was told the service was stable.
114
+ */
115
+ describe('ecosystem activity credentials', () => {
116
+ const previous = { ...process.env };
117
+ const originalFetch = globalThis.fetch;
118
+
119
+ afterEach(() => {
120
+ process.env = { ...previous };
121
+ globalThis.fetch = originalFetch;
122
+ });
123
+
124
+ it('uses a service key pair when the process holds one', async () => {
125
+ clearCredentialEnvironment();
126
+ process.env.OXY_SERVICE_API_KEY = 'service-key';
127
+ process.env.OXY_SERVICE_API_SECRET = 'service-secret';
128
+ const sent: RequestInit[] = [];
129
+ globalThis.fetch = recordingFetch(sent);
130
+
131
+ const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2' });
132
+ await traffic.stop();
133
+
134
+ expect(lastClient().configureServiceAuth).toHaveBeenCalledWith('service-key', 'service-secret');
135
+ expect(lastClient().getServiceToken).toHaveBeenCalled();
136
+ });
137
+
138
+ it('lets an explicit credential win over the environment', async () => {
139
+ clearCredentialEnvironment();
140
+ process.env.OXY_SERVICE_API_KEY = 'service-key';
141
+ process.env.OXY_SERVICE_API_SECRET = 'service-secret';
142
+ const sent: RequestInit[] = [];
143
+ globalThis.fetch = recordingFetch(sent);
144
+
145
+ const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2', credential: async () => 'explicit-token' });
146
+ await traffic.stop();
147
+
148
+ expect(authorizations(sent)).not.toContain('Bearer token-from-workload');
149
+ expect(authorizations(sent)).toContain('Bearer explicit-token');
150
+ expect(lastClient().getServiceToken).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it('refuses a process with no credential and nothing to attest', () => {
154
+ clearCredentialEnvironment();
155
+
156
+ // A laptop or a CI box: the container credentials endpoint is not going to
157
+ // appear later, so saying so at boot beats failing on the first heartbeat.
158
+ expect(() => createEcosystemTraffic({ service: 'homiio', region: 'us-west-2' })).toThrow(
159
+ /needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity/
160
+ );
161
+ });
162
+
163
+ it('publishes with no key pair at all when the process can attest its workload identity', async () => {
164
+ clearCredentialEnvironment();
165
+ // The variable ECS sets on every task, and nothing else does. Stubbed, so
166
+ // `canAttestWorkloadIdentity()` answers yes without a credentials endpoint.
167
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/v2/credentials/abc';
168
+ const sent: RequestInit[] = [];
169
+ globalThis.fetch = recordingFetch(sent);
170
+
171
+ const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2' });
172
+ await traffic.stop();
173
+
174
+ // Unconfigured on purpose: that is what sends `getServiceToken()` down the
175
+ // workload path instead of handing it half a credential.
176
+ expect(lastClient().configureServiceAuth).not.toHaveBeenCalled();
177
+ expect(lastClient().getServiceToken).toHaveBeenCalled();
178
+ expect(authorizations(sent)).toContain('Bearer token-from-workload');
179
+ });
180
+ });
@@ -3,6 +3,12 @@ import { observeTrafficSocket, observeTrafficWebSocket, type TrafficWebSocket, t
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import type { RequestHandler } from 'express';
5
5
  import { OxyServices } from '../OxyServices';
6
+ // Static, unlike the lazy import in `mixins/OxyServices.auth.ts`: that module is
7
+ // reachable from the root barrel, which ships to React Native, so pulling
8
+ // `node:crypto` in eagerly there would cost every mobile bundle. This file is
9
+ // server-only and the server barrel already exports `./workloadIdentity`, so
10
+ // there is nothing here for a phone to load.
11
+ import { canAttestWorkloadIdentity } from './workloadIdentity';
6
12
  import { createTrafficCollector, instrumentTrafficFetch, trafficMiddleware, type TrafficFlow, type ServiceEndpoint, resolveOxyServiceEndpoint, infrastructureLocation } from '@oxy.so/telemetry/collector';
7
13
 
8
14
  export interface EcosystemTrafficOptions {
@@ -36,8 +42,32 @@ export function createEcosystemTraffic(options: EcosystemTrafficOptions): {
36
42
  }
37
43
  const apiKey = activityKey || process.env.OXY_SERVICE_API_KEY;
38
44
  const apiSecret = activitySecret || process.env.OXY_SERVICE_API_SECRET;
39
- if (!options.credential && (!apiKey || !apiSecret)) throw new Error('Ecosystem activity requires OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET');
45
+ /**
46
+ * Having no key pair is a legitimate configuration now — but only where this
47
+ * process can prove what it IS (ADR 0026).
48
+ *
49
+ * This used to throw on a missing pair, full stop. Every service in the fleet
50
+ * boots with `OXY_ECOSYSTEM_ACTIVITY_ENABLED=true`, so deleting
51
+ * `OXY_SERVICE_API_KEY` and `OXY_SERVICE_API_SECRET` from a task definition —
52
+ * which is the entire migration ADR 0026 asks for — killed the process at
53
+ * boot. The ECS circuit breaker then rolled the deploy back and reported the
54
+ * service stable, so the operator saw a rollback with nothing naming its
55
+ * cause. That is what one line cost, multiplied by fourteen services holding
56
+ * a secret they no longer need.
57
+ *
58
+ * What is still refused: nothing to authenticate with AND nothing to attest.
59
+ * That is a laptop or a CI box, where the container credentials endpoint is
60
+ * not going to turn up later, and the honest answer is to say so at boot
61
+ * rather than to fail on the first heartbeat.
62
+ */
63
+ if (!options.credential && (!apiKey || !apiSecret) && !canAttestWorkloadIdentity()) {
64
+ throw new Error(
65
+ 'Ecosystem activity needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity this process can attest (ADR 0026).',
66
+ );
67
+ }
40
68
  const oxy = new OxyServices({ baseURL });
69
+ // Left unconfigured when there is no pair on purpose: `getServiceToken()`
70
+ // attests instead of being handed half a credential to fail on.
41
71
  if (apiKey && apiSecret) oxy.configureServiceAuth(apiKey, apiSecret);
42
72
  const credential = options.credential ?? (() => oxy.getServiceToken());
43
73
  const fetcher = globalThis.fetch.bind(globalThis);
@@ -1,5 +1,5 @@
1
1
  import { retryAsync } from '../asyncUtils';
2
- import { handleHttpError } from '../errorUtils';
2
+ import { createCancelledError, ErrorCodes, handleHttpError } from '../errorUtils';
3
3
 
4
4
  /**
5
5
  * Regression coverage for the 1.11.11 retry storm:
@@ -185,3 +185,95 @@ describe('handleHttpError preserves HTTP status for retry predicates', () => {
185
185
  expect(result.code).toBe('INTERNAL_ERROR');
186
186
  });
187
187
  });
188
+
189
+ describe('retryAsync refuses to retry a cancellation', () => {
190
+ it('stops after one attempt on a cancellation, whatever the retry budget', async () => {
191
+ const operation = jest.fn(async () => { throw createCancelledError(); });
192
+
193
+ await expect(retryAsync(operation, { maxRetries: 3, baseDelay: 1 })).rejects.toMatchObject({
194
+ code: ErrorCodes.CANCELLED,
195
+ });
196
+ // This is the defect in one assertion: a cancellation carries no HTTP
197
+ // status, `status: 0` is not 4xx, so the status-only predicate passed it
198
+ // into the retry loop and the SDK re-issued abandoned requests.
199
+ expect(operation).toHaveBeenCalledTimes(1);
200
+ });
201
+
202
+ it('refuses a cancellation even when a custom shouldRetry says yes', async () => {
203
+ const operation = jest.fn(async () => { throw createCancelledError(); });
204
+
205
+ await expect(
206
+ retryAsync(operation, { maxRetries: 3, baseDelay: 1, shouldRetry: () => true }),
207
+ ).rejects.toBeDefined();
208
+ // A custom predicate decides which FAILURES deserve another attempt. It has
209
+ // no business overriding an explicit instruction to stop.
210
+ expect(operation).toHaveBeenCalledTimes(1);
211
+ });
212
+
213
+ it('treats a bare AbortError as a cancellation', async () => {
214
+ const operation = jest.fn(async () => {
215
+ throw new DOMException('The operation was aborted.', 'AbortError');
216
+ });
217
+
218
+ await expect(retryAsync(operation, { maxRetries: 3, baseDelay: 1 })).rejects.toBeDefined();
219
+ // An unclassified abort is assumed to be a cancellation: refusing to retry
220
+ // something abandoned costs nothing, retrying it is the bug.
221
+ expect(operation).toHaveBeenCalledTimes(1);
222
+ });
223
+ });
224
+
225
+ describe('retryAsync timeout policy', () => {
226
+ afterEach(() => { jest.restoreAllMocks(); });
227
+
228
+ const timeoutError = (): Error => {
229
+ const error = new Error('timed out') as Error & { code?: string; timeout?: boolean; status?: number };
230
+ error.code = ErrorCodes.TIMEOUT;
231
+ error.timeout = true;
232
+ error.status = 0;
233
+ return error;
234
+ };
235
+
236
+ it('does not retry a timeout by default', async () => {
237
+ const operation = jest.fn(async () => { throw timeoutError(); });
238
+
239
+ await expect(retryAsync(operation, { maxRetries: 3, baseDelay: 1 })).rejects.toBeDefined();
240
+ expect(operation).toHaveBeenCalledTimes(1);
241
+ });
242
+
243
+ it('retries a timeout when asked to', async () => {
244
+ const operation = jest.fn(async () => { throw timeoutError(); });
245
+
246
+ await expect(
247
+ retryAsync(operation, { maxRetries: 2, baseDelay: 1, retryOnTimeout: true }),
248
+ ).rejects.toBeDefined();
249
+ expect(operation).toHaveBeenCalledTimes(3);
250
+ });
251
+
252
+ it('stops at the deadline rather than at the attempt budget', async () => {
253
+ // Jitter pinned so the backoff is exactly baseDelay * 2**attempt; otherwise
254
+ // this measures Math.random() rather than the deadline.
255
+ jest.spyOn(Math, 'random').mockReturnValue(0);
256
+ const operation = jest.fn(async () => { throw new Error('5xx-ish'); });
257
+
258
+ const started = Date.now();
259
+ await expect(
260
+ retryAsync(operation, { maxRetries: 10, baseDelay: 50, deadline: Date.now() + 120 }),
261
+ ).rejects.toBeDefined();
262
+
263
+ // The point of a deadline: the wall clock a caller feels is bounded by a
264
+ // budget it stated, not by arithmetic over attempts x timeout + backoff.
265
+ expect(Date.now() - started).toBeLessThan(400);
266
+ expect(operation.mock.calls.length).toBeLessThan(11);
267
+ });
268
+ });
269
+
270
+ describe('retryAsync keeps its positional signature working', () => {
271
+ it('accepts the legacy (operation, maxRetries, baseDelay, shouldRetry) call', async () => {
272
+ const operation = jest.fn(async () => { throw new Error('transient'); });
273
+
274
+ await expect(retryAsync(operation, 2, 1)).rejects.toThrow('transient');
275
+ // In-tree callers pass positionally, so the options object had to be an
276
+ // overload rather than a replacement.
277
+ expect(operation).toHaveBeenCalledTimes(3);
278
+ });
279
+ });