Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 222 additions & 1 deletion packages/host/tests/unit/virtual-network-retry-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { module, test } from 'qunit';

import { shouldRetryFetch } from '@cardstack/runtime-common/virtual-network';
import { VirtualNetwork } from '@cardstack/runtime-common';
import {
buildRequest,
shouldRetryFetch,
shouldTimeoutRetryableFetch,
} from '@cardstack/runtime-common/virtual-network';

// `shouldRetryFetch` is the pure predicate that gates the in-app fetch-retry
// safety net (see virtual-network.ts). The net papers over a transient CI
Expand Down Expand Up @@ -101,3 +106,219 @@ module('Unit | virtual-network shouldRetryFetch', function () {
});
});
});

// `shouldTimeoutRetryableFetch` gates the header-arrival timeout that turns a
// stall — response headers that never arrive, so the fetch hangs instead of
// throwing — into the same retryable failure the throw path already recovers
// from. It bounds only fetches that are already retryable, and only in the
// browser test suite: a slow response in node / worker / env-mode (e.g. a
// heavy `_search`) must never be aborted and retried.
module('Unit | virtual-network shouldTimeoutRetryableFetch', function () {
test('bounds a retryable base-realm fetch in the test environment', function (assert) {
withEnvironment('test', () => {
assert.true(
shouldTimeoutRetryableFetch(
new URL('https://realm-server.ci.localhost/base/_info'),
),
'the base _info fetch that can stall before headers is bounded',
);
assert.true(
shouldTimeoutRetryableFetch(
new URL('https://cardstack.com/base/card-api'),
),
'a virtual base-realm artifact is bounded',
);
});
});

test('does not bound a non-retryable fetch even in the test environment', function (assert) {
withEnvironment('test', () => {
assert.false(
shouldTimeoutRetryableFetch(
new URL(
'https://realm-server.ci.localhost/testuser/personal/Author/1',
),
),
'a user realm on the same host is not bounded',
);
assert.false(
shouldTimeoutRetryableFetch(
new URL('http://test-realm/test/SystemCard/default'),
),
'the in-browser test realm host is not bounded',
);
});
});

test('does not bound anything outside the test environment', function (assert) {
withEnvironment(undefined, () => {
assert.false(
shouldTimeoutRetryableFetch(
new URL('https://cardstack.com/base/card-api'),
),
'production / env-mode fetch flow keeps no header-timeout',
);
});
});
});

// A native fetch whose first attempt stalls at the header stage (never
// resolves; rejects only when its request signal aborts, exactly as a real
// fetch does), then succeeds. Mirrors a base-realm fetch whose response headers
// never arrive, so the unbounded fetch hangs until QUnit's global timeout.
function stallingThenOkFetch(): {
fetch: typeof globalThis.fetch;
attempts: () => number;
signalSeen: () => boolean;
} {
let attempt = 0;
let sawAbort = false;
let fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
attempt++;
let signal =
input instanceof Request ? input.signal : (init?.signal ?? undefined);
if (attempt === 1) {
return new Promise<Response>((_resolve, reject) => {
if (!signal) {
// No signal reached the fetch, so nothing can abort this attempt: it
// stays pending and the test fails via QUnit's timeout.
return;
}
let onAbort = () => {
sawAbort = true;
reject(signal.reason);
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
});
}
let ok = new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' },
});
// A real fetch response carries its resolved URL; mirror that (configurable
// so the virtual network's own url bookkeeping can still overwrite it)
// rather than returning the empty-url default a bare Response would have.
let url = input instanceof Request ? input.url : String(input);
Object.defineProperty(ok, 'url', { value: url, configurable: true });
return Promise.resolve(ok);
}) as typeof globalThis.fetch;
return { fetch, attempts: () => attempt, signalSeen: () => sawAbort };
}

module('Unit | virtual-network header-stall recovery', function () {
test('aborts a stalled retryable base fetch and recovers on the retry', async function (assert) {
let g = globalThis as { __environment?: string };
let had = '__environment' in g;
let prev = g.__environment;
g.__environment = 'test';
try {
let { fetch, attempts, signalSeen } = stallingThenOkFetch();
// Tiny header timeout so the stalled first attempt is aborted promptly;
// the second attempt then succeeds.
let vn = new VirtualNetwork(fetch, { fetchHeaderTimeoutMs: 20 });
let response = await vn.fetch('https://cardstack.com/base/card-api');
assert.strictEqual(
response.status,
200,
'recovered with a 200 after the stalled attempt was aborted',
);
assert.true(
attempts() >= 2,
'the stalled first attempt was retried on a fresh fetch',
);
assert.true(
signalSeen(),
'the aborted attempt fired the per-attempt timeout signal',
);
} finally {
if (had) {
g.__environment = prev;
} else {
delete g.__environment;
}
}
});

// The host reaches the base realm through a virtual-to-real URL mapping, and
// the per-attempt timeout signal is attached before that mapping runs. The
// remapped request must carry the signal through, or the native fetch for a
// base artifact addressed at the virtual host never sees the abort.
test('buildRequest carries the abort signal onto the remapped request', async function (assert) {
let controller = new AbortController();
let original = new Request('https://cardstack.com/base/_info', {
method: 'QUERY',
body: JSON.stringify({ realms: ['https://cardstack.com/base/'] }),
signal: controller.signal,
});
let remapped = await buildRequest(
'https://realm-server.ci.localhost/base/_info',
original,
);
assert.notStrictEqual(
remapped.url,
original.url,
'the request was rebuilt at the mapped real URL',
);
assert.false(remapped.signal.aborted, 'not aborted before the abort fires');
controller.abort(new Error('stall'));
assert.true(
remapped.signal.aborted,
'aborting the original aborts the remapped request',
);
});

test('a caller-initiated abort is surfaced at once, not retried', async function (assert) {
let g = globalThis as { __environment?: string };
let had = '__environment' in g;
let prev = g.__environment;
g.__environment = 'test';
try {
let attempts = 0;
let fetch = ((input: RequestInfo | URL) => {
attempts++;
let signal = input instanceof Request ? input.signal : undefined;
// Reject with the signal's reason when aborted, as a real fetch does.
return new Promise<Response>((_resolve, reject) => {
if (!signal) {
return;
}
let onAbort = () => reject(signal.reason);
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener('abort', onAbort, { once: true });
}
});
}) as typeof globalThis.fetch;
// A large header timeout so only the caller's abort ends the fetch. The
// URL is retryable, so without the guard this would retry the
// already-aborted request rather than surfacing the abort.
let vn = new VirtualNetwork(fetch, { fetchHeaderTimeoutMs: 60_000 });
let caller = new AbortController();
let pending = vn.fetch(
new Request('https://cardstack.com/base/card-api', {
signal: caller.signal,
}),
);
caller.abort();
let caught: { name?: string } | undefined;
try {
await pending;
} catch (e) {
caught = e as { name?: string };
}
assert.strictEqual(caught?.name, 'AbortError', 'the abort was surfaced');
assert.strictEqual(attempts, 1, 'the aborted request was not retried');
} finally {
if (had) {
g.__environment = prev;
} else {
delete g.__environment;
}
}
});
});
Loading
Loading