Skip to content

@slack/webhook v8 WebhookTrigger.send() retries after a successful delivery → duplicate Workflow Builder runs + multi-minute hang #2673

Description

@WilliamBergamin

Package version: @slack/webhook@8.0.0 (behavior also present on main)
Node version: 22
Reproduced via: slackapi/slack-github-action@v4.0.0, which bundles this package

Original report: slackapi/slack-github-action#654

Description

WebhookTrigger.send() retries after a successful delivery when the trigger endpoint's 2xx response body is not .json() parseable, producing duplicate Workflow Builder runs and a multi-minute backoff hang.

What happens

On a successful send, WebhookTrigger.send() reads the body with:

return (await response.json()) as WebhookTriggerResult;

When a Workflow Builder trigger returns a 2xx whose body is not undici .json() parseable (e.g. an empty body → SyntaxError: Unexpected end of JSON input), the parse throws after the workflow has already been triggered. That error is neither AbortError nor SlackWebhookError, so the catch block wraps it in the retryable WebhookTriggerRequestError:

} catch (error) {
  if (error instanceof AbortError || error instanceof SlackWebhookError) {
    throw error;
  }
  // No response received (network/timeout): retryable.
  throw new WebhookTriggerRequestError(error instanceof Error ? error : new Error(String(error)));
}
// packages/webhook/src/WebhookTrigger.ts:86-91

p-retry then re-POSTs — re-triggering the workflow on every attempt. With retries: 5 (fiveRetriesInFiveMinutes = { retries: 5, factor: 3.86 }, default minTimeout: 1000ms), that is ~5–6 duplicate deliveries spread over the backoff schedule (1s, ~3.9s, ~14.9s, ~57.5s, ~222s ≈ ~5 minutes total) before send() finally rejects.

Why v7 did not have this bug

@slack/webhook@7.2.0 (axios) read the body with return response.data, which resolves regardless of body shape and does not throw on an empty / non-JSON body → exactly one delivery. The v8 rewrite (fetch + p-retry) switched the trigger path to await response.json(), introducing the regression. This is the sole behavioral change on the delivered-2xx path between v7 and v8, and it exactly reproduces slackapi/slack-github-action#654 (v3.0.5 → v4.0.0 upgrade, no other changes).

Suggested fix

Read the trigger body tolerantly, mirroring IncomingWebhook.send() which already uses await response.text() rather than .json(). For WebhookTrigger, read text and guard the parse, for example:

const text = await response.text();
return (text ? JSON.parse(text) : { ok: true }) as WebhookTriggerResult;

The key invariant: a delivered 2xx must never be turned into a retryable error, regardless of body shape, otherwise a single successful trigger becomes N duplicate workflow runs.

Note on test coverage

packages/webhook/src/WebhookTrigger.test.ts only ever mocks success as .reply(200, { ok: true }) (a valid JSON body), so the empty / non-JSON 2xx case is never exercised. A regression test mocking a 2xx with an empty (or non-JSON) body and asserting send() resolves once (no retries) would cover this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions