Skip to content

Commit c0bc48a

Browse files
committed
retry stream target resolution on transient errors
1 parent 15166e8 commit c0bc48a

2 files changed

Lines changed: 89 additions & 1 deletion

File tree

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,78 @@ describe("CloudTaskService", () => {
10171017
expect(mockStreamTokenFetch.mock.calls.length).toBe(1);
10181018
});
10191019

1020+
it("a transient stream_token failure retries resolution instead of pinning to Django", async () => {
1021+
vi.useFakeTimers();
1022+
1023+
// The endpoint is momentarily down (503): unlike a 404, this must not cache a Django fallback.
1024+
// The next reconnect re-resolves and the watch upgrades to the durable proxy leg.
1025+
mockStreamTokenFetch
1026+
.mockImplementationOnce(() =>
1027+
Promise.resolve(createJsonResponse({ detail: "unavailable" }, 503)),
1028+
)
1029+
.mockImplementation(() =>
1030+
Promise.resolve(
1031+
createJsonResponse({
1032+
token: "fresh-token",
1033+
stream_base_url: "https://proxy.example",
1034+
}),
1035+
),
1036+
);
1037+
1038+
mockNetFetch.mockImplementation((input: string | Request) => {
1039+
const url = typeof input === "string" ? input : input.url;
1040+
if (url.includes("/session_logs/")) {
1041+
return Promise.resolve(
1042+
createJsonResponse([], 200, { "X-Has-More": "false" }),
1043+
);
1044+
}
1045+
return Promise.resolve(
1046+
createJsonResponse({
1047+
id: "run-1",
1048+
status: "in_progress",
1049+
stage: null,
1050+
output: null,
1051+
error_message: null,
1052+
branch: "main",
1053+
updated_at: "2026-01-01T00:00:00Z",
1054+
}),
1055+
);
1056+
});
1057+
1058+
// Django leg (transient round) just EOFs to force a reconnect; once the proxy resolves it
1059+
// emits stream-end so the watch completes, proving durable streaming engaged after the retry.
1060+
const usedProxyLeg = (input: string | Request): boolean => {
1061+
const url = typeof input === "string" ? input : input.url;
1062+
return url.includes("proxy.example");
1063+
};
1064+
mockStreamFetch.mockImplementation((input: string | Request) =>
1065+
Promise.resolve(
1066+
usedProxyLeg(input)
1067+
? createSseResponse("event: stream-end\ndata: {}\n\n")
1068+
: createSseResponse(""),
1069+
),
1070+
);
1071+
1072+
service.watch({
1073+
taskId: "task-1",
1074+
runId: "run-1",
1075+
apiHost: "https://app.example.com",
1076+
teamId: 2,
1077+
});
1078+
1079+
const hasWatcher = (): boolean =>
1080+
(service as unknown as { watchers: Map<string, unknown> }).watchers.has(
1081+
"task-1:run-1",
1082+
);
1083+
await waitFor(() => !hasWatcher(), 10_000);
1084+
1085+
// The 503 was not cached: resolution retried and the stream switched to the durable proxy leg.
1086+
expect(mockStreamTokenFetch.mock.calls.length).toBeGreaterThanOrEqual(2);
1087+
expect(
1088+
mockStreamFetch.mock.calls.some(([input]) => usedProxyLeg(input)),
1089+
).toBe(true);
1090+
});
1091+
10201092
it("proxy 401 re-resolves the read target and resumes with a fresh token", async () => {
10211093
vi.useFakeTimers();
10221094

packages/core/src/cloud-task/cloud-task.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,12 @@ function shouldFailWatcherForFetchStatus(status: number): boolean {
263263
return status === 401 || status === 403 || status === 404;
264264
}
265265

266+
// 5xx and 429 are momentary: the stream-token endpoint exists but is briefly unavailable, so the
267+
// target stays unresolved and the next reconnect retries instead of caching a Django fallback.
268+
function isTransientStreamTargetStatus(status: number): boolean {
269+
return status >= 500 || status === 429;
270+
}
271+
266272
@injectable()
267273
export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
268274
private watchers = new Map<string, WatcherState>();
@@ -1573,9 +1579,19 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
15731579
method: "GET",
15741580
});
15751581
if (!response.ok) {
1576-
// Refused, or an old server without the endpoint: read from Django with status polling.
15771582
watcher.streamBaseUrl = null;
15781583
watcher.streamReadToken = null;
1584+
if (isTransientStreamTargetStatus(response.status)) {
1585+
// Transient: read from Django this round but leave the target unresolved so the next
1586+
// reconnect retries durable resolution instead of pinning the run to status polling.
1587+
this.log.warn("Cloud task stream target temporarily unavailable", {
1588+
taskId: watcher.taskId,
1589+
runId: watcher.runId,
1590+
status: response.status,
1591+
});
1592+
return;
1593+
}
1594+
// Refused, or an old server without the endpoint: read from Django with status polling.
15791595
watcher.durableStreamEnabled = false;
15801596
watcher.streamTargetResolved = true;
15811597
this.log.info("Cloud task stream reading from API host", {

0 commit comments

Comments
 (0)