mongodb/mongo-ruby-driver

View on GitHub
lib/mongo/error/operation_failure.rb

Summary

Maintainability
A
45 mins
Test Coverage
# frozen_string_literal: true
# rubocop:todo all

# Copyright (C) 2015-2020 MongoDB Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
require 'mongo/error/read_write_retryable'

module Mongo
  class Error

    # Raised when an operation fails for some reason.
    #
    # @since 2.0.0
    class OperationFailure < Error
      extend Forwardable
      include SdamErrorDetection
      include ReadWriteRetryable

      def_delegators :@result, :operation_time

      # @!method connection_description
      #
      # @return [ Server::Description ] Server description of the server that
      #   the operation that this exception refers to was performed on.
      #
      # @api private
      def_delegator :@result, :connection_description

      # @return [ Integer ] The error code parsed from the document.
      #
      # @since 2.6.0
      attr_reader :code

      # @return [ String ] The error code name parsed from the document.
      #
      # @since 2.6.0
      attr_reader :code_name

      # @return [ String ] The server-returned error message
      #   parsed from the response.
      #
      # @api experimental
      attr_reader :server_message

      # Error codes and code names that should result in a failing getMore
      # command on a change stream NOT being resumed.
      #
      # @api private
      CHANGE_STREAM_RESUME_ERRORS = [
        {code_name: 'HostUnreachable', code: 6},
        {code_name: 'HostNotFound', code: 7},
        {code_name: 'NetworkTimeout', code: 89},
        {code_name: 'ShutdownInProgress', code: 91},
        {code_name: 'PrimarySteppedDown', code: 189},
        {code_name: 'ExceededTimeLimit', code: 262},
        {code_name: 'SocketException', code: 9001},
        {code_name: 'NotMaster', code: 10107},
        {code_name: 'InterruptedAtShutdown', code: 11600},
        {code_name: 'InterruptedDueToReplStateChange', code: 11602},
        {code_name: 'NotPrimaryNoSecondaryOk', code: 13435},
        {code_name: 'NotMasterOrSecondary', code: 13436},

        {code_name: 'StaleShardVersion', code: 63},
        {code_name: 'FailedToSatisfyReadPreference', code: 133},
        {code_name: 'StaleEpoch', code: 150},
        {code_name: 'RetryChangeStream', code: 234},
        {code_name: 'StaleConfig', code: 13388},
      ].freeze

      # Change stream can be resumed when these error messages are encountered.
      #
      # @since 2.6.0
      # @api private
      CHANGE_STREAM_RESUME_MESSAGES = ReadWriteRetryable::WRITE_RETRY_MESSAGES

      # Can the change stream on which this error occurred be resumed,
      # provided the operation that triggered this error was a getMore?
      #
      # @example Is the error resumable for the change stream?
      #   error.change_stream_resumable?
      #
      # @return [ true, false ] Whether the error is resumable.
      #
      # @since 2.6.0
      def change_stream_resumable?
        if @result && @result.is_a?(Mongo::Operation::GetMore::Result)
          # CursorNotFound exceptions are always resumable because the server
          # is not aware of the cursor id, and thus cannot determine if
          # the cursor is a change stream and cannot add the
          # ResumableChangeStreamError label.
          return true if code == 43

          # Connection description is not populated for unacknowledged writes.
          if connection_description.max_wire_version >= 9
            label?('ResumableChangeStreamError')
          else
            change_stream_resumable_code?
          end
        else
          false
        end
      end

      def change_stream_resumable_code?
        CHANGE_STREAM_RESUME_ERRORS.any? { |e| e[:code] == code }
      end
      private :change_stream_resumable_code?

      # @return [ true | false ] Whether the failure includes a write
      #   concern error. A failure may have a top level error and a write
      #   concern error or either one of the two.
      #
      # @since 2.10.0
      def write_concern_error?
        !!@write_concern_error_document
      end

      # Returns the write concern error document as it was reported by the
      # server, if any.
      #
      # @return [ Hash | nil ] Write concern error as reported to the server.
      attr_reader :write_concern_error_document

      # @return [ Integer | nil ] The error code for the write concern error,
      #   if a write concern error is present and has a code.
      #
      # @since 2.10.0
      attr_reader :write_concern_error_code

      # @return [ String | nil ] The code name for the write concern error,
      #   if a write concern error is present and has a code name.
      #
      # @since 2.10.0
      attr_reader :write_concern_error_code_name

      # @return [ String | nil ] The details of the error.
      #   For WriteConcernErrors this is `document['writeConcernError']['errInfo']`.
      #   For WriteErrors this is `document['writeErrors'][0]['errInfo']`.
      #   For all other errors this is nil.
      attr_reader :details

      # @return [ BSON::Document | nil ] The server-returned error document.
      #
      # @api experimental
      attr_reader :document

      # Create the operation failure.
      #
      # @example Create the error object
      #   OperationFailure.new(message, result)
      #
      # @example Create the error object with a code and a code name
      #   OperationFailure.new(message, result, :code => code, :code_name => code_name)
      #
      # @param [ String ] message The error message.
      # @param [ Operation::Result ] result The result object.
      # @param [ Hash ] options Additional parameters.
      #
      # @option options [ Integer ] :code Error code.
      # @option options [ String ] :code_name Error code name.
      # @option options [ BSON::Document ] :document The server-returned
      #   error document.
      # @option options [ String ] server_message The server-returned
      #   error message parsed from the response.
      # @option options [ Hash ] :write_concern_error_document The
      #   server-supplied write concern error document, if any.
      # @option options [ Integer ] :write_concern_error_code Error code for
      #   write concern error, if any.
      # @option options [ String ] :write_concern_error_code_name Error code
      #   name for write concern error, if any.
      # @option options [ Array<String> ] :write_concern_error_labels Error
      #   labels for the write concern error, if any.
      # @option options [ Array<String> ] :labels The set of labels associated
      #   with the error.
      # @option options [ true | false ] :wtimeout Whether the error is a wtimeout.
      def initialize(message = nil, result = nil, options = {})
        @details = retrieve_details(options[:document])
        super(append_details(message, @details))

        @result = result
        @code = options[:code]
        @code_name = options[:code_name]
        @write_concern_error_document = options[:write_concern_error_document]
        @write_concern_error_code = options[:write_concern_error_code]
        @write_concern_error_code_name = options[:write_concern_error_code_name]
        @write_concern_error_labels = options[:write_concern_error_labels] || []
        @labels = options[:labels] || []
        @wtimeout = !!options[:wtimeout]
        @document = options[:document]
        @server_message = options[:server_message]
      end

      # Whether the error is a write concern timeout.
      #
      # @return [ true | false ] Whether the error is a write concern timeout.
      #
      # @since 2.7.1
      def wtimeout?
        @wtimeout
      end

      # Whether the error is MaxTimeMSExpired.
      #
      # @return [ true | false ] Whether the error is MaxTimeMSExpired.
      #
      # @since 2.10.0
      def max_time_ms_expired?
        code == 50 # MaxTimeMSExpired
      end

      # Whether the error is caused by an attempted retryable write
      # on a storage engine that does not support retryable writes.
      #
      # @return [ true | false ] Whether the error is caused by an attempted
      # retryable write on a storage engine that does not support retryable writes.
      #
      # @since 2.10.0
      def unsupported_retryable_write?
        # code 20 is IllegalOperation.
        # Note that the document is expected to be a BSON::Document, thus
        # either having string keys or providing indifferent access.
        code == 20 && server_message&.start_with?("Transaction numbers") || false
      end

      private

      # Retrieve the details from a document
      #
      # @return [ Hash | nil ] the details extracted from the document
      def retrieve_details(document)
        return nil unless document
        if wce = document['writeConcernError']
          return wce['errInfo']
        elsif we = document['writeErrors']&.first
          return we['errInfo']
        end
      end

      # Append the details to the message
      #
      # @return [ String ] the message with the details appended to it
      def append_details(message, details)
        return message unless details && message
        message + " -- #{details.to_json}"
      end
    end
  end
end