fix(slack): retry transient dns send failures

This commit is contained in:
Peter Steinberger
2026-05-02 05:15:38 +01:00
parent ed6df7dd8b
commit 374529d612
4 changed files with 176 additions and 29 deletions

View File

@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
- Slack/delivery: preserve Slack Web API missing-scope details in outbound delivery errors, so queued retry state identifies the OAuth scope to add. Fixes #62391. Thanks @alexey-pelykh.
- Slack/DMs: send text/block-only proactive DMs directly with `chat.postMessage(channel=<user id>)` while keeping conversation resolution for uploads and threaded sends. Fixes #62042. Thanks @MarkMolina.
- Slack/routing: match route bindings written with Slack target syntax such as `channel:C...`, `user:U...`, or `<@U...>`, so bound Slack peers route to the configured agent instead of `main`. Fixes #41608. Thanks @Winnsolutionsadmin.
- Slack/delivery: retry Slack Web API writes only when the SDK wraps a DNS request failure such as `EAI_AGAIN`, so transient resolver hiccups can recover without retrying platform errors that may duplicate messages. Fixes #68789. Thanks @sonnyb9.
- Slack/mentions: resolve `<!subteam^...>` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack.
- Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars.
- Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97.

View File

@@ -6,6 +6,17 @@ const { sendMessageSlack } = await import("./send.js");
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
const SLACK_TEXT_LIMIT = 8000;
function slackDnsRequestError(): Error {
return Object.assign(new Error("A request error occurred: getaddrinfo EAI_AGAIN slack.com"), {
code: "slack_webapi_request_error",
original: Object.assign(new Error("getaddrinfo EAI_AGAIN slack.com"), {
code: "EAI_AGAIN",
syscall: "getaddrinfo",
hostname: "slack.com",
}),
});
}
describe("sendMessageSlack NO_REPLY guard", () => {
it("suppresses NO_REPLY text before any Slack API call", async () => {
const client = createSlackSendTestClient();
@@ -136,6 +147,63 @@ describe("sendMessageSlack blocks", () => {
expect(result).toEqual({ messageId: "171234.567", channelId: "U123" });
});
it("retries Slack postMessage DNS request errors without enabling broad write retries", async () => {
const client = createSlackSendTestClient();
client.chat.postMessage
.mockRejectedValueOnce(slackDnsRequestError())
.mockResolvedValueOnce({ ts: "171234.999" });
const result = await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(result).toEqual({ messageId: "171234.999", channelId: "C123" });
});
it("retries Slack conversations.open DNS request errors for threaded DMs", async () => {
const client = createSlackSendTestClient();
client.conversations.open
.mockRejectedValueOnce(slackDnsRequestError())
.mockResolvedValueOnce({ channel: { id: "D123" } });
const result = await sendMessageSlack("user:U123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "171234.100",
});
expect(client.conversations.open).toHaveBeenCalledTimes(2);
expect(client.chat.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: "D123", thread_ts: "171234.100" }),
);
expect(result).toEqual({ messageId: "171234.567", channelId: "D123" });
});
it("does not retry Slack platform errors", async () => {
const client = createSlackSendTestClient();
const platformError = Object.assign(
new Error("An API error occurred: message_limit_exceeded"),
{
data: { ok: false, error: "message_limit_exceeded" },
},
);
client.chat.postMessage.mockRejectedValue(platformError);
await expect(
sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
}),
).rejects.toThrow("message_limit_exceeded");
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
});
it("derives fallback text from image blocks", async () => {
const client = createSlackSendTestClient();
await sendMessageSlack("channel:C123", "", {

View File

@@ -189,6 +189,7 @@ describe("sendMessageSlack customize-scope fallback", () => {
token: "xoxb-test",
cfg: SLACK_TEST_CFG,
client,
threadTs: "171234.100",
}),
).rejects.toThrow(
"An API error occurred: missing_scope (needed: im:write; granted: chat:write)",

View File

@@ -32,6 +32,9 @@ const SLACK_UPLOAD_SSRF_POLICY = {
allowRfc2544BenchmarkRange: true,
};
const SLACK_DM_CHANNEL_CACHE_MAX = 1024;
const SLACK_DNS_RETRY_CODES = new Set(["EAI_AGAIN", "ENOTFOUND", "UND_ERR_DNS_RESOLVE_FAILED"]);
const SLACK_DNS_RETRY_ATTEMPTS = 2;
const SLACK_DNS_RETRY_BASE_DELAY_MS = 250;
const slackDmChannelCache = new Map<string, string>();
const slackSendQueues = new Map<string, Promise<void>>();
@@ -147,6 +150,66 @@ function enrichSlackWebApiError(err: unknown): unknown {
return new Error(message);
}
function readSlackRequestErrorCode(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const code = (value as { code?: unknown }).code;
return typeof code === "string" ? code.toUpperCase() : undefined;
}
function readSlackRequestErrorMessage(value: unknown): string {
if (value instanceof Error) {
return value.message;
}
return typeof value === "string" ? value : "";
}
function hasSlackDnsRequestSignal(err: unknown): boolean {
let current: unknown = err;
const seen = new Set<unknown>();
for (let depth = 0; current && typeof current === "object" && depth < 6; depth += 1) {
if (seen.has(current)) {
return false;
}
seen.add(current);
const code = readSlackRequestErrorCode(current);
if (code && SLACK_DNS_RETRY_CODES.has(code)) {
return true;
}
const message = readSlackRequestErrorMessage(current);
if (/\b(EAI_AGAIN|ENOTFOUND|UND_ERR_DNS_RESOLVE_FAILED)\b/i.test(message)) {
return true;
}
current =
(current as { original?: unknown; cause?: unknown }).original ??
(current as { cause?: unknown }).cause;
}
return false;
}
function delaySlackDnsRetry(attempt: number): Promise<void> {
return new Promise((resolve) =>
setTimeout(resolve, SLACK_DNS_RETRY_BASE_DELAY_MS * Math.max(1, attempt)),
);
}
async function withSlackDnsRequestRetry<T>(operation: string, fn: () => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt += 1) {
try {
return await fn();
} catch (err) {
if (attempt >= SLACK_DNS_RETRY_ATTEMPTS || !hasSlackDnsRequestSignal(err)) {
throw err;
}
logVerbose(
`slack send: retrying ${operation} after transient DNS request error (${attempt + 1}/${SLACK_DNS_RETRY_ATTEMPTS})`,
);
await delaySlackDnsRetry(attempt + 1);
}
}
}
function isSlackCustomizeScopeError(err: unknown): boolean {
const data = getSlackWebApiErrorData(err);
const code = normalizeLowercaseStringOrEmpty(normalizeSlackApiString(data?.error));
@@ -182,30 +245,37 @@ async function postSlackMessageBestEffort(params: {
try {
// Slack Web API types model icon_url and icon_emoji as mutually exclusive.
// Build payloads in explicit branches so TS and runtime stay aligned.
if (params.identity?.iconUrl) {
return await postChatMessage({
...basePayload,
...(params.identity.username ? { username: params.identity.username } : {}),
icon_url: params.identity.iconUrl,
});
const identity = params.identity;
if (identity?.iconUrl) {
return await withSlackDnsRequestRetry("chat.postMessage", () =>
postChatMessage({
...basePayload,
...(identity.username ? { username: identity.username } : {}),
icon_url: identity.iconUrl,
}),
);
}
if (params.identity?.iconEmoji) {
return await postChatMessage({
...basePayload,
...(params.identity.username ? { username: params.identity.username } : {}),
icon_emoji: params.identity.iconEmoji,
});
if (identity?.iconEmoji) {
return await withSlackDnsRequestRetry("chat.postMessage", () =>
postChatMessage({
...basePayload,
...(identity.username ? { username: identity.username } : {}),
icon_emoji: identity.iconEmoji,
}),
);
}
return await postChatMessage({
...basePayload,
...(params.identity?.username ? { username: params.identity.username } : {}),
});
return await withSlackDnsRequestRetry("chat.postMessage", () =>
postChatMessage({
...basePayload,
...(identity?.username ? { username: identity.username } : {}),
}),
);
} catch (err) {
if (!hasCustomIdentity(params.identity) || !isSlackCustomizeScopeError(err)) {
throw err;
}
logVerbose("slack send: missing chat:write.customize, retrying without custom identity");
return postChatMessage(basePayload);
return withSlackDnsRequestRetry("chat.postMessage", () => postChatMessage(basePayload));
}
}
@@ -338,7 +408,9 @@ async function resolveChannelId(
if (cachedChannelId) {
return { channelId: cachedChannelId, isDm: true, cacheHit: true };
}
const response = await client.conversations.open({ users: recipient.id });
const response = await withSlackDnsRequestRetry("conversations.open", () =>
client.conversations.open({ users: recipient.id }),
);
const channelId = response.channel?.id;
if (!channelId) {
throw new Error("Failed to open Slack DM channel");
@@ -382,13 +454,16 @@ async function uploadSlackFile(params: {
// Use the 3-step upload flow (getUploadURLExternal -> POST -> completeUploadExternal)
// instead of files.uploadV2 which relies on the deprecated files.upload endpoint
// and can fail with missing_scope even when files:write is granted.
const uploadUrlResp = await params.client.files.getUploadURLExternal({
filename: uploadFileName,
length: buffer.length,
});
const uploadUrlResp = await withSlackDnsRequestRetry("files.getUploadURLExternal", () =>
params.client.files.getUploadURLExternal({
filename: uploadFileName,
length: buffer.length,
}),
);
if (!uploadUrlResp.ok || !uploadUrlResp.upload_url || !uploadUrlResp.file_id) {
throw new Error(`Failed to get upload URL: ${uploadUrlResp.error ?? "unknown error"}`);
}
const uploadFileId = uploadUrlResp.file_id;
// Upload the file content to the presigned URL
const uploadBody = new Uint8Array(buffer) as BodyInit;
@@ -413,17 +488,19 @@ async function uploadSlackFile(params: {
}
// Complete the upload and share to channel/thread
const completeResp = await params.client.files.completeUploadExternal({
files: [{ id: uploadUrlResp.file_id, title: uploadTitle }],
channel_id: params.channelId,
...(params.caption ? { initial_comment: params.caption } : {}),
...(params.threadTs ? { thread_ts: params.threadTs } : {}),
});
const completeResp = await withSlackDnsRequestRetry("files.completeUploadExternal", () =>
params.client.files.completeUploadExternal({
files: [{ id: uploadFileId, title: uploadTitle }],
channel_id: params.channelId,
...(params.caption ? { initial_comment: params.caption } : {}),
...(params.threadTs ? { thread_ts: params.threadTs } : {}),
}),
);
if (!completeResp.ok) {
throw new Error(`Failed to complete upload: ${completeResp.error ?? "unknown error"}`);
}
return uploadUrlResp.file_id;
return uploadFileId;
}
export async function sendMessageSlack(