fix(messages): resend failed messages through provider (#15432)

Failed messages on external provider-delivery channels can appear sent
after an agent selects Retry even though the retry never reaches the
provider. This change clears the stale provider delivery ID before
retrying the existing message, allowing the channel service to create a
fresh provider attempt and store its new ID.

API and WebWidget messages are excluded because their `source_id` values
may be client-supplied correlation identifiers and their retry jobs do
not assign replacement provider IDs. Concurrent or stale retry requests
are also guarded so only the request that successfully claims a failed
message can enqueue delivery.

Fixes https://github.com/chatwoot/chatwoot/issues/14120

Related:
https://linear.app/chatwoot/issue/CW-6896/retrying-a-failed-whatsapp-message-silently-succeeds-locally-but-never

### Things to know

- Applies to external provider-delivery channels, including direct
WhatsApp and Twilio WhatsApp.
- Preserves `source_id` for `Channel::Api` and `Channel::WebWidget`.
- Reuses the existing Chatwoot message record while allowing the
provider to return a new message ID.
- Logs the cleared provider ID and Chatwoot message ID when a stale
provider ID is removed.

### How to reproduce

1. Send a message that the provider accepts and later marks as failed
through a status callback.
2. Select Retry on the failed message.
3. Observe that Chatwoot changes the message to sent but does not
contact the provider because the previous provider message ID remains
present.

### How to test

1. In an external provider inbox, retry a failed message that has an
existing provider message ID.
2. Confirm the old provider ID is cleared before delivery and the retry
reaches the provider.
3. Confirm the provider assigns a fresh message ID and subsequent status
callbacks update the retried message.
4. Retry a failed API or WebWidget message and confirm its existing
`source_id` is preserved.
5. Trigger concurrent retry requests for the same failed message and
confirm only one delivery job is queued.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-08-13 11:35:52 +05:30
committed by GitHub
parent 10e9cb5b60
commit 046c6c754f

View File

@@ -28,10 +28,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def retry
return if message.blank?
service = Messages::StatusUpdateService.new(message, 'sent')
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
::SendReplyJob.perform_later(message.id) if claim_message_retry
rescue StandardError => e
render_could_not_create_error(e.message)
end
@@ -67,6 +64,22 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
@message_finder ||= MessageFinder.new(@conversation, params)
end
def claim_message_retry
message.with_lock do
next false unless message.failed?
Messages::StatusUpdateService.new(message, 'sent').perform
previous_source_id = message.source_id
retry_attributes = { content_attributes: {} }
retry_attributes[:source_id] = nil unless @conversation.inbox.api? || @conversation.inbox.web_widget?
message.update!(retry_attributes)
if retry_attributes.key?(:source_id) && previous_source_id.present?
Rails.logger.info "Cleared older source ID #{previous_source_id} for message #{message.id}"
end
true
end
end
def permitted_params
params.permit(:id, :target_language, :status, :external_error)
end