# frozen_string_literal: true module Octokit # Custom error class for rescuing from all GitHub errors class Error < StandardError attr_reader :context # Returns the appropriate Octokit::Error subclass based # on status and response message # # @param [Hash] response HTTP response # @return [Octokit::Error] def self.from_response(response) status = response[:status].to_i body = response[:body].to_s headers = response[:response_headers] if klass = case status when 400 then Octokit::BadRequest when 401 then error_for_401(headers) when 403 then error_for_403(body) when 404 then error_for_404(body) when 405 then Octokit::MethodNotAllowed when 406 then Octokit::NotAcceptable when 409 then Octokit::Conflict when 415 then Octokit::UnsupportedMediaType when 422 then error_for_422(body) when 451 then Octokit::UnavailableForLegalReasons when 400..499 then Octokit::ClientError when 500 then Octokit::InternalServerError when 501 then Octokit::NotImplemented when 502 then Octokit::BadGateway when 503 then Octokit::ServiceUnavailable when 500..599 then Octokit::ServerError end klass.new(response) end end def build_error_context if RATE_LIMITED_ERRORS.include?(self.class) @context = Octokit::RateLimit.from_response(@response) end end def initialize(response = nil) @response = response super(build_error_message) build_error_context end # Documentation URL returned by the API for some errors # # @return [String] def documentation_url data[:documentation_url] if data.is_a? Hash end # Returns most appropriate error for 401 HTTP status code # @private # rubocop:disable Naming/VariableNumber def self.error_for_401(headers) # rubocop:enbale Naming/VariableNumber if Octokit::OneTimePasswordRequired.required_header(headers) Octokit::OneTimePasswordRequired else Octokit::Unauthorized end end # Returns most appropriate error for 403 HTTP status code # @private def self.error_for_403(body) # rubocop:enable Naming/VariableNumber case body when /rate limit exceeded/i, /exceeded a secondary rate limit/i Octokit::TooManyRequests when /login attempts exceeded/i Octokit::TooManyLoginAttempts when /returns blobs up to [0-9]+ MB/i Octokit::TooLargeContent when /abuse/i Octokit::AbuseDetected when /repository access blocked/i Octokit::RepositoryUnavailable when /email address must be verified/i Octokit::UnverifiedEmail when /account was suspended/i Octokit::AccountSuspended when /billing issue/i Octokit::BillingIssue when /Resource protected by organization SAML enforcement/i Octokit::SAMLProtected when /suspended your access|This installation has been suspended/i Octokit::InstallationSuspended else Octokit::Forbidden end end # Return most appropriate error for 404 HTTP status code # @private # rubocop:disable Naming/VariableNumber def self.error_for_404(body) # rubocop:enable Naming/VariableNumber if body =~ /Branch not protected/i Octokit::BranchNotProtected else Octokit::NotFound end end # Return most appropriate error for 422 HTTP status code # @private # rubocop:disable Naming/VariableNumber def self.error_for_422(body) # rubocop:enable Naming/VariableNumber if body =~ /PullRequestReviewComment/i && body =~ /(commit_id|end_commit_oid) is not part of the pull request/i Octokit::CommitIsNotPartOfPullRequest elsif body =~ /Path diff too large/i Octokit::PathDiffTooLarge else Octokit::UnprocessableEntity end end # Array of validation errors # @return [Array] Error info def errors if data.is_a?(Hash) data[:errors] || [] else [] end end # Status code returned by the GitHub server. # # @return [Integer] def response_status @response[:status] end # Headers returned by the GitHub server. # # @return [Hash] def response_headers @response[:response_headers] end # Body returned by the GitHub server. # # @return [String] def response_body @response[:body] end private def data @data ||= if (body = @response[:body]) && !body.empty? if body.is_a?(String) && @response[:response_headers] && @response[:response_headers][:content_type] =~ /json/ Sawyer::Agent.serializer.decode(body) else body end end end def response_message case data when Hash data[:message] when String data end end def response_error "Error: #{data[:error]}" if data.is_a?(Hash) && data[:error] end def response_error_summary return nil unless data.is_a?(Hash) && !Array(data[:errors]).empty? summary = +"\nError summary:\n" summary << data[:errors].map do |error| if error.is_a? Hash error.map { |k, v| " #{k}: #{v}" } else " #{error}" end end.join("\n") summary end def build_error_message return nil if @response.nil? message = +"#{@response[:method].to_s.upcase} " message << "#{redact_url(@response[:url].to_s.dup)}: " message << "#{@response[:status]} - " message << response_message.to_s unless response_message.nil? message << response_error.to_s unless response_error.nil? message << response_error_summary.to_s unless response_error_summary.nil? message << " // See: #{documentation_url}" unless documentation_url.nil? message end def redact_url(url_string) %w[client_secret access_token].each do |token| if url_string.include? token url_string.gsub!(/#{token}=\S+/, "#{token}=(redacted)") end end url_string end end # Raised on errors in the 400-499 range class ClientError < Error; end # Raised when GitHub returns a 400 HTTP status code class BadRequest < ClientError; end # Raised when GitHub returns a 401 HTTP status code class Unauthorized < ClientError; end # Raised when GitHub returns a 401 HTTP status code # and headers include "X-GitHub-OTP" class OneTimePasswordRequired < ClientError # @private OTP_DELIVERY_PATTERN = /required; (\w+)/i.freeze # @private def self.required_header(headers) OTP_DELIVERY_PATTERN.match headers['X-GitHub-OTP'].to_s end # Delivery method for the user's OTP # # @return [String] def password_delivery @password_delivery ||= delivery_method_from_header end private def delivery_method_from_header if match = self.class.required_header(@response[:response_headers]) match[1] end end end # Raised when GitHub returns a 403 HTTP status code class Forbidden < ClientError; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'rate limit exceeded' class TooManyRequests < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'login attempts exceeded' class TooManyLoginAttempts < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'returns blobs up to [0-9]+ MB' class TooLargeContent < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'abuse' class AbuseDetected < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'repository access blocked' class RepositoryUnavailable < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'email address must be verified' class UnverifiedEmail < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'account was suspended' class AccountSuspended < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'billing issue' class BillingIssue < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'Resource protected by organization SAML enforcement' class SAMLProtected < Forbidden; end # Raised when GitHub returns a 403 HTTP status code # and body matches 'suspended your access' class InstallationSuspended < Forbidden; end # Raised when GitHub returns a 404 HTTP status code class NotFound < ClientError; end # Raised when GitHub returns a 404 HTTP status code # and body matches 'Branch not protected' class BranchNotProtected < ClientError; end # Raised when GitHub returns a 405 HTTP status code class MethodNotAllowed < ClientError; end # Raised when GitHub returns a 406 HTTP status code class NotAcceptable < ClientError; end # Raised when GitHub returns a 409 HTTP status code class Conflict < ClientError; end # Raised when GitHub returns a 414 HTTP status code class UnsupportedMediaType < ClientError; end # Raised when GitHub returns a 422 HTTP status code class UnprocessableEntity < ClientError; end # Raised when GitHub returns a 422 HTTP status code # and body matches 'PullRequestReviewComment' and 'commit_id (or end_commit_oid) is not part of the pull request' class CommitIsNotPartOfPullRequest < UnprocessableEntity; end # Raised when GitHub returns a 422 HTTP status code and body matches 'Path diff too large'. # It could occur when attempting to post review comments on a "too large" file. class PathDiffTooLarge < UnprocessableEntity; end # Raised when GitHub returns a 451 HTTP status code class UnavailableForLegalReasons < ClientError; end # Raised on errors in the 500-599 range class ServerError < Error; end # Raised when GitHub returns a 500 HTTP status code class InternalServerError < ServerError; end # Raised when GitHub returns a 501 HTTP status code class NotImplemented < ServerError; end # Raised when GitHub returns a 502 HTTP status code class BadGateway < ServerError; end # Raised when GitHub returns a 503 HTTP status code class ServiceUnavailable < ServerError; end # Raised when client fails to provide valid Content-Type class MissingContentType < ArgumentError; end # Raised when a method requires an application client_id # and secret but none is provided class ApplicationCredentialsRequired < StandardError; end # Raised when a repository is created with an invalid format class InvalidRepository < ArgumentError; end RATE_LIMITED_ERRORS = [Octokit::TooManyRequests, Octokit::AbuseDetected].freeze end