Skip to content

Commit 676ed34

Browse files
mvanhornsteipete
authored andcommitted
fix(slack): treat Slack Connect finalize errors as benign in stopSlackStream
When Slack's chat.stopStream fails with user_not_found (Slack Connect DM recipients), team_not_found (cross-workspace shared channels), or missing_recipient_user_id (DM closed mid-stream), the text already delivered via append() is still visible to the user. Swallow those specific codes and mark the session stopped rather than surfacing a spurious 'slack-stream: failed to stop stream' error in dispatch. Other Slack API errors still propagate. Fixes #70295
1 parent 688fc28 commit 676ed34

2 files changed

Lines changed: 149 additions & 1 deletion

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { ChatStreamer } from "@slack/web-api/dist/chat-stream.js";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { stopSlackStream, type SlackStreamSession } from "./streaming.js";
4+
5+
function makeSession(stopImpl: () => Promise<void>): SlackStreamSession {
6+
return {
7+
streamer: {
8+
append: vi.fn(async () => {}),
9+
stop: vi.fn(stopImpl),
10+
} as unknown as ChatStreamer,
11+
channel: "C123",
12+
threadTs: "1700000000.000100",
13+
stopped: false,
14+
};
15+
}
16+
17+
function slackApiError(code: string): Error {
18+
const err = new Error(`An API error occurred: ${code}`);
19+
(err as unknown as { data: { error: string } }).data = { error: code };
20+
return err;
21+
}
22+
23+
describe("stopSlackStream finalize error handling", () => {
24+
it("swallows user_not_found (Slack Connect DMs) and marks the session stopped", async () => {
25+
const session = makeSession(async () => {
26+
throw slackApiError("user_not_found");
27+
});
28+
await expect(stopSlackStream({ session })).resolves.toBeUndefined();
29+
expect(session.stopped).toBe(true);
30+
});
31+
32+
it("swallows team_not_found (Slack Connect cross-workspace) and marks stopped", async () => {
33+
const session = makeSession(async () => {
34+
throw slackApiError("team_not_found");
35+
});
36+
await expect(stopSlackStream({ session })).resolves.toBeUndefined();
37+
expect(session.stopped).toBe(true);
38+
});
39+
40+
it("swallows missing_recipient_user_id (DM closed mid-stream) and marks stopped", async () => {
41+
const session = makeSession(async () => {
42+
throw slackApiError("missing_recipient_user_id");
43+
});
44+
await expect(stopSlackStream({ session })).resolves.toBeUndefined();
45+
expect(session.stopped).toBe(true);
46+
});
47+
48+
it("re-throws unexpected Slack API errors so callers can log them", async () => {
49+
const session = makeSession(async () => {
50+
throw slackApiError("not_authed");
51+
});
52+
await expect(stopSlackStream({ session })).rejects.toThrow(/not_authed/);
53+
// Session is still marked stopped so retries do not re-enter streamer.stop.
54+
expect(session.stopped).toBe(true);
55+
});
56+
57+
it("re-throws non-Slack-shaped errors unchanged", async () => {
58+
const session = makeSession(async () => {
59+
throw new Error("socket reset");
60+
});
61+
await expect(stopSlackStream({ session })).rejects.toThrow(/socket reset/);
62+
expect(session.stopped).toBe(true);
63+
});
64+
65+
it("returns a no-op on an already-stopped session", async () => {
66+
const stop = vi.fn(async () => {});
67+
const session: SlackStreamSession = {
68+
streamer: { append: vi.fn(async () => {}), stop } as unknown as ChatStreamer,
69+
channel: "C123",
70+
threadTs: "1700000000.000100",
71+
stopped: true,
72+
};
73+
await expect(stopSlackStream({ session })).resolves.toBeUndefined();
74+
expect(stop).not.toHaveBeenCalled();
75+
});
76+
});

extensions/slack/src/streaming.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ export async function appendSlackStream(params: AppendSlackStreamParams): Promis
130130
*
131131
* After calling this the stream message becomes a normal Slack message.
132132
* Optionally include final text to append before stopping.
133+
*
134+
* If Slack's `chat.stopStream` responds with a known benign finalize error
135+
* (e.g. `user_not_found` for Slack Connect recipients - see issue #70295),
136+
* any text already delivered via `append()` stays visible and the session
137+
* is marked stopped. Other Slack API errors still propagate so the caller
138+
* can record them.
133139
*/
134140
export async function stopSlackStream(params: StopSlackStreamParams): Promise<void> {
135141
const { session, text } = params;
@@ -147,7 +153,73 @@ export async function stopSlackStream(params: StopSlackStreamParams): Promise<vo
147153
}`,
148154
);
149155

150-
await session.streamer.stop(text ? { markdown_text: text } : undefined);
156+
try {
157+
await session.streamer.stop(text ? { markdown_text: text } : undefined);
158+
} catch (err) {
159+
if (isBenignSlackFinalizeError(err)) {
160+
logVerbose(
161+
`slack-stream: finalize rejected by Slack (${formatSlackError(err)}); ` +
162+
"appended text remains visible, treating stream as stopped",
163+
);
164+
return;
165+
}
166+
throw err;
167+
}
151168

152169
logVerbose("slack-stream: stream stopped");
153170
}
171+
172+
// ---------------------------------------------------------------------------
173+
// Finalize error classification
174+
// ---------------------------------------------------------------------------
175+
176+
/**
177+
* Slack API error codes that indicate `chat.stopStream` cannot finalize the
178+
* stream for the current recipient/team, but any `chat.appendStream` calls
179+
* that already landed are still visible to the user. Treat these as benign
180+
* at the dispatch layer so the reply is not reported as an error when text
181+
* did get through.
182+
*/
183+
const BENIGN_SLACK_FINALIZE_ERROR_CODES = new Set<string>([
184+
// Slack Connect recipients: finalize fails because the external user id
185+
// is not resolvable in the host workspace (#70295).
186+
"user_not_found",
187+
// Slack Connect team mismatch in shared channels.
188+
"team_not_found",
189+
// DMs that closed between stream start and stop.
190+
"missing_recipient_user_id",
191+
]);
192+
193+
function isBenignSlackFinalizeError(err: unknown): boolean {
194+
const code = extractSlackErrorCode(err);
195+
return code !== undefined && BENIGN_SLACK_FINALIZE_ERROR_CODES.has(code);
196+
}
197+
198+
function extractSlackErrorCode(err: unknown): string | undefined {
199+
if (!err || typeof err !== "object") {
200+
return undefined;
201+
}
202+
const record = err as Record<string, unknown>;
203+
// @slack/web-api errors expose `data.error` with the Slack error code.
204+
if (record.data && typeof record.data === "object") {
205+
const inner = (record.data as Record<string, unknown>).error;
206+
if (typeof inner === "string") {
207+
return inner;
208+
}
209+
}
210+
// Fallback: parse from message string ("An API error occurred: user_not_found").
211+
const message = typeof record.message === "string" ? record.message : "";
212+
const match = message.match(/An API error occurred:\s*([a-z_][a-z0-9_]*)/i);
213+
return match?.[1];
214+
}
215+
216+
function formatSlackError(err: unknown): string {
217+
const code = extractSlackErrorCode(err);
218+
if (code) {
219+
return code;
220+
}
221+
if (err instanceof Error) {
222+
return err.message;
223+
}
224+
return String(err);
225+
}

0 commit comments

Comments
 (0)