lib/momento/error.ex

defmodule Momento.Error do
  @enforce_keys [:error_code, :cause, :message]
  defstruct [:error_code, :cause, :message]

  @type t() :: %__MODULE__{
          error_code: Momento.Error.Code.t(),
          cause: Exception.t() | nil,
          message: String.t()
        }

  defimpl String.Chars, for: Momento.Error do
    def to_string(error) do
      inspect(error)
    end
  end

  @spec convert(error :: Exception.t()) :: Momento.Error.t()
  def convert(%Momento.Error{} = error), do: error
  def convert(%GRPC.RPCError{} = error), do: convert_grpc_error(error)

  def convert(%Protobuf.EncodeError{} = error),
    do: invalid_argument("Unable to encode message. Check cause for details.", error)

  def convert(error),
    do: %Momento.Error{
      error_code: Momento.Error.Code.unknown(),
      cause: error,
      message: "Momento SDK Failed to process the request."
    }

  @spec convert_grpc_error(error :: GRPC.RPCError.t()) :: Momento.Error.t()
  defp convert_grpc_error(error) do
    case error.status do
      # Cancelled
      1 ->
        create_error(
          Momento.Error.Code.cancelled_error(),
          error,
          "The request was cancelled by the server; please contact Momento."
        )

      # Unknown
      2 ->
        create_error(
          Momento.Error.Code.unknown_service_error(),
          error,
          "The service returned an unknown response; please contact Momento."
        )

      # InvalidArgument
      3 ->
        create_error(
          Momento.Error.Code.invalid_argument_error(),
          error,
          "Invalid argument passed to Momento client."
        )

      # DeadlineExceeded
      4 ->
        create_error(
          Momento.Error.Code.timeout_error(),
          error,
          "The client's configured timeout was exceeded; you may need to use a Configuration with more lenient timeouts."
        )

      # NotFound
      5 ->
        create_error(
          Momento.Error.Code.not_found_error(),
          error,
          "A cache with the specified name does not exist. To resolve this error, make sure you have created the cache before attempting to use it."
        )

      # AlreadyExists
      6 ->
        create_error(
          Momento.Error.Code.already_exists_error(),
          error,
          "A cache with the specified name already exists. To resolve this error, either delete the existing cache and make a new one, or use a different name."
        )

      # PermissionDenied
      7 ->
        create_error(
          Momento.Error.Code.permission_error(),
          error,
          "Insufficient permissions to perform an operation on a cache."
        )

      # ResourceExhausted
      8 ->
        handle_limit_exceeded_error(error)

      # FailedPrecondition
      9 ->
        create_error(
          Momento.Error.Code.failed_precondition(),
          error,
          "System is not in a state required for the operation's execution."
        )

      # Aborted
      10 ->
        create_error(
          Momento.Error.Code.internal_server_error(),
          error,
          "An unexpected error occurred while trying to fulfill the request; please contact Momento."
        )

      # OutOfRange
      11 ->
        create_error(
          Momento.Error.Code.bad_request_error(),
          error,
          "The request was invalid; please contact Momento."
        )

      # Unimplemented
      12 ->
        create_error(
          Momento.Error.Code.bad_request_error(),
          error,
          "The request was invalid; please contact Momento."
        )

      # Internal
      13 ->
        create_error(
          Momento.Error.Code.internal_server_error(),
          error,
          "An unexpected error occurred while trying to fulfill the request; please contact Momento."
        )

      # Unavailable
      14 ->
        create_error(
          Momento.Error.Code.server_unavailable(),
          error,
          "The server was unable to handle the request; consider retrying. If the error persists, please contact Momento."
        )

      # DataLoss
      15 ->
        create_error(
          Momento.Error.Code.internal_server_error(),
          error,
          "An unexpected error occurred while trying to fulfill the request; please contact Momento."
        )

      # Unauthenticated
      16 ->
        create_error(
          Momento.Error.Code.authentication_error(),
          error,
          "Invalid authentication credentials to connect to the cache service."
        )
    end
  end

  defmodule LimitExceededMessages do
    @messages %{
      "topic_subscriptions_limit_exceeded" => "Topic subscriptions limit exceeded.",
      "operations_rate_limit_exceeded" => "Operations rate limit exceeded.",
      "throughput_rate_limit_exceeded" => "Throughput rate limit exceeded.",
      "request_size_limit_exceeded" => "Request size limit exceeded.",
      "item_size_limit_exceeded" => "Item size limit exceeded.",
      "element_size_limit_exceeded" => "Element size limit exceeded."
    }

    @default_message "Limit exceeded for this account."

    def determine_limit_exceeded_message(error_cause) do
      Map.get(@messages, error_cause, default_limit_exceeded_message(error_cause))
    end

    defp default_limit_exceeded_message(error_cause) do
      cond do
        String.contains?(String.downcase(error_cause), "subscribers") ->
          @messages["topic_subscriptions_limit_exceeded"]

        String.contains?(String.downcase(error_cause), "operations") ->
          @messages["operations_rate_limit_exceeded"]

        String.contains?(String.downcase(error_cause), "throughput") ->
          @messages["throughput_rate_limit_exceeded"]

        String.contains?(String.downcase(error_cause), "request limit") ->
          @messages["request_size_limit_exceeded"]

        String.contains?(String.downcase(error_cause), "item size") ->
          @messages["item_size_limit_exceeded"]

        String.contains?(String.downcase(error_cause), "element size") ->
          @messages["element_size_limit_exceeded"]

        true ->
          @default_message
      end
    end
  end

  defp handle_limit_exceeded_error(error) do
    message = LimitExceededMessages.determine_limit_exceeded_message(error.metadata["err"] || "")

    %Momento.Error{
      error_code: Momento.Error.Code.limit_exceeded_error(),
      cause: error,
      message: message
    }
  end

  defp create_error(error_code, cause, message) do
    %Momento.Error{
      error_code: error_code,
      cause: cause,
      message: message
    }
  end

  @spec invalid_argument(message :: String.t(), cause :: Exception.t() | nil) :: Momento.Error.t()
  def invalid_argument(message, cause \\ nil) do
    %Momento.Error{
      error_code: Momento.Error.Code.invalid_argument_error(),
      cause: cause,
      message: "Invalid argument passed to Momento client: #{message}"
    }
  end
end