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.
Package version:
@slack/webhook@8.0.0(behavior also present onmain)Node version: 22
Reproduced via:
slackapi/slack-github-action@v4.0.0, which bundles this packageOriginal 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: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 neitherAbortErrornorSlackWebhookError, so the catch block wraps it in the retryableWebhookTriggerRequestError:p-retrythen re-POSTs — re-triggering the workflow on every attempt. Withretries: 5(fiveRetriesInFiveMinutes={ retries: 5, factor: 3.86 }, defaultminTimeout: 1000ms), that is ~5–6 duplicate deliveries spread over the backoff schedule (1s, ~3.9s, ~14.9s, ~57.5s, ~222s ≈ ~5 minutes total) beforesend()finally rejects.Why v7 did not have this bug
@slack/webhook@7.2.0(axios) read the body withreturn 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 toawait 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 usesawait response.text()rather than.json(). ForWebhookTrigger, read text and guard the parse, for example: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.tsonly 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 assertingsend()resolves once (no retries) would cover this.