From 5b56f64838cd03a96a1eb8da84c49a112a8dec32 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 17 Nov 2025 14:43:23 -0800 Subject: [PATCH] feat: Customizable webhook timeout configuration (#12777) ## Summary - Ability to configure the webhook timeout for Chatwoot self hosted installations fixes: https://github.com/chatwoot/chatwoot/issues/12754 --- .../super_admin/app_configs_controller.rb | 2 +- config/installation_config.yml | 5 ++ lib/webhooks/trigger.rb | 9 +++- spec/lib/webhooks/trigger_spec.rb | 53 ++++++++++++++++--- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 483c300bd..3cf360594 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -47,7 +47,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController @allowed_configs = mapping.fetch( @config, - %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS MAXIMUM_FILE_UPLOAD_SIZE] + %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE] ) end end diff --git a/config/installation_config.yml b/config/installation_config.yml index 48bb7f5bd..9b84ddc2b 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -79,6 +79,11 @@ display_title: 'System events Webhook URL' description: 'The URL to which the system events like new accounts created will be sent' locked: false +- name: WEBHOOK_TIMEOUT + value: 5 + display_title: 'Webhook request timeout (seconds)' + description: 'Maximum time Chatwoot waits for a webhook response before failing the request' + locked: false - name: DIRECT_UPLOADS_ENABLED type: boolean value: false diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index 95c399d54..54bd7499d 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -26,7 +26,7 @@ class Webhooks::Trigger url: @url, payload: @payload.to_json, headers: { content_type: :json, accept: :json }, - timeout: 5 + timeout: webhook_timeout ) end @@ -73,4 +73,11 @@ class Webhooks::Trigger def message_id @payload[:id] end + + def webhook_timeout + raw_timeout = GlobalConfig.get_value('WEBHOOK_TIMEOUT') + timeout = raw_timeout.presence&.to_i + + timeout&.positive? ? timeout : 5 + end end diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 224a35e07..3a4acf4e9 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -13,9 +13,12 @@ describe Webhooks::Trigger do let(:webhook_type) { :api_inbox_webhook } let!(:url) { 'https://test.com' } let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') } + let(:default_timeout) { 5 } + let(:webhook_timeout) { default_timeout } before do ActiveJob::Base.queue_adapter = :test + allow(GlobalConfig).to receive(:get_value).with('WEBHOOK_TIMEOUT').and_return(webhook_timeout) end after do @@ -33,7 +36,7 @@ describe Webhooks::Trigger do url: url, payload: payload.to_json, headers: { content_type: :json, accept: :json }, - timeout: 5 + timeout: webhook_timeout ).once trigger.execute(url, payload, webhook_type) end @@ -47,7 +50,7 @@ describe Webhooks::Trigger do url: url, payload: payload.to_json, headers: { content_type: :json, accept: :json }, - timeout: 5 + timeout: webhook_timeout ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed') @@ -62,7 +65,7 @@ describe Webhooks::Trigger do url: url, payload: payload.to_json, headers: { content_type: :json, accept: :json }, - timeout: 5 + timeout: webhook_timeout ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed') end @@ -80,7 +83,7 @@ describe Webhooks::Trigger do url: url, payload: payload.to_json, headers: { content_type: :json, accept: :json }, - timeout: 5 + timeout: webhook_timeout ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once expect do @@ -105,7 +108,7 @@ describe Webhooks::Trigger do url: url, payload: payload.to_json, headers: { content_type: :json, accept: :json }, - timeout: 5 + timeout: webhook_timeout ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once expect do @@ -128,9 +131,47 @@ describe Webhooks::Trigger do url: url, payload: payload.to_json, headers: { content_type: :json, accept: :json }, - timeout: 5 + timeout: webhook_timeout ).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once expect { trigger.execute(url, payload, webhook_type) }.not_to(change { message.reload.status }) end + + context 'when webhook timeout configuration is blank' do + let(:webhook_timeout) { nil } + + it 'falls back to default timeout' do + payload = { hello: :hello } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: default_timeout + ).once + + trigger.execute(url, payload, webhook_type) + end + end + + context 'when webhook timeout configuration is invalid' do + let(:webhook_timeout) { -1 } + + it 'falls back to default timeout' do + payload = { hello: :hello } + + expect(RestClient::Request).to receive(:execute) + .with( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: default_timeout + ).once + + trigger.execute(url, payload, webhook_type) + end + end end