x-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +353 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/lib/x/core/app_only_authenticator.rb +198 -0
- data/lib/x/core/authenticator.rb +74 -0
- data/lib/x/core/authenticator_request.rb +56 -0
- data/lib/x/core/bearer_token_authenticator.rb +46 -0
- data/lib/x/core/built_response.rb +100 -0
- data/lib/x/core/client.rb +728 -0
- data/lib/x/core/client_app_only.rb +115 -0
- data/lib/x/core/client_credentials.rb +193 -0
- data/lib/x/core/client_internals.rb +315 -0
- data/lib/x/core/client_memo.rb +58 -0
- data/lib/x/core/client_settings.rb +166 -0
- data/lib/x/core/client_token_refresh.rb +281 -0
- data/lib/x/core/connection.rb +328 -0
- data/lib/x/core/connection_pool.rb +123 -0
- data/lib/x/core/connection_proxy.rb +151 -0
- data/lib/x/core/connection_request.rb +90 -0
- data/lib/x/core/credential_holder.rb +78 -0
- data/lib/x/core/credential_validator.rb +286 -0
- data/lib/x/core/errors/authorization_denied.rb +55 -0
- data/lib/x/core/errors/authorization_error.rb +41 -0
- data/lib/x/core/errors/bad_gateway.rb +9 -0
- data/lib/x/core/errors/bad_request.rb +10 -0
- data/lib/x/core/errors/callback_error.rb +88 -0
- data/lib/x/core/errors/client_error.rb +17 -0
- data/lib/x/core/errors/conflict.rb +9 -0
- data/lib/x/core/errors/error.rb +66 -0
- data/lib/x/core/errors/forbidden.rb +10 -0
- data/lib/x/core/errors/gateway_timeout.rb +9 -0
- data/lib/x/core/errors/gone.rb +9 -0
- data/lib/x/core/errors/http_error.rb +333 -0
- data/lib/x/core/errors/internal_server_error.rb +9 -0
- data/lib/x/core/errors/invalid_response.rb +96 -0
- data/lib/x/core/errors/method_not_allowed.rb +10 -0
- data/lib/x/core/errors/network_error.rb +59 -0
- data/lib/x/core/errors/not_acceptable.rb +10 -0
- data/lib/x/core/errors/not_found.rb +15 -0
- data/lib/x/core/errors/payload_too_large.rb +10 -0
- data/lib/x/core/errors/payment_required.rb +10 -0
- data/lib/x/core/errors/request_timeout.rb +9 -0
- data/lib/x/core/errors/server_error.rb +15 -0
- data/lib/x/core/errors/service_unavailable.rb +9 -0
- data/lib/x/core/errors/token_report_failed.rb +59 -0
- data/lib/x/core/errors/too_many_redirects.rb +53 -0
- data/lib/x/core/errors/too_many_requests.rb +80 -0
- data/lib/x/core/errors/unauthorized.rb +15 -0
- data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
- data/lib/x/core/errors/unprocessable_entity.rb +10 -0
- data/lib/x/core/errors/unsupported_format.rb +22 -0
- data/lib/x/core/errors/unsupported_media_type.rb +10 -0
- data/lib/x/core/errors/unsupported_operation.rb +18 -0
- data/lib/x/core/oauth1_authenticator.rb +142 -0
- data/lib/x/core/oauth2_authenticator.rb +296 -0
- data/lib/x/core/oauth2_authorization.rb +405 -0
- data/lib/x/core/oauth2_refresh.rb +272 -0
- data/lib/x/core/oauth2_tokens.rb +277 -0
- data/lib/x/core/origin.rb +95 -0
- data/lib/x/core/problem.rb +302 -0
- data/lib/x/core/proxy_setting.rb +22 -0
- data/lib/x/core/rate_limit.rb +137 -0
- data/lib/x/core/rate_limit_handler.rb +166 -0
- data/lib/x/core/redirect_handler.rb +208 -0
- data/lib/x/core/refresh_reporter.rb +92 -0
- data/lib/x/core/request_builder.rb +155 -0
- data/lib/x/core/request_context.rb +85 -0
- data/lib/x/core/request_encoding.rb +135 -0
- data/lib/x/core/response.rb +196 -0
- data/lib/x/core/response_headers.rb +31 -0
- data/lib/x/core/response_parser.rb +155 -0
- data/lib/x/core/retry_handler.rb +149 -0
- data/lib/x/core/setting_validator.rb +399 -0
- data/lib/x/core/stream_body.rb +77 -0
- data/lib/x/core/stream_response.rb +267 -0
- data/lib/x/core/token_endpoint.rb +238 -0
- data/lib/x/core/version.rb +25 -0
- data/lib/x/core.rb +6 -0
- data/sig/manifest.yaml +8 -0
- data/sig/x-core.rbs +396 -0
- metadata +162 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "http_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# The base class of the errors of a 4xx response, which the API refused
|
|
7
|
+
#
|
|
8
|
+
# The request is most often the reason it was refused, so sending it again unchanged is refused again. Some are
|
|
9
|
+
# refused for a state that can change: TooManyRequests and RequestTimeout pass once the rate limit resets or the API
|
|
10
|
+
# reads the request in time, PaymentRequired once credit is added, and Conflict once a filtered stream has rules.
|
|
11
|
+
#
|
|
12
|
+
# @api public
|
|
13
|
+
# @example Tell a request the API refused from a failure of the API
|
|
14
|
+
# rescue X::ClientError => e
|
|
15
|
+
# logger.warn("#{e.status}: #{e.message}")
|
|
16
|
+
class ClientError < HTTPError; end
|
|
17
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
# The base class of every error the X gems raise
|
|
5
|
+
#
|
|
6
|
+
# Rescuing it catches every way a request can fail: an HTTPError for a response that failed, a NetworkError for
|
|
7
|
+
# a request that never got a response, and the errors the gems raise for what they will not send or cannot read.
|
|
8
|
+
#
|
|
9
|
+
# X::Error
|
|
10
|
+
# ├── X::HTTPError a response that failed, which the error holds, and itself a redirect not followed
|
|
11
|
+
# │ ├── X::ClientError 4xx: the request was refused, and mostly is again
|
|
12
|
+
# │ │ ├── X::BadRequest 400
|
|
13
|
+
# │ │ ├── X::Unauthorized 401
|
|
14
|
+
# │ │ ├── X::PaymentRequired 402
|
|
15
|
+
# │ │ ├── X::Forbidden 403
|
|
16
|
+
# │ │ ├── X::NotFound 404
|
|
17
|
+
# │ │ ├── X::MethodNotAllowed 405
|
|
18
|
+
# │ │ ├── X::NotAcceptable 406
|
|
19
|
+
# │ │ ├── X::RequestTimeout 408
|
|
20
|
+
# │ │ ├── X::Conflict 409
|
|
21
|
+
# │ │ ├── X::Gone 410
|
|
22
|
+
# │ │ ├── X::PayloadTooLarge 413
|
|
23
|
+
# │ │ ├── X::UnsupportedMediaType 415
|
|
24
|
+
# │ │ ├── X::UnprocessableEntity 422
|
|
25
|
+
# │ │ ├── X::TooManyRequests 429
|
|
26
|
+
# │ │ ├── X::UnavailableForLegalReasons 451
|
|
27
|
+
# │ │ └── X::AuthorizationError X refused to issue a token, as its token endpoint answered
|
|
28
|
+
# │ ├── X::ServerError 5xx: the API failed, and the same request may pass later
|
|
29
|
+
# │ │ ├── X::InternalServerError 500
|
|
30
|
+
# │ │ ├── X::BadGateway 502
|
|
31
|
+
# │ │ ├── X::ServiceUnavailable 503
|
|
32
|
+
# │ │ └── X::GatewayTimeout 504
|
|
33
|
+
# │ └── X::InvalidResponse 2xx: a response that succeeded, whose body is not the JSON it claims
|
|
34
|
+
# ├── X::NetworkError the request never reached the API, or its response never arrived
|
|
35
|
+
# ├── X::AuthorizationDenied the redirect back from X says the app was not authorized
|
|
36
|
+
# ├── X::TokenReportFailed save_tokens raised for the tokens of an exchange of a code or a refresh
|
|
37
|
+
# ├── X::TooManyRedirects a response redirected more times than max_redirects allows
|
|
38
|
+
# ├── X::UnsupportedOperation the API, or the credentials of the client, offer no way to do what was asked
|
|
39
|
+
# ├── X::UnsupportedFormat Marshal, YAML, or JSON read a state written in a format this release does not read
|
|
40
|
+
# ├── X::Resources::Error the failures of the object layer, from x-resources
|
|
41
|
+
# │ ├── X::MissingResource a resource that was asked for does not exist
|
|
42
|
+
# │ ├── X::UnreadableResponse a response that succeeded says what the API does not document
|
|
43
|
+
# │ │ └── X::InvalidAttribute a response holds a value that is not what the API documents it to be
|
|
44
|
+
# │ └── X::MissingClient a resource that holds no client was asked to make a request
|
|
45
|
+
# ├── X::Streams::Error the failures of a stream, from x-streams
|
|
46
|
+
# │ ├── X::StreamError a line of a stream held errors and no data
|
|
47
|
+
# │ └── X::RulesRejected the API left rules of the filtered stream unchanged, and no block took them
|
|
48
|
+
# └── X::Uploads::Error the failures of an upload, from x-uploads
|
|
49
|
+
# ├── X::InvalidMedia the media does not exist, cannot be read, is empty, or is too large
|
|
50
|
+
# │ └── X::InvalidMediaType the media is of a type the API does not take
|
|
51
|
+
# ├── X::ChunkedUploadFailed a chunk or the finalize of an initialized upload failed
|
|
52
|
+
# ├── X::AltTextFailed the media was uploaded, but its alt text could not be added
|
|
53
|
+
# ├── X::MediaProcessingCheckFailed the media was uploaded, but a check of its processing failed
|
|
54
|
+
# ├── X::MediaProcessingFailed X could not process the media that was uploaded
|
|
55
|
+
# ├── X::MediaProcessingTimeout the media was still processing when the wait ran out
|
|
56
|
+
# └── X::MissingMediaData a response of an upload describes no media
|
|
57
|
+
#
|
|
58
|
+
# @api public
|
|
59
|
+
# @example Rescue every failure of a request
|
|
60
|
+
# begin
|
|
61
|
+
# client.get("users/me")
|
|
62
|
+
# rescue X::Error => e
|
|
63
|
+
# logger.error(e.message)
|
|
64
|
+
# end
|
|
65
|
+
class Error < StandardError; end
|
|
66
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 403 Forbidden response, which the API sends for a request whose credentials it accepts but whose
|
|
7
|
+
# access level, or whose account, may not do what it asks
|
|
8
|
+
# @api public
|
|
9
|
+
class Forbidden < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "../built_response"
|
|
7
|
+
require_relative "../problem"
|
|
8
|
+
require_relative "../request_context"
|
|
9
|
+
require_relative "../response_headers"
|
|
10
|
+
|
|
11
|
+
module X
|
|
12
|
+
module Core
|
|
13
|
+
# Base class for HTTP errors from the X API
|
|
14
|
+
#
|
|
15
|
+
# The message is what the API said went wrong, read from the body of the response, behind the method and path of
|
|
16
|
+
# the request it answered. {#body} holds that body as it arrived, {#headers} the headers it came with, and
|
|
17
|
+
# {#problem} the problem it describes the failure with as a whole, and {#problems} each problem it names, for code
|
|
18
|
+
# that acts on the reason rather than logging it. {#http_method} and {#uri} are the request the API refused.
|
|
19
|
+
#
|
|
20
|
+
# A 4xx raises a ClientError, a 5xx a ServerError, and a body that is not JSON an InvalidResponse, each a subclass
|
|
21
|
+
# of this. It is raised itself for a redirect the client does not follow: a 300 Multiple Choices, 304 Not Modified,
|
|
22
|
+
# or 305 Use Proxy, and a redirect whose Location is missing, is not a valid URL, or is not an HTTP or HTTPS URL.
|
|
23
|
+
#
|
|
24
|
+
# @api public
|
|
25
|
+
class ::X::HTTPError < Error
|
|
26
|
+
include RequestContext
|
|
27
|
+
include ResponseHeaders
|
|
28
|
+
|
|
29
|
+
# @!method http_method
|
|
30
|
+
# The HTTP method the request was sent with
|
|
31
|
+
# @api public
|
|
32
|
+
# @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one
|
|
33
|
+
# @example Tell a read that failed from a write
|
|
34
|
+
# writes_failed += 1 unless error.http_method.eql?(:get)
|
|
35
|
+
# @!method uri
|
|
36
|
+
# The URI the request was sent to
|
|
37
|
+
# @api public
|
|
38
|
+
# @return [URI::Generic, nil] the URI, or nil for an error built without one
|
|
39
|
+
# @example Count the failures of each endpoint
|
|
40
|
+
# failures[error.uri.path] += 1
|
|
41
|
+
# @!method headers
|
|
42
|
+
# The headers of the response
|
|
43
|
+
#
|
|
44
|
+
# The names are lowercase, and a field the API sent more than once is joined with a comma.
|
|
45
|
+
# @api public
|
|
46
|
+
# @return [Hash{String => String}] the headers, frozen
|
|
47
|
+
# @example Read how long the API took to answer
|
|
48
|
+
# error.headers["x-response-time"]
|
|
49
|
+
|
|
50
|
+
# Regular expression to match JSON content types
|
|
51
|
+
JSON_CONTENT_TYPE_REGEXP = %r{application/(problem\+|)json}
|
|
52
|
+
private_constant :JSON_CONTENT_TYPE_REGEXP
|
|
53
|
+
# The keys of a body that describes the failure itself, rather than naming the errors of the request
|
|
54
|
+
PROBLEM_KEYS = %w[title detail type error].freeze
|
|
55
|
+
private_constant :PROBLEM_KEYS
|
|
56
|
+
# The header that says how long to wait before sending the request again
|
|
57
|
+
RETRY_AFTER_HEADER = "retry-after"
|
|
58
|
+
private_constant :RETRY_AFTER_HEADER
|
|
59
|
+
# The value of a Retry-After header that counts seconds, rather than naming the time to wait until
|
|
60
|
+
RETRY_AFTER_SECONDS = /\A\d+\z/
|
|
61
|
+
private_constant :RETRY_AFTER_SECONDS
|
|
62
|
+
# The message of the error raised for an HTTPError itself, which has no status, given neither a response nor one
|
|
63
|
+
ANY_STATUS = "X::HTTPError is raised for a response of any status, so it is built with the status: of one, or the " \
|
|
64
|
+
"http_response: itself; raise the error of the status, such as X::NotFound, to build one without either"
|
|
65
|
+
private_constant :ANY_STATUS
|
|
66
|
+
|
|
67
|
+
# The response itself, as the client received it
|
|
68
|
+
#
|
|
69
|
+
# It is an escape hatch, for what the error does not read: the status is {#status}, the headers are
|
|
70
|
+
# {#headers}, and the body is {#body}. It is the response of the transport the client sent the request with, a
|
|
71
|
+
# Net::HTTPResponse today, or the one built of the status, headers, and body the error was given. Its class is
|
|
72
|
+
# not covered by the compatibility promise of 1.x: a later release of 1.x may send its requests with another
|
|
73
|
+
# library, whose response this then returns.
|
|
74
|
+
#
|
|
75
|
+
# @api public
|
|
76
|
+
# @return [Net::HTTPResponse] the response of the transport
|
|
77
|
+
# @example Read the reason phrase of the status line
|
|
78
|
+
# error.http_response.message # => "Too Many Requests"
|
|
79
|
+
attr_reader :http_response
|
|
80
|
+
|
|
81
|
+
# The problems the API named in the body of the response
|
|
82
|
+
#
|
|
83
|
+
# They are the errors the body names, such as each parameter of the request the API refused, or else the
|
|
84
|
+
# problem the body describes itself, which is {#problem}.
|
|
85
|
+
#
|
|
86
|
+
# @api public
|
|
87
|
+
# @return [Array<Problem>] the problems, frozen, empty for a response that describes none in JSON
|
|
88
|
+
# @example Name each parameter the API refused
|
|
89
|
+
# error.problems.flat_map { |problem| problem.to_h.fetch("parameters", {}).keys } # => ["ids", "user.fields"]
|
|
90
|
+
attr_reader :problems
|
|
91
|
+
|
|
92
|
+
# The problem the API described the failure with as a whole
|
|
93
|
+
#
|
|
94
|
+
# It is the problem the body of the response describes itself, by its title, detail, type, and status, whether
|
|
95
|
+
# or not the body names errors of its own, which are {#problems}, so its type reads the kind of failure alike
|
|
96
|
+
# for a request the API refused a parameter of and for one it refused to authorize; its attributes are the whole
|
|
97
|
+
# body. A body that describes no problem of its own, but names errors, as the responses of v1.1 do, is described
|
|
98
|
+
# by the first of them.
|
|
99
|
+
#
|
|
100
|
+
# @api public
|
|
101
|
+
# @return [Problem, nil] the problem, or nil for a response that describes none in JSON
|
|
102
|
+
# @example Tell a request the API found invalid from one it refused to authorize
|
|
103
|
+
# error.problem&.type # => "https://api.twitter.com/2/problems/invalid-request"
|
|
104
|
+
# @example Act on the reason rather than the status
|
|
105
|
+
# wait_for_the_next_month if error.problem&.usage_capped?
|
|
106
|
+
attr_reader :problem
|
|
107
|
+
|
|
108
|
+
# Initialize a new HTTPError
|
|
109
|
+
#
|
|
110
|
+
# Public, so that code that rescues an HTTPError, or a subclass such as NotFound, can be tested with one built from
|
|
111
|
+
# the status, headers, and body of a response, or from a Net::HTTP response, as x-core builds each from the
|
|
112
|
+
# response it parses. The error names the request, when given its method and URI, as x-core names the request the
|
|
113
|
+
# response answers.
|
|
114
|
+
#
|
|
115
|
+
# It can be raised as any other exception is, as in raise X::NotFound, or raise X::NotFound, "gone", for a test
|
|
116
|
+
# double that stands in for a client: an error of a status, such as NotFound, given neither a response nor a
|
|
117
|
+
# status, is built with the status x-core raises it for, a ClientError or ServerError with the first of its kind,
|
|
118
|
+
# 400 or 500, and an InvalidResponse with 200. An HTTPError itself, which is raised for a status of any kind, must
|
|
119
|
+
# be given one. A message given is the message of the error, in place of the one read from the body.
|
|
120
|
+
#
|
|
121
|
+
# @api public
|
|
122
|
+
# @param message [String, nil] the message, or nil for the one the body describes the failure with
|
|
123
|
+
# @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as
|
|
124
|
+
# {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of
|
|
125
|
+
# the status, headers, and body, which every release of 1.x takes
|
|
126
|
+
# @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given, or nil for the
|
|
127
|
+
# status of the class
|
|
128
|
+
# @param headers [Hash{String => String}, nil] the headers of the response, when it is not given
|
|
129
|
+
# @param body [String, nil] the body of the response, when it is not given
|
|
130
|
+
# @param http_method [Symbol, String, nil] the method of the request the response answers, in any case
|
|
131
|
+
# @param uri [URI::Generic, nil] the URI of the request the response answers
|
|
132
|
+
# @return [HTTPError] a new instance
|
|
133
|
+
# @raise [ArgumentError] if the HTTP response is given beside a status, headers, or a body, or neither it nor a
|
|
134
|
+
# status is given to an HTTPError itself, or the status is not from 100 to 599, or the headers are not a Hash of
|
|
135
|
+
# names to values
|
|
136
|
+
# @example Create the error of a user that does not exist
|
|
137
|
+
# error = X::NotFound.new(status: 404, headers: {"content-type" => "application/json"},
|
|
138
|
+
# body: %({"title":"Not Found Error","detail":"Could not find user."}))
|
|
139
|
+
# @example Raise the error of a rate limit from a test double
|
|
140
|
+
# raise X::TooManyRequests, "Too Many Requests"
|
|
141
|
+
# @example Create an HTTP error from a response
|
|
142
|
+
# error = X::HTTPError.new(http_response: response, http_method: :get, uri: URI("https://api.x.com/2/users/me"))
|
|
143
|
+
def initialize(message = nil, http_response: nil, status: nil, headers: nil, body: nil, http_method: nil, uri: nil)
|
|
144
|
+
@http_response = built_response(http_response, status:, headers:, body:)
|
|
145
|
+
name_request(http_method, uri)
|
|
146
|
+
parsed = parsed_body
|
|
147
|
+
errors = Problem.all_from(parsed)
|
|
148
|
+
described = (Problem.new(parsed) if describes_problem?(parsed))
|
|
149
|
+
@problems = errors.empty? ? [described].compact.freeze : errors
|
|
150
|
+
@problem = described || errors.first
|
|
151
|
+
super(message_naming_request(message || message_from(parsed) || @http_response.message))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The status an error of this class is built with by default
|
|
155
|
+
#
|
|
156
|
+
# It is the status an error given neither a response nor a status is built with: the status x-core raises the class, or the nearest class it descends from, for, or the first status of
|
|
157
|
+
# the kind of a ClientError or ServerError, 400 or 500.
|
|
158
|
+
#
|
|
159
|
+
# @api private
|
|
160
|
+
# @return [Integer, nil] the status, or nil for an HTTPError itself, which is raised for a status of any kind
|
|
161
|
+
# @example Get the status of a NotFound
|
|
162
|
+
# X::NotFound.__send__(:default_status) # => 404
|
|
163
|
+
def self.default_status
|
|
164
|
+
parser = Core.const_get(:ResponseParser)
|
|
165
|
+
ancestors.each do |ancestor|
|
|
166
|
+
status = parser::ERROR_MAP.key(ancestor) || parser::STATUS_CLASS_ERRORS.key(ancestor)&.*(100)
|
|
167
|
+
return status if status
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
private_class_method :default_status
|
|
172
|
+
|
|
173
|
+
# The HTTP status code, as an Integer like X::Response#status
|
|
174
|
+
#
|
|
175
|
+
# @api public
|
|
176
|
+
# @return [Integer] the HTTP status code
|
|
177
|
+
# @example Handle a status the errors do not name
|
|
178
|
+
# retry if error.status.eql?(408)
|
|
179
|
+
def status = Integer(http_response.code)
|
|
180
|
+
|
|
181
|
+
# The body of the response, as it arrived
|
|
182
|
+
#
|
|
183
|
+
# A server can send any body with an error, so it is the JSON the API describes a failure with, or whatever
|
|
184
|
+
# else was sent in its place, such as the page of a proxy. It is tagged UTF-8, the encoding of the JSON the API
|
|
185
|
+
# sends, and a body that is not valid UTF-8 keeps its bytes, so valid_encoding? tells it apart.
|
|
186
|
+
#
|
|
187
|
+
# @api public
|
|
188
|
+
# @return [String, nil] the body, tagged UTF-8, or nil for a response without one
|
|
189
|
+
# @example Log what the API sent
|
|
190
|
+
# logger.error(error.body)
|
|
191
|
+
def body = http_response.body
|
|
192
|
+
|
|
193
|
+
# The seconds the response asks a request to wait before it is sent again
|
|
194
|
+
#
|
|
195
|
+
# The API sends a Retry-After header with a request it refused for a rate limit, and with some of the responses
|
|
196
|
+
# of a failure of its own, such as a 503 that names the time its endpoint is expected back. The header counts
|
|
197
|
+
# the seconds from when the response was sent, or names the time to wait until. A client waits it out before it
|
|
198
|
+
# sends an idempotent request refused for a failure of the API's own again, up to a minute, and before it sends
|
|
199
|
+
# any request refused for a rate limit again, up to max_rate_limit_wait; see {Client#initialize}.
|
|
200
|
+
#
|
|
201
|
+
# @api public
|
|
202
|
+
# @return [Integer, nil] the seconds, never negative, or nil for a response that does not say
|
|
203
|
+
# @example Wait as long as the API asks before sending a request again
|
|
204
|
+
# sleep(error.retry_after || 1)
|
|
205
|
+
def retry_after
|
|
206
|
+
value = http_response[RETRY_AFTER_HEADER]
|
|
207
|
+
return if value.nil?
|
|
208
|
+
|
|
209
|
+
value.match?(RETRY_AFTER_SECONDS) ? Integer(value, 10) : seconds_until(value)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# The HTTP response given, or the one built of the status, headers, and body
|
|
215
|
+
#
|
|
216
|
+
# A response given neither a response nor a status is built with the status of the class, and an HTTPError itself,
|
|
217
|
+
# which has none, raises.
|
|
218
|
+
#
|
|
219
|
+
# @api private
|
|
220
|
+
# @param http_response [Net::HTTPResponse, nil] the HTTP response, or nil to build one
|
|
221
|
+
# @param status [Integer, nil] the status of the response to build, or nil for the status of the class
|
|
222
|
+
# @param headers [Hash{String => String}, nil] the headers of the response to build, or nil for none
|
|
223
|
+
# @param body [String, nil] the body of the response to build, or nil for none
|
|
224
|
+
# @return [Net::HTTPResponse] the HTTP response
|
|
225
|
+
# @raise [ArgumentError] if the error is given neither a response nor a status, and its class has no status, or as
|
|
226
|
+
# {BuiltResponse.of} raises
|
|
227
|
+
def built_response(http_response, status:, headers:, body:)
|
|
228
|
+
status ||= self.class.__send__(:default_status) if http_response.nil?
|
|
229
|
+
raise ArgumentError, ANY_STATUS if http_response.nil? && status.nil?
|
|
230
|
+
|
|
231
|
+
BuiltResponse.of(http_response, status:, headers:, body:)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The seconds until the time an HTTP date names
|
|
235
|
+
#
|
|
236
|
+
# They count from the Date the response was sent at, on the clock of the API that named the time, so the wait
|
|
237
|
+
# holds however far the local clock is from the API's; a response that names no Date counts from now.
|
|
238
|
+
#
|
|
239
|
+
# @api private
|
|
240
|
+
# @param value [String] the value of the header
|
|
241
|
+
# @return [Integer, nil] the seconds, never negative, or nil if the value names no time
|
|
242
|
+
def seconds_until(value)
|
|
243
|
+
[(Time.httpdate(value) - sent_at).ceil, 0].max
|
|
244
|
+
rescue ArgumentError
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# The time the response was sent at, by its Date header
|
|
249
|
+
#
|
|
250
|
+
# A response that names no Date, or one that cannot be read, was sent now.
|
|
251
|
+
#
|
|
252
|
+
# @api private
|
|
253
|
+
# @return [Time] the time
|
|
254
|
+
def sent_at
|
|
255
|
+
date = http_response["date"]
|
|
256
|
+
date.nil? ? Time.now : Time.httpdate(date)
|
|
257
|
+
rescue ArgumentError
|
|
258
|
+
Time.now
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# The body of the response, parsed as a JSON object
|
|
262
|
+
#
|
|
263
|
+
# A server can send any body with an error, so a body that is not JSON, or that is not the JSON its content
|
|
264
|
+
# type claims, parses to an empty object rather than raise while the error is built.
|
|
265
|
+
#
|
|
266
|
+
# @api private
|
|
267
|
+
# @return [Hash{String => Object}] the parsed body, empty for a body that is not a JSON object
|
|
268
|
+
def parsed_body
|
|
269
|
+
return {} unless json?
|
|
270
|
+
|
|
271
|
+
Hash.try_convert(JSON.parse(body.to_s)) || {}
|
|
272
|
+
rescue JSON::ParserError
|
|
273
|
+
{}
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Check whether a body describes a problem itself, rather than only naming errors
|
|
277
|
+
#
|
|
278
|
+
# @api private
|
|
279
|
+
# @param body [Hash{String => Object}] the parsed body
|
|
280
|
+
# @return [Boolean] true if the body holds a title, detail, type, or error of its own
|
|
281
|
+
def describes_problem?(body) = PROBLEM_KEYS.any? { |key| body.key?(key) }
|
|
282
|
+
|
|
283
|
+
# The message a body describes the failure with
|
|
284
|
+
#
|
|
285
|
+
# A body that holds no message leaves the error with the status message of the response.
|
|
286
|
+
#
|
|
287
|
+
# @api private
|
|
288
|
+
# @param body [Hash{String => Object}] the parsed body
|
|
289
|
+
# @return [String, nil] the message, or nil if the body holds none
|
|
290
|
+
def message_from(body)
|
|
291
|
+
message_from_errors(body["errors"]) || message_from_problem(body) || String.try_convert(body["error"])
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Join the messages of an errors array
|
|
295
|
+
#
|
|
296
|
+
# Each error gives its message, or else its detail or title.
|
|
297
|
+
#
|
|
298
|
+
# @api private
|
|
299
|
+
# @param errors [Object] the errors of the body
|
|
300
|
+
# @return [String, nil] the joined messages, or nil if there are none
|
|
301
|
+
def message_from_errors(errors)
|
|
302
|
+
messages = Array(errors).filter_map do |error|
|
|
303
|
+
Hash.try_convert(error)&.values_at("message", "detail", "title")&.grep(String)&.first
|
|
304
|
+
end
|
|
305
|
+
messages.join(", ") unless messages.empty?
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The title and detail of a problem, joined
|
|
309
|
+
#
|
|
310
|
+
# A detail that says no more than the title, as the Unauthorized of a 401 does, is left out, rather than
|
|
311
|
+
# repeated behind it.
|
|
312
|
+
#
|
|
313
|
+
# @api private
|
|
314
|
+
# @param body [Hash{String => untyped}] the body
|
|
315
|
+
# @return [String, nil] the title and detail, the title alone if the detail is the same, or nil unless the body
|
|
316
|
+
# has both
|
|
317
|
+
def message_from_problem(body)
|
|
318
|
+
title = body["title"]
|
|
319
|
+
detail = body["detail"]
|
|
320
|
+
return unless title && detail
|
|
321
|
+
|
|
322
|
+
title.eql?(detail) ? title : "#{title}: #{detail}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Check whether the response carries JSON
|
|
326
|
+
# @api private
|
|
327
|
+
# @return [Boolean] true if the response is JSON
|
|
328
|
+
def json?
|
|
329
|
+
JSON_CONTENT_TYPE_REGEXP === http_response["content-type"]
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "http_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
module Core
|
|
7
|
+
# Error raised for a successful response whose body is not JSON, such as the page of a proxy or captive portal
|
|
8
|
+
#
|
|
9
|
+
# It is an X::HTTPError, so that code that rescues the failures of a response reads it as it reads any other:
|
|
10
|
+
# the status is {#status}, the headers are {#headers}, the body is {#body}, and {#http_method} and {#uri} are the
|
|
11
|
+
# request it answered. A body that is not JSON describes no problem, so {#problem} is nil and {#problems} empty.
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
14
|
+
class ::X::InvalidResponse < ::X::HTTPError
|
|
15
|
+
# The body that is not JSON: the whole body of a response, or the line of a stream
|
|
16
|
+
#
|
|
17
|
+
# The body of a stream can be read only as it arrives, so an error raised for a line of a stream holds that line.
|
|
18
|
+
# It is tagged UTF-8, as {Response#body} is, and keeps the bytes of a body that is not valid UTF-8.
|
|
19
|
+
#
|
|
20
|
+
# An error given no body holds the body of its response, as {HTTPError#body} does, once that response has been
|
|
21
|
+
# read whole. The body of a response that has not been read, as that of a stream still arriving, is never read
|
|
22
|
+
# for it, since reading it would wait for the rest of the stream, or take the lines the stream has yet to read.
|
|
23
|
+
#
|
|
24
|
+
# @api public
|
|
25
|
+
# @return [String, nil] the body, or the line of a stream, tagged UTF-8, or else the body of the response once it
|
|
26
|
+
# has been read whole, or nil for an error of a response that has not been, or that has none
|
|
27
|
+
# @example Read the body that could not be parsed
|
|
28
|
+
# error.body
|
|
29
|
+
def body = @body || body_read
|
|
30
|
+
|
|
31
|
+
# Initialize a new InvalidResponse
|
|
32
|
+
#
|
|
33
|
+
# Public, so that code that rescues an InvalidResponse can be tested with one built from the status, headers,
|
|
34
|
+
# and body of a response, or from a Net::HTTP response, as ResponseParser and the stream of x-streams build one. The error
|
|
35
|
+
# names the request, when given its method and URI, as x-core names the request the response answers.
|
|
36
|
+
#
|
|
37
|
+
# It can be raised as any other exception is, as in raise X::InvalidResponse, and is built with a status of 200
|
|
38
|
+
# when it is given neither a response nor a status, as HTTPError states.
|
|
39
|
+
#
|
|
40
|
+
# @api public
|
|
41
|
+
# @param message [String, nil] the message, or nil for the one that names the status and content type
|
|
42
|
+
# @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as
|
|
43
|
+
# {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of
|
|
44
|
+
# the status, headers, and body, which every release of 1.x takes
|
|
45
|
+
# @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given, or nil for 200
|
|
46
|
+
# @param headers [Hash{String => String}, nil] the headers of the response, when it is not given
|
|
47
|
+
# @param body [String, nil] the body that is not JSON, which is the body of a response built of the status
|
|
48
|
+
# @param http_method [Symbol, String, nil] the method of the request the response answers, in any case
|
|
49
|
+
# @param uri [URI::Generic, nil] the URI of the request the response answers
|
|
50
|
+
# @return [InvalidResponse] a new instance
|
|
51
|
+
# @raise [ArgumentError] if the HTTP response is given beside a status or headers, or the status is not from 100
|
|
52
|
+
# to 599, or the headers are not a Hash of names to values
|
|
53
|
+
# @example Create the error of a page a proxy answered with
|
|
54
|
+
# error = X::InvalidResponse.new(status: 200, headers: {"content-type" => "text/html"}, body: "<html></html>")
|
|
55
|
+
# @example Create an error for a line of a stream
|
|
56
|
+
# error = X::InvalidResponse.new(http_response: response, body: line, http_method: :get, uri: stream_uri)
|
|
57
|
+
def initialize(message = nil, http_response: nil, status: nil, headers: nil, body: nil, http_method: nil, uri: nil)
|
|
58
|
+
@body = body.dup&.force_encoding(Encoding::UTF_8)
|
|
59
|
+
super(message, http_response:, status:, headers:, body: (body if http_response.nil?), http_method:, uri:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The status an InvalidResponse is built with by default
|
|
63
|
+
#
|
|
64
|
+
# It is the status one given neither a response nor a status is built with. A body that is not JSON is raised for a response that succeeded, so it is 200 OK.
|
|
65
|
+
#
|
|
66
|
+
# @api private
|
|
67
|
+
# @return [Integer] the status, 200
|
|
68
|
+
# @example Get the status of an InvalidResponse
|
|
69
|
+
# X::InvalidResponse.__send__(:default_status) # => 200
|
|
70
|
+
def self.default_status = 200
|
|
71
|
+
private_class_method :default_status
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# The body of the response, if it has been read whole
|
|
76
|
+
#
|
|
77
|
+
# A response read whole holds its body as a String, where one read in chunks, as a stream is, holds what it read
|
|
78
|
+
# them with, and one not yet read reads its body when asked for it, which this never asks it.
|
|
79
|
+
#
|
|
80
|
+
# @api private
|
|
81
|
+
# @return [String, nil] the body, or nil for a response not yet read, read in chunks, or without one
|
|
82
|
+
def body_read
|
|
83
|
+
String.try_convert(http_response.body) if http_response.instance_variable_get(:@read)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The message of the error, which says the body is not JSON
|
|
87
|
+
#
|
|
88
|
+
# It names what the response says the body is instead, since a body that is not JSON holds no message.
|
|
89
|
+
#
|
|
90
|
+
# @api private
|
|
91
|
+
# @param _body [Hash{String => Object}] the parsed body, which is empty
|
|
92
|
+
# @return [String] the message
|
|
93
|
+
def message_from(_body) = "The body of the #{http_response.code} response is not JSON (#{http_response["content-type"] || "no content type"})"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 405 Method Not Allowed response, which the API sends for a method the endpoint does not take, such
|
|
7
|
+
# as a POST to an endpoint that only reads
|
|
8
|
+
# @api public
|
|
9
|
+
class MethodNotAllowed < ClientError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "../request_context"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Core
|
|
8
|
+
# Raised when a request never reached the API, or its response never arrived
|
|
9
|
+
#
|
|
10
|
+
# It stands for every error a socket raises: a refused, reset, or dropped connection, a host that cannot be
|
|
11
|
+
# resolved, a TLS handshake that failed, a proxy that refused to open a tunnel, and the open, read, and write
|
|
12
|
+
# timeouts of the client. A client sends an idempotent request again only after one that never reached the API,
|
|
13
|
+
# such as a refused connection or a timeout opening it, and leaves the rest, and any POST, to the caller, since
|
|
14
|
+
# whether the API acted on it is unknown; see Client#initialize.
|
|
15
|
+
#
|
|
16
|
+
# The message names the request that failed, and {#http_method} and {#uri} read it, since nothing of a request
|
|
17
|
+
# that got no response is left to read it from.
|
|
18
|
+
#
|
|
19
|
+
# @api public
|
|
20
|
+
# @example Tell a request that failed on the network from one the API refused
|
|
21
|
+
# rescue X::NetworkError => e
|
|
22
|
+
# logger.warn("#{e.message}, giving up")
|
|
23
|
+
class ::X::NetworkError < Error
|
|
24
|
+
include RequestContext
|
|
25
|
+
|
|
26
|
+
# @!method http_method
|
|
27
|
+
# The HTTP method the request that failed was sent with
|
|
28
|
+
# @api public
|
|
29
|
+
# @return [Symbol, nil] the method, as :get, :post, :put, or :delete, or nil for an error built without one
|
|
30
|
+
# @example Tell a read that failed from a write
|
|
31
|
+
# writes_failed += 1 unless error.http_method.eql?(:get)
|
|
32
|
+
# @!method uri
|
|
33
|
+
# The URI the request that failed was sent to
|
|
34
|
+
# @api public
|
|
35
|
+
# @return [URI::Generic, nil] the URI, or nil for an error built without one
|
|
36
|
+
# @example Count the failures of each host
|
|
37
|
+
# failures[error.uri.host] += 1
|
|
38
|
+
|
|
39
|
+
# Initialize a new NetworkError
|
|
40
|
+
#
|
|
41
|
+
# Public, so that code that rescues a NetworkError can be tested with one built by hand, as Connection builds one
|
|
42
|
+
# for the errors a socket raises, or raised with no message, as any exception is. The error names the request,
|
|
43
|
+
# when given its method and URI, as x-core names the request that failed.
|
|
44
|
+
#
|
|
45
|
+
# @api public
|
|
46
|
+
# @param message [String, nil] what went wrong on the network, or nil for the name of the class, as an exception
|
|
47
|
+
# raised with no message is named, which names no request
|
|
48
|
+
# @param http_method [Symbol, String, nil] the method of the request that failed, in any case
|
|
49
|
+
# @param uri [URI::Generic, nil] the URI of the request that failed
|
|
50
|
+
# @return [NetworkError] a new instance
|
|
51
|
+
# @example Create a network error
|
|
52
|
+
# error = X::NetworkError.new("Network error: Connection refused", http_method: :get, uri: URI("https://api.x.com/2/users/me"))
|
|
53
|
+
def initialize(message = nil, http_method: nil, uri: nil)
|
|
54
|
+
name_request(http_method, uri)
|
|
55
|
+
super(message && message_naming_request(message))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client_error"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Raised for a 406 Not Acceptable response, which the API sends for a request that asks for a format the endpoint
|
|
7
|
+
# does not serve
|
|
8
|
+
# @api public
|
|
9
|
+
class NotAcceptable < ClientError; end
|
|
10
|
+
end
|