Skip to content

Commit f81975b

Browse files
committed
re-resolve stream target on proxy 401
1 parent 4b2abe6 commit f81975b

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

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

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

1020+
it("proxy 401 re-resolves the read target and resumes with a fresh token", async () => {
1021+
vi.useFakeTimers();
1022+
1023+
mockStreamTokenFetch
1024+
.mockImplementationOnce(() =>
1025+
Promise.resolve(
1026+
createJsonResponse({
1027+
token: "expired-token",
1028+
stream_base_url: "https://proxy.example",
1029+
}),
1030+
),
1031+
)
1032+
.mockImplementation(() =>
1033+
Promise.resolve(
1034+
createJsonResponse({
1035+
token: "fresh-token",
1036+
stream_base_url: "https://proxy.example",
1037+
}),
1038+
),
1039+
);
1040+
1041+
mockNetFetch.mockImplementation((input: string | Request) => {
1042+
const url = typeof input === "string" ? input : input.url;
1043+
if (url.includes("/session_logs/")) {
1044+
return Promise.resolve(
1045+
createJsonResponse([], 200, { "X-Has-More": "false" }),
1046+
);
1047+
}
1048+
return Promise.resolve(
1049+
createJsonResponse({
1050+
id: "run-1",
1051+
status: "in_progress",
1052+
stage: null,
1053+
output: null,
1054+
error_message: null,
1055+
branch: "main",
1056+
updated_at: "2026-01-01T00:00:00Z",
1057+
}),
1058+
);
1059+
});
1060+
1061+
mockStreamFetch
1062+
.mockImplementationOnce(() =>
1063+
Promise.resolve(createJsonResponse({ detail: "expired" }, 401)),
1064+
)
1065+
.mockImplementation(() =>
1066+
Promise.resolve(
1067+
createOpenSseResponse(
1068+
'event: keepalive\ndata: {"type":"keepalive"}\n\n',
1069+
),
1070+
),
1071+
);
1072+
1073+
service.watch({
1074+
taskId: "task-1",
1075+
runId: "run-1",
1076+
apiHost: "https://app.example.com",
1077+
teamId: 2,
1078+
});
1079+
1080+
await waitFor(() => mockStreamFetch.mock.calls.length >= 2, 10_000);
1081+
1082+
expect(mockStreamTokenFetch.mock.calls.length).toBe(2);
1083+
const [secondUrl, secondInit] = mockStreamFetch.mock.calls[1];
1084+
expect(String(secondUrl)).toMatch(
1085+
/^https:\/\/proxy\.example\/v1\/runs\/run-1\/stream/,
1086+
);
1087+
expect((secondInit?.headers as Record<string, string>)?.Authorization).toBe(
1088+
"Bearer fresh-token",
1089+
);
1090+
expect(
1091+
(service as unknown as { watchers: Map<string, unknown> }).watchers.has(
1092+
"task-1:run-1",
1093+
),
1094+
).toBe(true);
1095+
});
1096+
1097+
it("proxy 401 falls back to Django when the proxy is withdrawn", async () => {
1098+
vi.useFakeTimers();
1099+
1100+
// The re-resolution after the 401 no longer offers a proxy (rollout flag turned
1101+
// off mid-run); the watcher continues on the Django leg, which still emits the
1102+
// stream-end sentinel.
1103+
mockStreamTokenFetch
1104+
.mockImplementationOnce(() =>
1105+
Promise.resolve(
1106+
createJsonResponse({
1107+
token: "expired-token",
1108+
stream_base_url: "https://proxy.example",
1109+
}),
1110+
),
1111+
)
1112+
.mockImplementation(() =>
1113+
Promise.resolve(
1114+
createJsonResponse({ token: "django-token", stream_base_url: null }),
1115+
),
1116+
);
1117+
1118+
let runFetchCount = 0;
1119+
mockNetFetch.mockImplementation((input: string | Request) => {
1120+
const url = typeof input === "string" ? input : input.url;
1121+
if (url.includes("/session_logs/")) {
1122+
return Promise.resolve(
1123+
createJsonResponse([], 200, { "X-Has-More": "false" }),
1124+
);
1125+
}
1126+
runFetchCount += 1;
1127+
const terminal = runFetchCount >= 2;
1128+
return Promise.resolve(
1129+
createJsonResponse({
1130+
id: "run-1",
1131+
status: terminal ? "completed" : "in_progress",
1132+
stage: null,
1133+
output: null,
1134+
error_message: null,
1135+
branch: "main",
1136+
updated_at: terminal
1137+
? "2026-01-01T00:00:05Z"
1138+
: "2026-01-01T00:00:00Z",
1139+
}),
1140+
);
1141+
});
1142+
1143+
mockStreamFetch
1144+
.mockImplementationOnce(() =>
1145+
Promise.resolve(createJsonResponse({ detail: "expired" }, 401)),
1146+
)
1147+
.mockImplementation(() =>
1148+
Promise.resolve(createSseResponse("event: stream-end\ndata: {}\n\n")),
1149+
);
1150+
1151+
service.watch({
1152+
taskId: "task-1",
1153+
runId: "run-1",
1154+
apiHost: "https://app.example.com",
1155+
teamId: 2,
1156+
});
1157+
1158+
const hasWatcher = (): boolean =>
1159+
(service as unknown as { watchers: Map<string, unknown> }).watchers.has(
1160+
"task-1:run-1",
1161+
);
1162+
await waitFor(() => !hasWatcher(), 10_000);
1163+
1164+
expect(mockStreamTokenFetch.mock.calls.length).toBe(2);
1165+
const [secondUrl] = mockStreamFetch.mock.calls[1];
1166+
expect(String(secondUrl)).toContain("https://app.example.com/api/");
1167+
});
1168+
10201169
it("stream-end still stops the watcher in legacy mode", async () => {
10211170
vi.useFakeTimers();
10221171

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,32 @@ export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> {
823823
return;
824824
}
825825

826+
// Proxy-leg 401: the run-scoped read token expired mid-watch (short TTL) or its signing
827+
// key rotated. Re-resolve the read target — which mints a fresh token, or routes back to
828+
// Django if the rollout flag was turned off meanwhile — instead of failing. Django-leg
829+
// 401 stays fatal below (the user session expired). Counting the attempt bounds
830+
// persistent proxy 401s by the transport reconnect budget.
831+
const unauthorizedWatcher = this.watchers.get(key);
832+
if (
833+
error instanceof CloudTaskStreamError &&
834+
error.status === 401 &&
835+
unauthorizedWatcher?.streamBaseUrl
836+
) {
837+
unauthorizedWatcher.streamTargetResolved = false;
838+
unauthorizedWatcher.streamBaseUrl = null;
839+
unauthorizedWatcher.streamReadToken = null;
840+
unauthorizedWatcher.durableStreamEnabled = false;
841+
this.log.info("Cloud task stream proxy token rejected, re-resolving", {
842+
key,
843+
});
844+
await this.handleStreamCompletion(key, {
845+
reconnectOnDisconnect: true,
846+
reconnectError: error,
847+
countReconnectAttempt: true,
848+
});
849+
return;
850+
}
851+
826852
if (
827853
error instanceof CloudTaskStreamError &&
828854
error.details.autoRetry === false

0 commit comments

Comments
 (0)