Skip to content

Commit 6ae0ae7

Browse files
committed
feat(sdk): add iterator resume callback
1 parent cd23c88 commit 6ae0ae7

2 files changed

Lines changed: 60 additions & 4 deletions

File tree

typescript/packages/sdk/src/index.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export type GetJobEventsAllPagesRequest = Omit<GetJobEventsRequest, "before"> &
102102
export type IterateJobEventsRequest = GetJobEventsAllPagesRequest & {
103103
resumeOnTransientError?: boolean;
104104
maxResumeAttempts?: number;
105+
shouldResumeOnError?: (error: SpatiadApiError, attempt: number) => boolean;
105106
};
106107

107108
export type JobEvent = {
@@ -290,6 +291,7 @@ export class SpatiadClient {
290291

291292
const resumeOnTransientError = request.resumeOnTransientError ?? false;
292293
const maxResumeAttempts = Math.max(0, request.maxResumeAttempts ?? 3);
294+
const shouldResumeOnError = request.shouldResumeOnError;
293295
let cursor: string | undefined;
294296
let yielded = 0;
295297
let resumeAttempts = 0;
@@ -308,13 +310,19 @@ export class SpatiadClient {
308310
retry: request.retry
309311
});
310312
} catch (error) {
313+
const nextAttempt = resumeAttempts + 1;
314+
const apiError = error instanceof SpatiadApiError ? error : undefined;
315+
const callbackAllowsResume =
316+
apiError !== undefined
317+
&& shouldResumeOnError !== undefined
318+
&& shouldResumeOnError(apiError, nextAttempt);
319+
311320
if (
312-
resumeOnTransientError
313-
&& error instanceof SpatiadApiError
314-
&& error.retryable
321+
apiError !== undefined
315322
&& resumeAttempts < maxResumeAttempts
323+
&& (callbackAllowsResume || (resumeOnTransientError && apiError.retryable))
316324
) {
317-
resumeAttempts += 1;
325+
resumeAttempts = nextAttempt;
318326
const backoffBase = Math.max(0, request.retry?.backoffMs ?? 150);
319327
const backoffMax = Math.max(backoffBase, request.retry?.maxBackoffMs ?? 2000);
320328
const waitMs = Math.min(backoffMax, backoffBase * (2 ** (resumeAttempts - 1)));

typescript/packages/sdk/test/client.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,3 +607,51 @@ test("iterateJobEvents fails without resumeOnTransientError", async () => {
607607
globalThis.fetch = originalFetch;
608608
}
609609
});
610+
611+
test("iterateJobEvents can resume via shouldResumeOnError callback", async () => {
612+
const originalFetch = globalThis.fetch;
613+
let calls = 0;
614+
const observedAttempts = [];
615+
616+
globalThis.fetch = async () => {
617+
calls += 1;
618+
if (calls <= 2) {
619+
return makeJsonResponse(400, {
620+
error: "invalid_query",
621+
message: "temporary parse mismatch"
622+
});
623+
}
624+
625+
return makeJsonResponse(200, {
626+
job_id: "job-iter-callback",
627+
events: [
628+
{ at: "2026-03-20T10:00:00Z", kind: "offer_created", offer_id: "o1", driver_id: "d1", status: "pending" }
629+
],
630+
next_cursor: null,
631+
next_before_cursor: null
632+
});
633+
};
634+
635+
try {
636+
const client = new SpatiadClient("http://localhost:3000");
637+
const streamed = [];
638+
639+
for await (const event of client.iterateJobEvents({
640+
jobId: "job-iter-callback",
641+
maxResumeAttempts: 2,
642+
shouldResumeOnError: (error, attempt) => {
643+
observedAttempts.push(`${error.status}:${attempt}`);
644+
return error.status === 400 && attempt <= 2;
645+
},
646+
retry: { maxAttempts: 1, backoffMs: 1 }
647+
})) {
648+
streamed.push(event.kind);
649+
}
650+
651+
assert.deepEqual(observedAttempts, ["400:1", "400:2"]);
652+
assert.deepEqual(streamed, ["offer_created"]);
653+
assert.equal(calls, 3);
654+
} finally {
655+
globalThis.fetch = originalFetch;
656+
}
657+
});

0 commit comments

Comments
 (0)