Skip to content

Commit ca31a07

Browse files
tsushanthclaude
andcommitted
fix(run): honour AbortSignal at start and after polling
Closes #370. `replicate.run()` accepted `options.signal` but only checked it as a poll exit condition. Two paths still silently completed: 1. A signal that was already aborted before `run()` was called fell through to `predictions.create()` and waited on the prediction as usual. 2. A signal that aborted during polling broke the poll loop, but the caller's awaited promise resolved with the empty output of the canceled prediction (or `undefined`) instead of throwing — the cancellation was invisible to the consumer. `run()` now: - Calls `signal.throwIfAborted()` before any HTTP work happens, matching the `fetch`/standard-AbortController contract. - Forwards `signal` to `predictions.create()` so the initial create fetch is itself abortable. - After the poll loop exits with an aborted signal, best-effort cancels the prediction (swallowing any cancel-side error so the abort remains the higher-priority signal) and then calls `signal.throwIfAborted()` so the awaited promise rejects with `AbortError`. The existing "Aborts the operation when abort signal is invoked" test is updated to assert the new throwing contract; two new regression cases cover the pre-aborted-signal short-circuit and the no-network guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2fd6f39 commit ca31a07

2 files changed

Lines changed: 61 additions & 17 deletions

File tree

index.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ class Replicate {
145145
async run(ref, options, progress) {
146146
const { wait = { mode: "block" }, signal, ...data } = options;
147147

148+
// Honour an already-aborted signal before any network work happens.
149+
if (signal && signal.aborted) {
150+
signal.throwIfAborted();
151+
}
152+
148153
const identifier = ModelVersionIdentifier.parse(ref);
149154

150155
let prediction;
@@ -153,12 +158,14 @@ class Replicate {
153158
...data,
154159
version: identifier.version,
155160
wait: wait.mode === "block" ? wait.timeout ?? true : false,
161+
signal,
156162
});
157163
} else if (identifier.owner && identifier.name) {
158164
prediction = await this.predictions.create({
159165
...data,
160166
model: `${identifier.owner}/${identifier.name}`,
161167
wait: wait.mode === "block" ? wait.timeout ?? true : false,
168+
signal,
162169
});
163170
} else {
164171
throw new Error("Invalid model version identifier");
@@ -191,7 +198,16 @@ class Replicate {
191198
}
192199

193200
if (signal && signal.aborted) {
194-
prediction = await this.predictions.cancel(prediction.id);
201+
// Best-effort cancel on Replicate's side so we don't keep billing the
202+
// user for compute they no longer want, then surface the abort to the
203+
// caller. Without the throw, the awaited promise would resolve with a
204+
// half-cancelled prediction, which is silent on the consumer side.
205+
try {
206+
prediction = await this.predictions.cancel(prediction.id);
207+
} catch {
208+
// Ignore cancel failures — the abort is the higher-priority signal.
209+
}
210+
signal.throwIfAborted();
195211
}
196212

197213
// Call progress callback with the completed prediction object

index.test.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,20 +1598,28 @@ describe("Replicate client", () => {
15981598
});
15991599

16001600
const onProgress = jest.fn();
1601-
const output = await client.run(
1602-
"owner/model:5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa",
1603-
{
1604-
wait: { mode: "poll" },
1605-
input: { text: "Hello, world!" },
1606-
signal,
1607-
},
1608-
onProgress
1609-
);
1601+
let caught: unknown;
1602+
try {
1603+
await client.run(
1604+
"owner/model:5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa",
1605+
{
1606+
wait: { mode: "poll" },
1607+
input: { text: "Hello, world!" },
1608+
signal,
1609+
},
1610+
onProgress
1611+
);
1612+
} catch (err) {
1613+
caught = err;
1614+
}
16101615

16111616
expect(body).toBeDefined();
16121617
expect(body?.["signal"]).toBeUndefined();
16131618
expect(signal.aborted).toBe(true);
1614-
expect(output).toBeUndefined();
1619+
// Regression for replicate-javascript#370: an aborted run() must throw
1620+
// an AbortError so the caller can detect cancellation, not silently
1621+
// resolve to `undefined` from the canceled prediction's empty output.
1622+
expect((caught as Error | undefined)?.name).toBe("AbortError");
16151623

16161624
expect(onProgress).toHaveBeenNthCalledWith(
16171625
1,
@@ -1625,16 +1633,36 @@ describe("Replicate client", () => {
16251633
status: "processing",
16261634
})
16271635
);
1628-
expect(onProgress).toHaveBeenNthCalledWith(
1629-
3,
1630-
expect.objectContaining({
1631-
status: "canceled",
1632-
})
1633-
);
16341636

16351637
scope.done();
16361638
});
16371639

1640+
test("throws AbortError immediately when signal is already aborted", async () => {
1641+
// Regression for replicate-javascript#370: a pre-aborted signal must
1642+
// short-circuit before any HTTP request — previously run() created the
1643+
// prediction and waited for it anyway.
1644+
const controller = new AbortController();
1645+
controller.abort();
1646+
1647+
let caught: unknown;
1648+
try {
1649+
await client.run(
1650+
"owner/model:5c7d5dc6dd8bf75c1acaa8565735e7986bc5b66206b55cca93cb72c9bf15ccaa",
1651+
{
1652+
wait: { mode: "poll" },
1653+
input: { text: "Hello, world!" },
1654+
signal: controller.signal,
1655+
}
1656+
);
1657+
} catch (err) {
1658+
caught = err;
1659+
}
1660+
1661+
expect((caught as Error | undefined)?.name).toBe("AbortError");
1662+
// No nock scope was registered — if any HTTP request fired, nock would
1663+
// throw a "Nock: No match for request" error instead of an AbortError.
1664+
});
1665+
16381666
test("returns FileOutput for URLs when useFileOutput is true", async () => {
16391667
client = new Replicate({ auth: "foo", useFileOutput: true });
16401668

0 commit comments

Comments
 (0)