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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require_relative "built_response"
|
|
6
|
+
require_relative "rate_limit"
|
|
7
|
+
require_relative "response_headers"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Core
|
|
11
|
+
# A summary of one API response, or one object of a stream, which a client passes to its on_response hook
|
|
12
|
+
# @api public
|
|
13
|
+
class ::X::Response
|
|
14
|
+
include ResponseHeaders
|
|
15
|
+
|
|
16
|
+
# The body of a response parsed, which the response and its copies share
|
|
17
|
+
#
|
|
18
|
+
# It is an object of its own, rather than the state of the response, so that a response that was frozen counts
|
|
19
|
+
# its resources as any other does, and a dup or clone of a response parses no body again: the body they hold
|
|
20
|
+
# is one. A response frozen deeply, as Ractor.make_shareable freezes one, freezes it too, and parses its body
|
|
21
|
+
# for each count, unless it was parsed before.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class ParsedBody
|
|
25
|
+
# The body parsed as a JSON object
|
|
26
|
+
# @api private
|
|
27
|
+
# @return [Hash{String => Object}, nil] the parsed body, or nil for a body not yet parsed
|
|
28
|
+
attr_accessor :json
|
|
29
|
+
end
|
|
30
|
+
private_constant :ParsedBody
|
|
31
|
+
|
|
32
|
+
# @!method headers
|
|
33
|
+
# The headers of the response
|
|
34
|
+
#
|
|
35
|
+
# The names are lowercase, and a field the API sent more than once is joined with a comma.
|
|
36
|
+
# @api public
|
|
37
|
+
# @return [Hash{String => String}] the headers, frozen
|
|
38
|
+
# @example Read how long the API took to answer
|
|
39
|
+
# response.headers["x-response-time"]
|
|
40
|
+
|
|
41
|
+
# The HTTP method of the request
|
|
42
|
+
# @api public
|
|
43
|
+
# @return [Symbol] the HTTP method
|
|
44
|
+
# @example Get the HTTP method
|
|
45
|
+
# response.http_method # => :get
|
|
46
|
+
attr_reader :http_method
|
|
47
|
+
|
|
48
|
+
# The URI of the request
|
|
49
|
+
# @api public
|
|
50
|
+
# @return [URI::Generic] the request URI
|
|
51
|
+
# @example Get the path of the request
|
|
52
|
+
# response.uri.path # => "/2/users/me"
|
|
53
|
+
attr_reader :uri
|
|
54
|
+
|
|
55
|
+
# The response itself, as the client received it
|
|
56
|
+
#
|
|
57
|
+
# It is an escape hatch, for what a summary does not read: the status is {#status}, the headers are
|
|
58
|
+
# {#headers}, and the body is {#body}. It is the response of the transport the client sent the request with, a
|
|
59
|
+
# Net::HTTPResponse today, or the one built of the status, headers, and body the summary was given. Its class is
|
|
60
|
+
# not covered by the compatibility promise of 1.x: a later release of 1.x may send its requests with another
|
|
61
|
+
# library, whose response this then returns.
|
|
62
|
+
#
|
|
63
|
+
# @api public
|
|
64
|
+
# @return [Net::HTTPResponse] the response of the transport
|
|
65
|
+
# @example Read the reason phrase of the status line
|
|
66
|
+
# response.http_response.message # => "OK"
|
|
67
|
+
attr_reader :http_response
|
|
68
|
+
|
|
69
|
+
# Summarize a response
|
|
70
|
+
#
|
|
71
|
+
# Public, so that an on_response hook can be tested with a summary built from the status, headers, and body of
|
|
72
|
+
# a response, or from a Net::HTTP response, as the client builds one for each response it reads.
|
|
73
|
+
#
|
|
74
|
+
# @api public
|
|
75
|
+
# @param http_method [Symbol, String] the HTTP method of the request, in any case, which is read as a lowercase
|
|
76
|
+
# Symbol, as an error reads it
|
|
77
|
+
# @param uri [URI::Generic] the URI of the request
|
|
78
|
+
# @param http_response [Net::HTTPResponse, nil] the response of the transport, an escape hatch as
|
|
79
|
+
# {#http_response} is, whose class is not covered by the compatibility promise of 1.x, or nil for one built of
|
|
80
|
+
# the status, headers, and body, which every release of 1.x takes
|
|
81
|
+
# @param status [Integer, nil] the status of the response, from 100 to 599, when it is not given
|
|
82
|
+
# @param headers [Hash{String => String}, nil] the headers of the response, when it is not given
|
|
83
|
+
# @param body [String, nil] the part of the body summarized, such as one object of a stream, or nil for all of
|
|
84
|
+
# it, which is the body of a response built of the status
|
|
85
|
+
# @return [Response] a new summary
|
|
86
|
+
# @raise [ArgumentError] if the HTTP response is given beside a status or headers, or neither it nor a status is
|
|
87
|
+
# given, or the status is not from 100 to 599, or the headers are not a Hash of names to values
|
|
88
|
+
# @example Summarize a response
|
|
89
|
+
# X::Response.new(http_method: :get, uri: URI("https://api.x.com/2/users/me"), status: 200,
|
|
90
|
+
# headers: {"x-rate-limit-remaining" => "74"}, body: %({"data":{"id":"1"}}))
|
|
91
|
+
# @example Summarize a Net::HTTP response
|
|
92
|
+
# X::Response.new(http_response:, http_method: :get, uri: URI("https://api.x.com/2/users/me"))
|
|
93
|
+
def initialize(http_method:, uri:, http_response: nil, status: nil, headers: nil, body: nil)
|
|
94
|
+
@http_method = http_method.downcase.to_sym
|
|
95
|
+
@uri = uri
|
|
96
|
+
@http_response = BuiltResponse.of(http_response, status:, headers:, body: (body if http_response.nil?))
|
|
97
|
+
@body = body.dup&.force_encoding(Encoding::UTF_8)
|
|
98
|
+
@parsed = ParsedBody.new
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The body summarized: one streamed object, or else the whole body
|
|
102
|
+
#
|
|
103
|
+
# It is tagged UTF-8, the encoding of the JSON the API sends. A body that is not valid UTF-8 keeps its bytes, so
|
|
104
|
+
# valid_encoding? tells it apart, and scrub replaces what is not UTF-8.
|
|
105
|
+
#
|
|
106
|
+
# @api public
|
|
107
|
+
# @return [String, nil] the body, tagged UTF-8
|
|
108
|
+
# @example Log the body
|
|
109
|
+
# logger.debug(response.body)
|
|
110
|
+
def body = @body || http_response.body
|
|
111
|
+
|
|
112
|
+
# The HTTP status code
|
|
113
|
+
#
|
|
114
|
+
# @api public
|
|
115
|
+
# @return [Integer] the status code
|
|
116
|
+
# @example Get the status code
|
|
117
|
+
# response.status # => 200
|
|
118
|
+
def status = Integer(http_response.code)
|
|
119
|
+
|
|
120
|
+
# Check whether the request succeeded
|
|
121
|
+
#
|
|
122
|
+
# @api public
|
|
123
|
+
# @return [Boolean] true for a 2xx status
|
|
124
|
+
# @example Count the failed requests
|
|
125
|
+
# failures += 1 unless response.success?
|
|
126
|
+
def success? = http_response.is_a?(Net::HTTPSuccess)
|
|
127
|
+
|
|
128
|
+
# The rate limits the response reports in its headers
|
|
129
|
+
#
|
|
130
|
+
# @api public
|
|
131
|
+
# @return [Array<RateLimit>] the 15-minute limit, and the 24-hour app and user limits when reported
|
|
132
|
+
# @example Print how many requests remain in each window
|
|
133
|
+
# response.rate_limits.each { |limit| puts "#{limit.type}: #{limit.remaining}" }
|
|
134
|
+
def rate_limits = RateLimit.__send__(:all_from, http_response)
|
|
135
|
+
|
|
136
|
+
# The 15-minute rate limit of the endpoint, which nearly every response reports
|
|
137
|
+
#
|
|
138
|
+
# @api public
|
|
139
|
+
# @return [RateLimit, nil] the rate limit, or nil if the response reports none
|
|
140
|
+
# @example Slow down near the limit
|
|
141
|
+
# sleep response.rate_limit.reset_in if response.rate_limit&.remaining&.zero?
|
|
142
|
+
def rate_limit = rate_limits.find { |limit| limit.type.eql?(RateLimit::RATE_LIMIT_TYPE) }
|
|
143
|
+
|
|
144
|
+
# The number of resources the body holds, as data and as each kind of include
|
|
145
|
+
#
|
|
146
|
+
# The API bills reads by the resource, but once a UTC day for each, so these count what the response returned,
|
|
147
|
+
# not what X billed for it. The body is parsed once, however many times a summary is asked what it holds,
|
|
148
|
+
# unless the summary was frozen deeply, as Ractor.make_shareable freezes one, before it was first asked, which
|
|
149
|
+
# leaves it nowhere to keep what it parsed. A body whose includes is not an object holds no includes to count.
|
|
150
|
+
#
|
|
151
|
+
# @api public
|
|
152
|
+
# @return [Hash{String => Integer}] the count of data and of each include, such as users and posts
|
|
153
|
+
# @example Count the users a lookup returned
|
|
154
|
+
# response.resource_counts # => {"data" => 1, "posts" => 1}
|
|
155
|
+
def resource_counts
|
|
156
|
+
body = parsed_body
|
|
157
|
+
data = body["data"]
|
|
158
|
+
counts = {"data" => Array.try_convert(data)&.size || [data].compact.size}
|
|
159
|
+
Hash.try_convert(body["includes"])&.each { |key, resources| counts[key] = Array(resources).size }
|
|
160
|
+
counts
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The number of resources the body holds, in data and includes together
|
|
164
|
+
#
|
|
165
|
+
# @api public
|
|
166
|
+
# @return [Integer] the resource count
|
|
167
|
+
# @example Total the resources a client has read
|
|
168
|
+
# total += response.resource_count
|
|
169
|
+
def resource_count = resource_counts.values.sum
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# The body parsed as a JSON object, read once and kept
|
|
174
|
+
#
|
|
175
|
+
# A hook that reads a summary reads a body the client has parsed already, so parsing it again for each count
|
|
176
|
+
# would parse every response of a client twice. It is kept apart from the state of the response, so a response
|
|
177
|
+
# that was frozen keeps it too. What holds it is frozen with a response that was frozen deeply, as
|
|
178
|
+
# Ractor.make_shareable freezes one, which then parses its body without keeping it, rather than raise.
|
|
179
|
+
#
|
|
180
|
+
# @api private
|
|
181
|
+
# @return [Hash{String => Object}] the parsed body
|
|
182
|
+
def parsed_body
|
|
183
|
+
@parsed.json || (@parsed.frozen? ? parse_body : @parsed.json = parse_body)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The body parsed as a JSON object, or an empty one for a body that holds none
|
|
187
|
+
# @api private
|
|
188
|
+
# @return [Hash{String => Object}] the parsed body
|
|
189
|
+
def parse_body
|
|
190
|
+
Hash.try_convert(JSON.parse(body.to_s)) || {}
|
|
191
|
+
rescue JSON::ParserError
|
|
192
|
+
{}
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Core
|
|
5
|
+
# The headers of a response, read from the response it holds, included into Response, HTTPError, and InvalidResponse
|
|
6
|
+
#
|
|
7
|
+
# Internal to x-core: it gives a summary of a response, and the error of a failed one, the same headers.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module ResponseHeaders
|
|
11
|
+
# The headers of the response
|
|
12
|
+
#
|
|
13
|
+
# The names are lowercase, whatever case the API sent them in, since HTTP field names mean the same in any
|
|
14
|
+
# case, so a header is read here by the name it is written with here. A field the API sent more than once
|
|
15
|
+
# is joined with a comma, as HTTP joins the lines of a repeated field.
|
|
16
|
+
#
|
|
17
|
+
# Set-Cookie is joined the same way, though HTTP does not join it, since a cookie may hold a comma, as its
|
|
18
|
+
# Expires attribute does, so the cookies of a response that sets more than one cannot be read apart from its
|
|
19
|
+
# headers: read each of them from the http_response, with get_fields.
|
|
20
|
+
#
|
|
21
|
+
# @api public
|
|
22
|
+
# @return [Hash{String => String}] the headers, frozen
|
|
23
|
+
# @example Read how long the API took to answer
|
|
24
|
+
# response.headers["x-response-time"]
|
|
25
|
+
# @example Read each cookie a response sets
|
|
26
|
+
# response.http_response.get_fields("set-cookie")
|
|
27
|
+
def headers = http_response.to_hash.transform_values { |values| values.join(", ") }.freeze
|
|
28
|
+
end
|
|
29
|
+
private_constant :ResponseHeaders
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require_relative "errors/bad_gateway"
|
|
6
|
+
require_relative "errors/bad_request"
|
|
7
|
+
require_relative "errors/callback_error"
|
|
8
|
+
require_relative "errors/conflict"
|
|
9
|
+
require_relative "errors/http_error"
|
|
10
|
+
require_relative "errors/invalid_response"
|
|
11
|
+
require_relative "errors/forbidden"
|
|
12
|
+
require_relative "errors/gateway_timeout"
|
|
13
|
+
require_relative "errors/gone"
|
|
14
|
+
require_relative "errors/internal_server_error"
|
|
15
|
+
require_relative "errors/method_not_allowed"
|
|
16
|
+
require_relative "errors/not_acceptable"
|
|
17
|
+
require_relative "errors/not_found"
|
|
18
|
+
require_relative "errors/payload_too_large"
|
|
19
|
+
require_relative "errors/payment_required"
|
|
20
|
+
require_relative "errors/request_timeout"
|
|
21
|
+
require_relative "errors/service_unavailable"
|
|
22
|
+
require_relative "errors/too_many_requests"
|
|
23
|
+
require_relative "errors/unauthorized"
|
|
24
|
+
require_relative "errors/unavailable_for_legal_reasons"
|
|
25
|
+
require_relative "errors/unprocessable_entity"
|
|
26
|
+
require_relative "errors/unsupported_media_type"
|
|
27
|
+
require_relative "request_context"
|
|
28
|
+
|
|
29
|
+
module X
|
|
30
|
+
module Core
|
|
31
|
+
# Parses HTTP responses from the X API
|
|
32
|
+
#
|
|
33
|
+
# Internal to x-core: Client parses responses with it.
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
class ResponseParser
|
|
37
|
+
# Mapping of HTTP status codes to error classes
|
|
38
|
+
ERROR_MAP = {
|
|
39
|
+
400 => BadRequest,
|
|
40
|
+
401 => Unauthorized,
|
|
41
|
+
402 => PaymentRequired,
|
|
42
|
+
403 => Forbidden,
|
|
43
|
+
404 => NotFound,
|
|
44
|
+
405 => MethodNotAllowed,
|
|
45
|
+
406 => NotAcceptable,
|
|
46
|
+
408 => RequestTimeout,
|
|
47
|
+
409 => Conflict,
|
|
48
|
+
410 => Gone,
|
|
49
|
+
413 => PayloadTooLarge,
|
|
50
|
+
415 => UnsupportedMediaType,
|
|
51
|
+
422 => UnprocessableEntity,
|
|
52
|
+
429 => TooManyRequests,
|
|
53
|
+
451 => UnavailableForLegalReasons,
|
|
54
|
+
500 => InternalServerError,
|
|
55
|
+
502 => BadGateway,
|
|
56
|
+
503 => ServiceUnavailable,
|
|
57
|
+
504 => GatewayTimeout
|
|
58
|
+
}.freeze
|
|
59
|
+
# Error classes for the statuses ERROR_MAP does not name, keyed by the class of status: 4xx or 5xx
|
|
60
|
+
STATUS_CLASS_ERRORS = {4 => ClientError, 5 => ServerError}.freeze
|
|
61
|
+
|
|
62
|
+
# Parse an HTTP response
|
|
63
|
+
#
|
|
64
|
+
# A successful response without a body, such as 204 No Content, parses to nil. One whose body is not JSON
|
|
65
|
+
# raises, rather than parse to nil as though the response had no body.
|
|
66
|
+
#
|
|
67
|
+
# @api private
|
|
68
|
+
# @param response [Net::HTTPResponse] the HTTP response to parse
|
|
69
|
+
# @param array_class [Class, nil] the class for parsing JSON arrays
|
|
70
|
+
# @param object_class [Class, nil] the class for parsing JSON objects, or a class that builds objects from
|
|
71
|
+
# the whole body (see {#decode})
|
|
72
|
+
# @param client [Client, nil] the client that made the request
|
|
73
|
+
# @param request [Net::HTTPRequest, nil] the request the response answers, which its error names
|
|
74
|
+
# @return [Object, nil] the parsed response body
|
|
75
|
+
# @raise [HTTPError] if the response is not successful
|
|
76
|
+
# @raise [InvalidResponse] if the body of a successful response is not JSON
|
|
77
|
+
# @example Parse a response
|
|
78
|
+
# parser.parse(response: response, request: request)
|
|
79
|
+
def parse(response:, array_class: nil, object_class: nil, client: nil, request: nil)
|
|
80
|
+
raise error(response, request) unless response.is_a?(Net::HTTPSuccess)
|
|
81
|
+
|
|
82
|
+
body = response.body.to_s
|
|
83
|
+
return if blank?(body)
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
decode(body, array_class:, object_class:, client:)
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
raise InvalidResponse.new(http_response: response, **RequestContext.of(request))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Decode a JSON document into the classes a request asked for
|
|
93
|
+
#
|
|
94
|
+
# JSON gives every object in a document the same object_class, at every depth. A class that
|
|
95
|
+
# models a whole response instead responds to from_response, which receives the document
|
|
96
|
+
# parsed into Hashes and Arrays along with the client, and whatever it returns is the result;
|
|
97
|
+
# see {Client} for the protocol, which later versions of 1.x may pass other keywords to. An error from_response
|
|
98
|
+
# raises is tagged as a CallbackError, so that the handlers of a request do not take it for the request's own.
|
|
99
|
+
#
|
|
100
|
+
# @api private
|
|
101
|
+
# @param json [String] the JSON document
|
|
102
|
+
# @param array_class [Class, nil] the class for parsing JSON arrays
|
|
103
|
+
# @param object_class [Class, #from_response, nil] the class for parsing JSON objects, or one that
|
|
104
|
+
# builds objects from the whole document
|
|
105
|
+
# @param client [Client, nil] the client that made the request, passed to from_response
|
|
106
|
+
# @return [Object] the decoded document
|
|
107
|
+
# @raise [JSON::ParserError] if the document is not valid JSON
|
|
108
|
+
# @raise [CallbackError] if from_response raises
|
|
109
|
+
# @example Decode a document into the default classes
|
|
110
|
+
# parser.decode('{"data": {"id": "1"}}') # => {"data" => {"id" => "1"}}
|
|
111
|
+
def decode(json, array_class: nil, object_class: nil, client: nil)
|
|
112
|
+
return JSON.parse(json, array_class:, object_class:) unless object_class.respond_to?(:from_response)
|
|
113
|
+
|
|
114
|
+
document = JSON.parse(json)
|
|
115
|
+
CallbackError.tagging { object_class.from_response(document, client:) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Create the error of a response that is not successful
|
|
119
|
+
#
|
|
120
|
+
# Internal to x-core: TokenEndpoint raises it for a token request the endpoint failed to answer.
|
|
121
|
+
#
|
|
122
|
+
# @api private
|
|
123
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
124
|
+
# @param request [Net::HTTPRequest, nil] the request the response answers, which the error names
|
|
125
|
+
# @return [HTTPError] the error
|
|
126
|
+
# @example Raise the error of a response
|
|
127
|
+
# raise parser.error(response, request)
|
|
128
|
+
def error(response, request)
|
|
129
|
+
error_class(response).new(http_response: response, **RequestContext.of(request))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Check whether a body holds nothing but whitespace
|
|
135
|
+
#
|
|
136
|
+
# A body that is not valid UTF-8 holds something other than whitespace, and a regular expression raises for it,
|
|
137
|
+
# so it is not matched against one: it is not JSON, and raises InvalidResponse, which holds it.
|
|
138
|
+
#
|
|
139
|
+
# @api private
|
|
140
|
+
# @param body [String] the body of the response, tagged UTF-8
|
|
141
|
+
# @return [Boolean] true for a body that is empty or holds whitespace alone
|
|
142
|
+
def blank?(body) = body.valid_encoding? && !body.match?(/\S/)
|
|
143
|
+
|
|
144
|
+
# Get the error class for a response, falling back on its class of status
|
|
145
|
+
# @api private
|
|
146
|
+
# @param response [Net::HTTPResponse] the HTTP response
|
|
147
|
+
# @return [Class] the error class
|
|
148
|
+
def error_class(response)
|
|
149
|
+
status = Integer(response.code)
|
|
150
|
+
ERROR_MAP.fetch(status) { STATUS_CLASS_ERRORS.fetch(status / 100, HTTPError) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
private_constant :ResponseParser
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "socket"
|
|
5
|
+
require_relative "errors/callback_error"
|
|
6
|
+
require_relative "errors/http_error"
|
|
7
|
+
require_relative "errors/network_error"
|
|
8
|
+
require_relative "errors/request_timeout"
|
|
9
|
+
require_relative "errors/server_error"
|
|
10
|
+
require_relative "setting_validator"
|
|
11
|
+
|
|
12
|
+
module X
|
|
13
|
+
module Core
|
|
14
|
+
# Sends a request again after the API failed to answer it, or after its answer never arrived
|
|
15
|
+
#
|
|
16
|
+
# Internal to x-core: Client retries with it, and takes max_retries, and Client#with_retries sends a request
|
|
17
|
+
# again with it that a client sends no more than once, as it does any POST.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
class RetryHandler
|
|
21
|
+
# Default maximum number of retries, which sends an idempotent request twice more before it raises
|
|
22
|
+
DEFAULT_MAX_RETRIES = 2
|
|
23
|
+
# Seconds to wait before the first retry, doubled for each retry after, up to MAX_RETRY_AFTER
|
|
24
|
+
INITIAL_WAIT = 1
|
|
25
|
+
# The longest wait a response may ask for that a request waits out before it is sent again, and the longest the
|
|
26
|
+
# backoff grows to; a response that asks to be left alone for longer raises at once, rather than hold a caller for
|
|
27
|
+
# minutes on end
|
|
28
|
+
MAX_RETRY_AFTER = 60
|
|
29
|
+
# The failures a retry may follow, none of which the request itself is the reason for: a 408 says the API gave
|
|
30
|
+
# up waiting for the request, not that it refused it
|
|
31
|
+
RETRIABLE_ERRORS = [NetworkError, ServerError, RequestTimeout].freeze
|
|
32
|
+
# The errors of a socket, the cause of a NetworkError, that fail a request before any of it is written: a host
|
|
33
|
+
# that cannot be resolved or reached, a connection refused, and a connection or TLS handshake that timed out
|
|
34
|
+
UNSENT_ERRORS = [Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, SocketError].freeze
|
|
35
|
+
|
|
36
|
+
# The maximum number of times to send an idempotent request again after a failure
|
|
37
|
+
# @api private
|
|
38
|
+
# @return [Integer] the maximum number of retries
|
|
39
|
+
# @example Read the maximum retries
|
|
40
|
+
# handler.max_retries # => 2
|
|
41
|
+
attr_reader :max_retries
|
|
42
|
+
|
|
43
|
+
# Initialize a new retry handler
|
|
44
|
+
#
|
|
45
|
+
# @api private
|
|
46
|
+
# @param max_retries [Integer] the maximum number of times to send an idempotent request again
|
|
47
|
+
# @return [RetryHandler] a new instance
|
|
48
|
+
# @raise [ArgumentError] if the maximum number of retries is not an Integer of at least 0
|
|
49
|
+
# @example Create a handler that sends a failed request twice more
|
|
50
|
+
# handler = X::Core::RetryHandler.new(max_retries: 2)
|
|
51
|
+
def initialize(max_retries: DEFAULT_MAX_RETRIES)
|
|
52
|
+
@max_retries = SettingValidator.count!(:max_retries, max_retries)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Run a request, running it again after a failure of the API or of the network
|
|
56
|
+
#
|
|
57
|
+
# A request is sent again while retries remain, after waiting up to a second before the first retry and up to
|
|
58
|
+
# twice as long before each retry after, but never more than MAX_RETRY_AFTER, or for as long as the response asks
|
|
59
|
+
# when it carries a Retry-After header, whichever is longer. A response that asks to be left alone for longer than MAX_RETRY_AFTER raises
|
|
60
|
+
# at once, since waiting it out would hold the caller for minutes. Only an idempotent request is retried: the
|
|
61
|
+
# API may have acted on a POST whose answer never arrived, so sending that again could post twice. The block
|
|
62
|
+
# must build its request anew each time, so that each attempt is signed afresh.
|
|
63
|
+
#
|
|
64
|
+
# A TooManyRequests is not retried here: a request sent again before its rate limit resets is refused again, so
|
|
65
|
+
# RateLimitHandler waits for the reset instead, for as long as max_rate_limit_wait allows, well past
|
|
66
|
+
# MAX_RETRY_AFTER.
|
|
67
|
+
#
|
|
68
|
+
# A NetworkError is retried only when the request never left: a request that timed out reading its response,
|
|
69
|
+
# or whose connection dropped once it was written, may have been answered, and the API bills a read it answered
|
|
70
|
+
# whether or not the answer arrived, so sending it again could bill it again. resend_unanswered retries those
|
|
71
|
+
# too, for a request the API bills nothing for, such as the chunk of an upload.
|
|
72
|
+
#
|
|
73
|
+
# An error a callback of the request raised, such as on_response, is not the API's failure, and is raised as it
|
|
74
|
+
# is, so that a request Client#with_retries wraps is not sent again for it, though the API answered it.
|
|
75
|
+
#
|
|
76
|
+
# @api private
|
|
77
|
+
# @param idempotent [Boolean] whether sending the request again has the same effect as sending it once
|
|
78
|
+
# @param resend_unanswered [Boolean] whether to send the request again after a NetworkError that may have come
|
|
79
|
+
# after the API received it
|
|
80
|
+
# @yield runs the request
|
|
81
|
+
# @return [Object] what the block returns
|
|
82
|
+
# @raise [NetworkError] if the request fails once more than the retries allow
|
|
83
|
+
# @raise [ServerError, RequestTimeout] if the API fails to answer once more than the retries allow, or asks for a
|
|
84
|
+
# wait longer than MAX_RETRY_AFTER
|
|
85
|
+
# @example Retry a lookup
|
|
86
|
+
# handler.handle(idempotent: true) { client.get("users/me") }
|
|
87
|
+
def handle(idempotent:, resend_unanswered: false)
|
|
88
|
+
retries = 0
|
|
89
|
+
begin
|
|
90
|
+
yield
|
|
91
|
+
rescue *RETRIABLE_ERRORS => e
|
|
92
|
+
retries += 1
|
|
93
|
+
requested = retry_after(e)
|
|
94
|
+
raise unless idempotent && retries <= max_retries && requested.to_i <= MAX_RETRY_AFTER && resendable?(e, resend_unanswered)
|
|
95
|
+
|
|
96
|
+
sleep([requested, backoff(retries)].compact.max)
|
|
97
|
+
retry
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Whether a failure leaves a request safe to send again
|
|
104
|
+
#
|
|
105
|
+
# A ServerError or a RequestTimeout is an answer, which says the API failed to act on the request. A NetworkError
|
|
106
|
+
# says the API never received it only when its cause is among UNSENT_ERRORS; any other may have come after the
|
|
107
|
+
# API answered. None of them says so when a callback raised it, rather than the API.
|
|
108
|
+
#
|
|
109
|
+
# @api private
|
|
110
|
+
# @param error [Error] the error the request raised
|
|
111
|
+
# @param resend_unanswered [Boolean] whether a request the API may have answered is sent again
|
|
112
|
+
# @return [Boolean] true if the request may be sent again
|
|
113
|
+
def resendable?(error, resend_unanswered)
|
|
114
|
+
return false if CallbackError.untagged?(error)
|
|
115
|
+
|
|
116
|
+
resend_unanswered || error.is_a?(HTTPError) || UNSENT_ERRORS.any? { |unsent| error.cause.is_a?(unsent) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The seconds a response asks a request to wait before it is sent again
|
|
120
|
+
#
|
|
121
|
+
# A request that never got a response, which raised a NetworkError, asks for no wait, and neither does a
|
|
122
|
+
# response that carries no Retry-After header.
|
|
123
|
+
#
|
|
124
|
+
# @api private
|
|
125
|
+
# @param error [Error] the error the request raised
|
|
126
|
+
# @return [Integer, nil] the seconds the response asks for, or nil if it asks for none
|
|
127
|
+
def retry_after(error)
|
|
128
|
+
error.retry_after if error.is_a?(HTTPError)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The seconds to wait before a retry
|
|
132
|
+
#
|
|
133
|
+
# The wait doubles with each retry until it reaches MAX_RETRY_AFTER, and a random share of up to half of it is
|
|
134
|
+
# taken off. The share is what keeps the requests apart: a failure of the API fails every request in flight at
|
|
135
|
+
# once, and requests that waited the same time would be sent again together, to fail together once more. The
|
|
136
|
+
# cap keeps a client given many retries from sleeping for longer than a response may ask it to, as the waits
|
|
137
|
+
# of a doubling without end would: the tenth retry would otherwise wait over eight minutes.
|
|
138
|
+
#
|
|
139
|
+
# @api private
|
|
140
|
+
# @param retries [Integer] the number of the retry, counting from one
|
|
141
|
+
# @return [Float] the seconds to wait
|
|
142
|
+
def backoff(retries)
|
|
143
|
+
wait = [INITIAL_WAIT << (retries - 1), MAX_RETRY_AFTER].min
|
|
144
|
+
wait - (rand * wait / 2)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
private_constant :RetryHandler
|
|
148
|
+
end
|
|
149
|
+
end
|