/
error.rb
363 lines (300 loc) · 11 KB
/
error.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# 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|for) blobs (up to|between) [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<Hash>] 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 api_key].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