fix: reduce Slack WebClient retries

This commit is contained in:
Peter Steinberger
2026-01-23 06:28:01 +00:00
parent 4912e85ac8
commit 68ea6e521b
11 changed files with 94 additions and 15 deletions

View File

@@ -44,6 +44,7 @@ Docs: https://docs.clawd.bot
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)

View File

@@ -1,8 +1,9 @@
import { WebClient } from "@slack/web-api";
import type { WebClient } from "@slack/web-api";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js";
import { sendMessageSlack } from "./send.js";
import { resolveSlackBotToken } from "./token.js";
@@ -56,7 +57,7 @@ function normalizeEmoji(raw: string) {
async function getClient(opts: SlackActionClientOpts = {}) {
const token = resolveToken(opts.token, opts.accountId);
return opts.client ?? new WebClient(token);
return opts.client ?? createSlackWebClient(token);
}
async function resolveBotUserId(client: WebClient) {

46
src/slack/client.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@slack/web-api", () => {
const WebClient = vi.fn(function WebClientMock(
this: Record<string, unknown>,
token: string,
options?: Record<string, unknown>,
) {
this.token = token;
this.options = options;
});
return { WebClient };
});
const slackWebApi = await import("@slack/web-api");
const { createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
await import("./client.js");
const WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
describe("slack web client config", () => {
it("applies the default retry config when none is provided", () => {
const options = resolveSlackWebClientOptions();
expect(options.retryConfig).toEqual(SLACK_DEFAULT_RETRY_OPTIONS);
});
it("respects explicit retry config overrides", () => {
const customRetry = { retries: 0 };
const options = resolveSlackWebClientOptions({ retryConfig: customRetry });
expect(options.retryConfig).toBe(customRetry);
});
it("passes merged options into WebClient", () => {
createSlackWebClient("xoxb-test", { timeout: 1234 });
expect(WebClient).toHaveBeenCalledWith(
"xoxb-test",
expect.objectContaining({
timeout: 1234,
retryConfig: SLACK_DEFAULT_RETRY_OPTIONS,
}),
);
});
});

20
src/slack/client.ts Normal file
View File

@@ -0,0 +1,20 @@
import { type RetryOptions, type WebClientOptions, WebClient } from "@slack/web-api";
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
retries: 2,
factor: 2,
minTimeout: 500,
maxTimeout: 3000,
randomize: true,
};
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
return {
...options,
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
};
}
export function createSlackWebClient(token: string, options: WebClientOptions = {}) {
return new WebClient(token, resolveSlackWebClientOptions(options));
}

View File

@@ -1,4 +1,4 @@
import { WebClient } from "@slack/web-api";
import { createSlackWebClient } from "./client.js";
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
@@ -61,7 +61,7 @@ export async function listSlackDirectoryPeersLive(
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) return [];
const client = new WebClient(token);
const client = createSlackWebClient(token);
const query = normalizeQuery(params.query);
const members: SlackUser[] = [];
let cursor: string | undefined;
@@ -119,7 +119,7 @@ export async function listSlackDirectoryGroupsLive(
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) return [];
const client = new WebClient(token);
const client = createSlackWebClient(token);
const query = normalizeQuery(params.query);
const channels: SlackChannel[] = [];
let cursor: string | undefined;

View File

@@ -17,6 +17,7 @@ import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { resolveSlackWebClientOptions } from "../client.js";
import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js";
@@ -130,16 +131,19 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
endpoints: slackWebhookPath,
})
: null;
const clientOptions = resolveSlackWebClientOptions();
const app = new App(
slackMode === "socket"
? {
token: botToken,
appToken,
socketMode: true,
clientOptions,
}
: {
token: botToken,
receiver: receiver ?? undefined,
clientOptions,
},
);
const slackHttpHandler =

View File

@@ -1,4 +1,4 @@
import { WebClient } from "@slack/web-api";
import { createSlackWebClient } from "./client.js";
export type SlackProbe = {
ok: boolean;
@@ -21,7 +21,7 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
}
export async function probeSlack(token: string, timeoutMs = 2500): Promise<SlackProbe> {
const client = new WebClient(token);
const client = createSlackWebClient(token);
const start = Date.now();
try {
const result = await withTimeout(client.auth.test(), timeoutMs);

View File

@@ -1,4 +1,6 @@
import { WebClient } from "@slack/web-api";
import type { WebClient } from "@slack/web-api";
import { createSlackWebClient } from "./client.js";
export type SlackChannelLookup = {
id: string;
@@ -84,7 +86,7 @@ export async function resolveSlackChannelAllowlist(params: {
entries: string[];
client?: WebClient;
}): Promise<SlackChannelResolution[]> {
const client = params.client ?? new WebClient(params.token);
const client = params.client ?? createSlackWebClient(params.token);
const channels = await listSlackChannels(client);
const results: SlackChannelResolution[] = [];

View File

@@ -1,4 +1,6 @@
import { WebClient } from "@slack/web-api";
import type { WebClient } from "@slack/web-api";
import { createSlackWebClient } from "./client.js";
export type SlackUserLookup = {
id: string;
@@ -101,7 +103,7 @@ export async function resolveSlackUserAllowlist(params: {
entries: string[];
client?: WebClient;
}): Promise<SlackUserResolution[]> {
const client = params.client ?? new WebClient(params.token);
const client = params.client ?? createSlackWebClient(params.token);
const users = await listSlackUsers(client);
const results: SlackUserResolution[] = [];

View File

@@ -1,4 +1,6 @@
import { WebClient } from "@slack/web-api";
import type { WebClient } from "@slack/web-api";
import { createSlackWebClient } from "./client.js";
export type SlackScopesResult = {
ok: boolean;
@@ -81,7 +83,7 @@ export async function fetchSlackScopes(
token: string,
timeoutMs: number,
): Promise<SlackScopesResult> {
const client = new WebClient(token, { timeout: timeoutMs });
const client = createSlackWebClient(token, { timeout: timeoutMs });
const attempts: SlackScopesSource[] = ["auth.scopes", "apps.permissions.info"];
const errors: string[] = [];

View File

@@ -1,4 +1,4 @@
import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
@@ -6,6 +6,7 @@ import { logVerbose } from "../globals.js";
import { loadWebMedia } from "../web/media.js";
import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { parseSlackTarget } from "./targets.js";
import { resolveSlackBotToken } from "./token.js";
@@ -137,7 +138,7 @@ export async function sendMessageSlack(
fallbackToken: account.botToken,
fallbackSource: account.botTokenSource,
});
const client = opts.client ?? new WebClient(token);
const client = opts.client ?? createSlackWebClient(token);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(client, recipient);
const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId);