From dd94e5ff2d1474816237595d99ebce898af32f01 Mon Sep 17 00:00:00 2001 From: Xavier Hamel <76625630+xavierhamel@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:47:59 -0400 Subject: [PATCH 1/2] fix(integrations/linear): Fix event error in Linear integration (#14241) Co-authored-by: Xavier Hamel --- integrations/linear/integration.definition.ts | 2 +- integrations/linear/src/handler.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/integrations/linear/integration.definition.ts b/integrations/linear/integration.definition.ts index 05d3fe2f87c..1da6dac4a8a 100644 --- a/integrations/linear/integration.definition.ts +++ b/integrations/linear/integration.definition.ts @@ -7,7 +7,7 @@ import { actions, channels, events, configuration, configurations, user, states, export default new IntegrationDefinition({ name: 'linear', - version: '1.1.1', + version: '1.1.2', title: 'Linear', description: 'Manage your projects autonomously. Have your bot participate in discussions, manage issues and teams, and track progress.', diff --git a/integrations/linear/src/handler.ts b/integrations/linear/src/handler.ts index 224caa7859a..f09115338b7 100644 --- a/integrations/linear/src/handler.ts +++ b/integrations/linear/src/handler.ts @@ -56,7 +56,13 @@ export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client return } - if (linearEvent.type === 'comment' && linearEvent.action === 'create') { + if ( + linearEvent.type === 'comment' && + linearEvent.action === 'create' && + // Comment can be added in projects which are not issues. Therefore they don't have issueId or + // issue.id. Comments in projects are currently ignored. + (linearEvent.data.issue || linearEvent.data.issueId) + ) { const linearCommentId = linearEvent.data.id const issueConversationId = linearEvent.data.issueId || linearEvent.data.issue.id const content = linearEvent.data.body From 6fb3e252dc77f8d2f890904c8e99cc864874df85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Levasseur?= Date: Thu, 18 Sep 2025 11:55:28 -0400 Subject: [PATCH 2/2] feat(integrations/whatsapp): retry for whatsapp throughput errors (#14246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gordon-BP Co-authored-by: Sébastien Poitras --- .../whatsapp/integration.definition.ts | 2 +- integrations/whatsapp/src/channels/channel.ts | 39 ++++++++++- integrations/whatsapp/src/repeat.test.ts | 68 +++++++++++++++++++ integrations/whatsapp/src/repeat.ts | 36 ++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 integrations/whatsapp/src/repeat.test.ts create mode 100644 integrations/whatsapp/src/repeat.ts diff --git a/integrations/whatsapp/integration.definition.ts b/integrations/whatsapp/integration.definition.ts index d13f3d1094b..8ab9d8067aa 100644 --- a/integrations/whatsapp/integration.definition.ts +++ b/integrations/whatsapp/integration.definition.ts @@ -85,7 +85,7 @@ const defaultBotPhoneNumberId = { export default new IntegrationDefinition({ name: INTEGRATION_NAME, - version: '4.2.5', + version: '4.2.6', title: 'WhatsApp', description: 'Send and receive messages through WhatsApp.', icon: 'icon.svg', diff --git a/integrations/whatsapp/src/channels/channel.ts b/integrations/whatsapp/src/channels/channel.ts index a91897a7681..0354c2e94e2 100644 --- a/integrations/whatsapp/src/channels/channel.ts +++ b/integrations/whatsapp/src/channels/channel.ts @@ -16,6 +16,7 @@ import { getAuthenticatedWhatsappClient } from '../auth' import { WHATSAPP } from '../misc/constants' import { convertMarkdownToWhatsApp } from '../misc/markdown-to-whatsapp-rtf' import { sleep } from '../misc/util' +import { repeat } from '../repeat' import * as card from './message-types/card' import * as carousel from './message-types/carousel' import * as choice from './message-types/choice' @@ -128,6 +129,24 @@ type SendMessageProps = { message?: OutgoingMessage } +// From https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes#throttling-errors +// Only contains codes for errors that can be recovered from by waiting +const THROTTLING_CODES = new Set([ + 80007, // WABA/app rate limit + 130429, // Cloud API throughput reached + 131056, // Pair rate limit (same sender↔recipient) +]) + +function backoffDelayMs(attempt: number) { + // Helper function for backoff delay with jitter. Uses Meta recommendation for exponential curve + // https://developers.facebook.com/docs/whatsapp/cloud-api/overview/?locale=en_US#pair-rate-limits + const baseMs = Math.pow(4, attempt) * 1000 + const jitter = 0.75 + Math.random() * 0.5 + return Math.floor(baseMs * jitter) +} + +const MAX_ATTEMPT = 3 + async function _send({ client, ctx, conversation, message, ack, logger }: SendMessageProps) { if (!message) { logger.forBot().debug('No message to send') @@ -153,7 +172,25 @@ async function _send({ client, ctx, conversation, message, ack, logger }: SendMe return } - const feedback = await whatsapp.sendMessage(botPhoneNumberId, userPhoneNumber, message) + const feedback = await repeat( + async (i) => { + if (i > 0) { + logger.forBot().info(`Retrying to send ${messageType} message to WhatsApp (attempt ${i + 1}/${MAX_ATTEMPT})...`) + } + + const result = await whatsapp.sendMessage(botPhoneNumberId, userPhoneNumber, message) + const repeat = 'error' in result && THROTTLING_CODES.has(result.error?.code ?? 0) + return { + repeat, + result, + } + }, + { + maxIterations: MAX_ATTEMPT, + backoff: backoffDelayMs, + } + ) + if ('error' in feedback) { logger .forBot() diff --git a/integrations/whatsapp/src/repeat.test.ts b/integrations/whatsapp/src/repeat.test.ts new file mode 100644 index 00000000000..b5860de4a79 --- /dev/null +++ b/integrations/whatsapp/src/repeat.test.ts @@ -0,0 +1,68 @@ +import { test, expect, vi } from 'vitest' +import { repeat } from './repeat' + +test('repeat calls the function the specified number of times', async () => { + const callback = vi + .fn() + .mockResolvedValueOnce({ repeat: true, result: 1 }) + .mockResolvedValueOnce({ repeat: true, result: 2 }) + .mockResolvedValueOnce({ repeat: false, result: 3 }) + + const backoff = vi.fn().mockReturnValue(0) + + const result = await repeat(callback, { maxIterations: 5, backoff }) + + expect(result).toBe(3) + expect(callback).toHaveBeenCalledTimes(3) + expect(backoff).toHaveBeenCalledTimes(2) + expect(backoff).toHaveBeenNthCalledWith(1, 1, 1) + expect(backoff).toHaveBeenNthCalledWith(2, 2, 2) +}) + +test('repeat stops after maxIterations', async () => { + const callback = vi + .fn() + .mockResolvedValueOnce({ repeat: true, result: 1 }) + .mockResolvedValueOnce({ repeat: true, result: 2 }) + .mockResolvedValueOnce({ repeat: false, result: 3 }) + + const backoff = vi.fn().mockReturnValue(0) + + const result = await repeat(callback, { maxIterations: 2, backoff }) + + expect(result).toBe(2) + expect(callback).toHaveBeenCalledTimes(2) + expect(backoff).toHaveBeenCalledTimes(1) + expect(backoff).toHaveBeenNthCalledWith(1, 1, 1) +}) + +test('repeat uses the backoff function to determine delay', async () => { + const timestamps: number[] = [] + + let t0 = Date.now() + let iteration = 0 + let cb = async () => { + timestamps.push(Date.now() - t0) + t0 = Date.now() + + iteration++ + if (iteration < 3) { + return { repeat: true, result: 'failure' } + } + return { repeat: false, result: 'success' } + } + + const backoff = (iteration: number) => iteration * 100 + + const result = await repeat(cb, { maxIterations: 5, backoff }) + + expect(result).toBe('success') + expect(timestamps.length).toBe(3) + expect(timestamps[0]).toBeLessThan(50) // First call, no delay + + expect(timestamps[1]).toBeGreaterThanOrEqual(100) + expect(timestamps[1]).toBeLessThan(150) + + expect(timestamps[2]).toBeGreaterThanOrEqual(200) + expect(timestamps[2]).toBeLessThan(250) +}) diff --git a/integrations/whatsapp/src/repeat.ts b/integrations/whatsapp/src/repeat.ts new file mode 100644 index 00000000000..1a462f35035 --- /dev/null +++ b/integrations/whatsapp/src/repeat.ts @@ -0,0 +1,36 @@ +import { sleep } from './misc/util' + +export type RepeatResult = { + repeat: boolean + result: T +} + +export type RepeatCallback = (iteration: number) => Promise> +export type BackoffCallback = (iteration: number, result: T) => number + +export type RepeatOptions = { + maxIterations: number + backoff: BackoffCallback +} + +/** + * Like a retry function, but with a slightly different semantics. + * The callback function is repeated based on its return value, not on whether it throws or not. + */ +export const repeat = async (callback: RepeatCallback, options: RepeatOptions): Promise => { + let iteration = 0 + for (;;) { + const res = await callback(iteration) + if (!res.repeat) { + return res.result + } + + iteration++ + if (iteration >= options.maxIterations) { + return res.result + } + + const delay = options.backoff(iteration, res.result) + await sleep(delay) + } +}