From eab859cd047502a0820c13761b3a6b50cf549b69 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Tue, 7 Jul 2026 16:20:52 +0530 Subject: [PATCH] fix: Sanitize query canceled API errors (#14933) # Pull Request Template ## Description Message creation API failures caused by PostgreSQL query cancellations now return a generic retryable error instead of exposing raw database internals such as `PG::QueryCanceled`, tuple identifiers, or relation names to customers. Existing validation failures continue to return their specific validation messages. Closes [CW-7538](https://linear.app/chatwoot/issue/CW-7538/do-not-expose-pgquerycanceled-details-in-messages-api-errors) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Reproduced the messages API path by raising `ActiveRecord::QueryCanceled` from `Messages::MessageBuilder` and verified the response contains the customer-safe localized error without `PG::QueryCanceled` details. Validation run locally: - `bundle exec rspec spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb` - `bundle exec rubocop app/controllers/concerns/request_exception_handler.rb spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb` - `git diff --check` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../concerns/request_exception_handler.rb | 26 ++++++++++++++++++- config/locales/en.yml | 2 ++ .../conversations/messages_controller_spec.rb | 16 ++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index ccab0090a..7f4e313b1 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -1,6 +1,12 @@ module RequestExceptionHandler extend ActiveSupport::Concern + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [ + 'ActiveRecord::QueryCanceled', + 'PG::QueryCanceled', + 'canceling statement due to statement timeout' + ].freeze + included do rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid end @@ -18,6 +24,9 @@ module RequestExceptionHandler rescue ActionController::ParameterMissing => e log_handled_error(e) render_could_not_create_error(e.message) + rescue ActiveRecord::QueryCanceled => e + log_handled_error(e) + render_could_not_create_error(database_query_canceled_message) ensure # to address the thread variable leak issues in Puma/Thin webserver Current.reset @@ -32,7 +41,7 @@ module RequestExceptionHandler end def render_could_not_create_error(message) - render json: { error: message }, status: :unprocessable_entity + render json: { error: sanitized_error_message(message) }, status: :unprocessable_entity end def render_payment_required(message) @@ -59,4 +68,19 @@ module RequestExceptionHandler def log_handled_error(exception) logger.info("Handled error: #{exception.inspect}") end + + def sanitized_error_message(message) + return database_query_canceled_message if database_query_canceled_message?(message) + + message + end + + def database_query_canceled_message?(message) + error_message = message.to_s + QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) } + end + + def database_query_canceled_message + I18n.t('errors.database.query_canceled') + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 7aafbef0a..42758ad1f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -82,6 +82,8 @@ en: file_too_large: 'File exceeds the maximum allowed size' unsupported_content_type: 'File type not supported (only images and videos are allowed)' unexpected: 'An unexpected error occurred' + database: + query_canceled: 'The request took too long to complete. Please try again.' saml: feature_not_enabled: SAML feature not enabled for this account sso_not_enabled: SAML SSO is not enabled for this installation diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb index 9ab8d7316..766fd3b6b 100644 --- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb @@ -51,6 +51,22 @@ RSpec.describe 'Conversation Messages API', type: :request do expect(json_response['error']).to eq('Validation failed: Content is too long (maximum is 150000 characters)') end + it 'returns a customer-safe error when the database query is canceled' do + message_builder = instance_double(Messages::MessageBuilder) + allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder) + allow(message_builder).to receive(:perform) + .and_raise(ActiveRecord::QueryCanceled, 'PG::QueryCanceled: ERROR: canceling statement due to statement timeout') + + post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id), + params: { content: 'test-message', private: true }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq(I18n.t('errors.database.query_canceled')) + expect(response.parsed_body['error']).not_to include('PG::QueryCanceled') + end + it 'creates an outgoing text message with a specific bot sender' do agent_bot = create(:agent_bot) time_stamp = Time.now.utc.to_s