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,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors/unsupported_format"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
# A problem the API described, in a response that failed or in one that otherwise succeeded
|
|
8
|
+
#
|
|
9
|
+
# The API describes what went wrong the same way whether it refused the request, which raises an {HTTPError}
|
|
10
|
+
# whose {HTTPError#problem} is one of these, or answered it with the resources it could and named the rest as
|
|
11
|
+
# errors, which the object layer reads as the problems of a resource or a page. Code that acts on the reason
|
|
12
|
+
# rather than logging it reads the same object either way.
|
|
13
|
+
#
|
|
14
|
+
# @api public
|
|
15
|
+
class Problem
|
|
16
|
+
# The number of the format of the state Marshal writes, which every release of 1.x writes
|
|
17
|
+
#
|
|
18
|
+
# A later release of 1.x adds to the state only what an earlier one ignores, parts after those it reads and keys of a
|
|
19
|
+
# Hash it does not read, so that the state one release of 1.x writes is read by every other, earlier or later.
|
|
20
|
+
MARSHAL_FORMAT = 1
|
|
21
|
+
# The name YAML writes each part of the state under, in the order Marshal writes them
|
|
22
|
+
YAML_KEYS = %w[format attrs].freeze
|
|
23
|
+
private_constant :MARSHAL_FORMAT, :YAML_KEYS
|
|
24
|
+
|
|
25
|
+
# The raw attributes of the problem
|
|
26
|
+
# @api public
|
|
27
|
+
# @return [Hash{String => Object}] the attributes
|
|
28
|
+
# @example Get the raw attributes
|
|
29
|
+
# problem.attrs # => {"title" => "Not Found Error", "detail" => "Could not find tweet with pinned_tweet_id: [1].", ...}
|
|
30
|
+
attr_reader :attrs
|
|
31
|
+
|
|
32
|
+
# @!method to_h
|
|
33
|
+
# Alias for attrs, returns the raw attributes
|
|
34
|
+
# @api public
|
|
35
|
+
# @return [Hash{String => Object}] the attributes
|
|
36
|
+
# @example Convert a problem to a hash
|
|
37
|
+
# problem.to_h
|
|
38
|
+
alias_method :to_h, :attrs
|
|
39
|
+
|
|
40
|
+
# The problems a response body reports
|
|
41
|
+
#
|
|
42
|
+
# @api public
|
|
43
|
+
# @param body [Hash, nil] the parsed response body
|
|
44
|
+
# @return [Array<Problem>] the problems, empty if there are none
|
|
45
|
+
# @example Read the problems of a response
|
|
46
|
+
# X::Problem.all_from(client.get("users/me"))
|
|
47
|
+
def self.all_from(body)
|
|
48
|
+
entries = Array(body.to_h["errors"]) #: Array[untyped]
|
|
49
|
+
entries.filter_map { |attrs| Hash.try_convert(attrs)&.then { |hash| new(hash) } }.freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Initialize a problem from the attributes the API reported
|
|
53
|
+
#
|
|
54
|
+
# @api public
|
|
55
|
+
# @param attrs [Hash{String => Object}] the attributes
|
|
56
|
+
# @return [Problem] a new problem
|
|
57
|
+
# @example Build a problem
|
|
58
|
+
# X::Problem.new({"title" => "Not Found Error"})
|
|
59
|
+
def initialize(attrs)
|
|
60
|
+
@attrs = deep_freeze(attrs)
|
|
61
|
+
freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The short, general description of the problem
|
|
65
|
+
#
|
|
66
|
+
# @api public
|
|
67
|
+
# @return [String, nil] the title
|
|
68
|
+
# @example Get the title
|
|
69
|
+
# problem.title # => "Not Found Error"
|
|
70
|
+
def title = attrs["title"]
|
|
71
|
+
|
|
72
|
+
# The description of this occurrence of the problem
|
|
73
|
+
#
|
|
74
|
+
# @api public
|
|
75
|
+
# @return [String, nil] the detail
|
|
76
|
+
# @example Get the detail
|
|
77
|
+
# problem.detail # => "Could not find tweet with pinned_tweet_id: [1]."
|
|
78
|
+
def detail = attrs["detail"]
|
|
79
|
+
|
|
80
|
+
# The URI that identifies the kind of problem
|
|
81
|
+
#
|
|
82
|
+
# @api public
|
|
83
|
+
# @return [String, nil] the type
|
|
84
|
+
# @example Get the type
|
|
85
|
+
# problem.type # => "https://api.x.com/2/problems/resource-not-found"
|
|
86
|
+
def type = attrs["type"]
|
|
87
|
+
|
|
88
|
+
# The kind of resource the problem concerns
|
|
89
|
+
#
|
|
90
|
+
# @api public
|
|
91
|
+
# @return [String, nil] the resource type, such as tweet or user
|
|
92
|
+
# @example Get the resource type
|
|
93
|
+
# problem.resource_type # => "tweet"
|
|
94
|
+
def resource_type = attrs["resource_type"]
|
|
95
|
+
|
|
96
|
+
# The identifier of the resource the problem concerns
|
|
97
|
+
#
|
|
98
|
+
# It is the String the API gave, whatever the kind of resource, since the API names a user by a username as
|
|
99
|
+
# often as by an identifier, and a space, a place, or media by an identifier that is not a number; {#about?}
|
|
100
|
+
# compares it with a resource, or the identifier of one, as a String.
|
|
101
|
+
#
|
|
102
|
+
# @api public
|
|
103
|
+
# @return [String, nil] the resource identifier
|
|
104
|
+
# @example Get the resource identifier
|
|
105
|
+
# problem.resource_id # => "1"
|
|
106
|
+
def resource_id = attrs["resource_id"]
|
|
107
|
+
|
|
108
|
+
# The request parameter the problem concerns
|
|
109
|
+
#
|
|
110
|
+
# @api public
|
|
111
|
+
# @return [String, nil] the parameter, such as ids or pinned_tweet_id
|
|
112
|
+
# @example Get the parameter
|
|
113
|
+
# problem.parameter # => "pinned_tweet_id"
|
|
114
|
+
def parameter = attrs["parameter"]
|
|
115
|
+
|
|
116
|
+
# The value of the parameter the problem concerns
|
|
117
|
+
#
|
|
118
|
+
# It is the value the API gave, as the request sent it, so an identifier is a String, as resource_id is.
|
|
119
|
+
#
|
|
120
|
+
# @api public
|
|
121
|
+
# @return [Object, nil] the value
|
|
122
|
+
# @example Get the value
|
|
123
|
+
# problem.value # => "1"
|
|
124
|
+
def value = attrs["value"]
|
|
125
|
+
|
|
126
|
+
# The message the API gave for a request it refused
|
|
127
|
+
#
|
|
128
|
+
# The errors of a request the API refused carry a message where the problems of a response that succeeded carry
|
|
129
|
+
# a detail, so a problem read from a failed request reads as one of either.
|
|
130
|
+
#
|
|
131
|
+
# @api public
|
|
132
|
+
# @return [String, nil] the message
|
|
133
|
+
# @example Get the message
|
|
134
|
+
# problem.message # => "Could not authenticate you"
|
|
135
|
+
def message = attrs["message"]
|
|
136
|
+
|
|
137
|
+
# Check whether the problem is a resource that was not found
|
|
138
|
+
#
|
|
139
|
+
# @api public
|
|
140
|
+
# @return [Boolean] true for a resource-not-found problem
|
|
141
|
+
# @example Skip the posts that no longer exist
|
|
142
|
+
# problems.reject(&:not_found?)
|
|
143
|
+
def not_found? = type.to_s.end_with?("/resource-not-found")
|
|
144
|
+
|
|
145
|
+
# Check whether the problem is an operational-disconnect of a stream
|
|
146
|
+
#
|
|
147
|
+
# X sends one before it closes a stream for its own reasons. A stream reconnects after a line that holds such problems alone, as it does after a connection that dropped.
|
|
148
|
+
#
|
|
149
|
+
# @api public
|
|
150
|
+
# @return [Boolean] true for an operational-disconnect problem
|
|
151
|
+
# @example Tell a disconnect apart from another error of a stream
|
|
152
|
+
# error.problems.all?(&:disconnect?)
|
|
153
|
+
def disconnect? = type.to_s.end_with?("/operational-disconnect")
|
|
154
|
+
|
|
155
|
+
# Check whether the problem is the usage cap of the project, reached for the month
|
|
156
|
+
#
|
|
157
|
+
# X refuses every request of a project that has used the posts its plan allows for the month with a 429 of
|
|
158
|
+
# this problem, until the month ends, so a client neither waits for it nor sends the request again, and a stream
|
|
159
|
+
# does not reconnect after it, however its rate limits are set.
|
|
160
|
+
#
|
|
161
|
+
# @api public
|
|
162
|
+
# @return [Boolean] true for a usage-capped problem
|
|
163
|
+
# @example Tell the usage cap apart from a rate limit
|
|
164
|
+
# wait_for_the_next_month if error.problem&.usage_capped?
|
|
165
|
+
def usage_capped? = type.to_s.end_with?("/usage-capped")
|
|
166
|
+
|
|
167
|
+
# Check whether the problem is about a resource
|
|
168
|
+
#
|
|
169
|
+
# The resource_id of a problem is the String the API gave, where the resources of x-resources hold an Integer
|
|
170
|
+
# identifier, so the identifier of the resource, or the identifier given, is compared with it as a String. A
|
|
171
|
+
# username names the user a problem names by it. A problem that names no resource is about none.
|
|
172
|
+
#
|
|
173
|
+
# @api public
|
|
174
|
+
# @param resource [#id, Integer, String] the resource, or its identifier
|
|
175
|
+
# @return [Boolean] true if the resource_id of the problem names the resource
|
|
176
|
+
# @example Check whether a problem is about a user
|
|
177
|
+
# problem.about?(user) # => true
|
|
178
|
+
# @example Check whether a problem is about a post by its identifier
|
|
179
|
+
# error.problems.any? { |problem| problem.about?(1_234_567_890) }
|
|
180
|
+
def about?(resource)
|
|
181
|
+
id = case resource
|
|
182
|
+
when Integer, String then resource
|
|
183
|
+
else resource.id
|
|
184
|
+
end
|
|
185
|
+
resource_id.eql?(id.to_s)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Check whether another problem is the same problem
|
|
189
|
+
#
|
|
190
|
+
# @api public
|
|
191
|
+
# @param other [Object] the other problem
|
|
192
|
+
# @return [Boolean] true if the other problem is a Problem of the same attributes
|
|
193
|
+
# @example Check whether a response reported a problem before
|
|
194
|
+
# seen.include?(problem)
|
|
195
|
+
def ==(other) = other.instance_of?(self.class) && attrs.eql?(other.attrs)
|
|
196
|
+
alias_method :eql?, :==
|
|
197
|
+
|
|
198
|
+
# The hash of the problem, which equal problems share
|
|
199
|
+
#
|
|
200
|
+
# @api public
|
|
201
|
+
# @return [Integer] the hash
|
|
202
|
+
# @example Count the distinct problems
|
|
203
|
+
# problems.uniq.size
|
|
204
|
+
def hash = [self.class, attrs].hash
|
|
205
|
+
|
|
206
|
+
# The attributes, as a JSON encoder and ActiveSupport read them
|
|
207
|
+
#
|
|
208
|
+
# ActiveSupport's Object#as_json would otherwise read the instance variables, which is the same Hash under
|
|
209
|
+
# another name.
|
|
210
|
+
#
|
|
211
|
+
# @api public
|
|
212
|
+
# @return [Hash{String => Object}] the attributes
|
|
213
|
+
# @example Serialize a problem
|
|
214
|
+
# problem.as_json # => {"title" => "Not Found Error"}
|
|
215
|
+
def as_json(*) = attrs
|
|
216
|
+
|
|
217
|
+
# The attributes as JSON
|
|
218
|
+
#
|
|
219
|
+
# @api public
|
|
220
|
+
# @param state [JSON::State, nil] the state a JSON encoder passes, which the attributes are given
|
|
221
|
+
# @return [String] the attributes as a JSON object
|
|
222
|
+
# @example Serialize a problem
|
|
223
|
+
# problem.to_json # => "{\"title\":\"Not Found Error\"}"
|
|
224
|
+
def to_json(state = nil) = as_json.to_json(state)
|
|
225
|
+
|
|
226
|
+
# Summarize the problem for the console
|
|
227
|
+
#
|
|
228
|
+
# A problem the API described in a response that succeeded carries a detail, and one it named among the errors
|
|
229
|
+
# of a request it refused carries a message in its place, so the summary reads whichever of the two it holds.
|
|
230
|
+
#
|
|
231
|
+
# @api public
|
|
232
|
+
# @return [String] the class name, title, and detail, or message
|
|
233
|
+
# @example Inspect a problem of a response that succeeded
|
|
234
|
+
# problem.inspect # => #<X::Problem Not Found Error: Could not find tweet with pinned_tweet_id: [1].>
|
|
235
|
+
# @example Inspect a problem of a request the API refused
|
|
236
|
+
# problem.inspect # => #<X::Problem Could not authenticate you>
|
|
237
|
+
def inspect = "#<#{self.class} #{[title, detail || message].compact.join(": ")}>"
|
|
238
|
+
|
|
239
|
+
# The state Marshal writes
|
|
240
|
+
#
|
|
241
|
+
# What is written is plain data, led by the number of its format, so that a problem written by one release of 1.x
|
|
242
|
+
# is read by a later one: its attributes, as the API described it.
|
|
243
|
+
#
|
|
244
|
+
# @api public
|
|
245
|
+
# @return [Array(Integer, Hash{String => Object})] the number of the format, then the attributes
|
|
246
|
+
# @example Cache the problems of a response
|
|
247
|
+
# Rails.cache.write("problems", X::Problem.all_from(body))
|
|
248
|
+
def marshal_dump = [MARSHAL_FORMAT, attrs]
|
|
249
|
+
|
|
250
|
+
# Restore a problem Marshal read, frozen as the problem that was written was
|
|
251
|
+
#
|
|
252
|
+
# @api public
|
|
253
|
+
# @param state [Array] the state Marshal wrote
|
|
254
|
+
# @return [void]
|
|
255
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
256
|
+
# @example Read cached problems
|
|
257
|
+
# Marshal.load(Marshal.dump(problem)).title
|
|
258
|
+
def marshal_load(state)
|
|
259
|
+
format, attrs = state
|
|
260
|
+
raise UnsupportedFormat, "#{self.class} reads format #{MARSHAL_FORMAT} of Marshal, not #{format.inspect}" unless MARSHAL_FORMAT.eql?(format)
|
|
261
|
+
|
|
262
|
+
initialize(attrs)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Write the state Marshal writes as YAML
|
|
266
|
+
#
|
|
267
|
+
# YAML would write the instance variables of the problem, and read them back into one that is not frozen, so it says
|
|
268
|
+
# how it is written: each part of the state Marshal writes, under its name.
|
|
269
|
+
#
|
|
270
|
+
# @api public
|
|
271
|
+
# @param coder [Psych::Coder] the coder YAML writes the problem with
|
|
272
|
+
# @return [void]
|
|
273
|
+
# @example Write a problem as YAML
|
|
274
|
+
# YAML.dump(problem)
|
|
275
|
+
def encode_with(coder) = YAML_KEYS.zip(marshal_dump) { |key, value| coder[key] = value }
|
|
276
|
+
|
|
277
|
+
# Restore a problem YAML read, frozen, as Marshal restores one
|
|
278
|
+
#
|
|
279
|
+
# @api public
|
|
280
|
+
# @param coder [Psych::Coder] the coder YAML read the problem with
|
|
281
|
+
# @return [void]
|
|
282
|
+
# @raise [UnsupportedFormat] if the state is of a format this release does not read
|
|
283
|
+
# @example Read a problem written as YAML
|
|
284
|
+
# YAML.unsafe_load(YAML.dump(problem)).title
|
|
285
|
+
def init_with(coder) = marshal_load(coder.map.values_at(*YAML_KEYS))
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
# Copy the attributes with String keys, and freeze them and what they hold
|
|
290
|
+
# @api private
|
|
291
|
+
# @param value [Object] the value
|
|
292
|
+
# @return [Object] the frozen copy
|
|
293
|
+
def deep_freeze(value)
|
|
294
|
+
case value
|
|
295
|
+
when Hash then value.transform_keys(&:to_s).transform_values { |element| deep_freeze(element) }.freeze
|
|
296
|
+
when Array then value.map { |element| deep_freeze(element) }.freeze
|
|
297
|
+
when String then value.dup.freeze
|
|
298
|
+
else value
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Core
|
|
5
|
+
# The proxy URL a connection, or the internals of a client, was built with, included into each
|
|
6
|
+
#
|
|
7
|
+
# A proxy URL can hold the user and password of the proxy, so neither reveals it to a caller, as a client reveals
|
|
8
|
+
# none of its secrets. The reader is private, and a copy of a client, which opens its connections with the proxy
|
|
9
|
+
# of the client it copies, calls it on the internals of that client with __send__.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module ProxySetting
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# The proxy URL, as it was given
|
|
16
|
+
# @api private
|
|
17
|
+
# @return [String, URI::Generic, nil] the proxy URL, or nil to take the proxy the environment names
|
|
18
|
+
def proxy_url = @proxy_url
|
|
19
|
+
end
|
|
20
|
+
private_constant :ProxySetting
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
# Represents rate limit information from an API response
|
|
5
|
+
# @api public
|
|
6
|
+
class RateLimit
|
|
7
|
+
# Rate limit type identifier
|
|
8
|
+
RATE_LIMIT_TYPE = "rate-limit"
|
|
9
|
+
# App limit type identifier
|
|
10
|
+
APP_LIMIT_TYPE = "app-limit-24hour"
|
|
11
|
+
# User limit type identifier
|
|
12
|
+
USER_LIMIT_TYPE = "user-limit-24hour"
|
|
13
|
+
# All supported rate limit types
|
|
14
|
+
TYPES = [RATE_LIMIT_TYPE, APP_LIMIT_TYPE, USER_LIMIT_TYPE].freeze
|
|
15
|
+
# The fields of a rate limit, each of which a response reports in a header of its own
|
|
16
|
+
FIELDS = %w[limit remaining reset].freeze
|
|
17
|
+
private_constant :FIELDS
|
|
18
|
+
# The value of a header of a rate limit, which counts requests, or the seconds since the epoch, in base 10
|
|
19
|
+
COUNT = /\A\d+\z/
|
|
20
|
+
private_constant :COUNT
|
|
21
|
+
|
|
22
|
+
# The type of rate limit
|
|
23
|
+
# @api public
|
|
24
|
+
# @return [String] the type of rate limit
|
|
25
|
+
# @example Get the rate limit type
|
|
26
|
+
# rate_limit.type # => "rate-limit"
|
|
27
|
+
attr_reader :type
|
|
28
|
+
|
|
29
|
+
# Every rate limit a response reports in full, in the order of TYPES
|
|
30
|
+
#
|
|
31
|
+
# Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that
|
|
32
|
+
# response may, and is private, so X::Response#rate_limits and X::TooManyRequests#rate_limits, which read the
|
|
33
|
+
# same limits, call it with __send__.
|
|
34
|
+
#
|
|
35
|
+
# @api private
|
|
36
|
+
# @param http_response [Net::HTTPResponse] the HTTP response
|
|
37
|
+
# @return [Array<RateLimit>] the 15-minute limit, and the 24-hour app and user limits, when reported
|
|
38
|
+
# @example Read every limit a response reports
|
|
39
|
+
# X::RateLimit.__send__(:all_from, http_response)
|
|
40
|
+
def self.all_from(http_response) = TYPES.filter_map { |type| new(type:, http_response:) if reported?(type, http_response) }
|
|
41
|
+
|
|
42
|
+
# Check whether a response has the limit, remaining, and reset of a rate limit
|
|
43
|
+
#
|
|
44
|
+
# Each is a count in base 10, and a limit whose header holds anything else, such as a proxy that mangled it, is
|
|
45
|
+
# not reported, rather than read as another number or raise once it is read, which would raise from a refusal
|
|
46
|
+
# in place of the TooManyRequests the refusal is.
|
|
47
|
+
#
|
|
48
|
+
# Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that
|
|
49
|
+
# response may.
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
52
|
+
# @param type [String] the type of rate limit
|
|
53
|
+
# @param http_response [Net::HTTPResponse] the HTTP response
|
|
54
|
+
# @return [Boolean] true if the response has every header of the rate limit, each a count in base 10
|
|
55
|
+
# @example Check for the 15-minute rate limit
|
|
56
|
+
# X::RateLimit.__send__(:reported?, "rate-limit", response)
|
|
57
|
+
def self.reported?(type, http_response) = FIELDS.all? { |field| http_response["x-#{type}-#{field}"].to_s.match?(COUNT) }
|
|
58
|
+
private_class_method :all_from, :reported?, :new
|
|
59
|
+
|
|
60
|
+
# Initialize a new RateLimit
|
|
61
|
+
#
|
|
62
|
+
# Internal to x-core: it takes the Net::HTTP response of a request, so that it can change within 1.x, as that
|
|
63
|
+
# response may, and new is private, so that only all_from builds one, as X::Response#rate_limits and
|
|
64
|
+
# X::TooManyRequests#rate_limits read them.
|
|
65
|
+
#
|
|
66
|
+
# @api private
|
|
67
|
+
# @param type [String] the type of rate limit
|
|
68
|
+
# @param http_response [Net::HTTPResponse] the HTTP response containing rate limit headers
|
|
69
|
+
# @return [RateLimit] a new instance
|
|
70
|
+
# @example Create a rate limit instance
|
|
71
|
+
# rate_limit = X::RateLimit.__send__(:new, type: "rate-limit", http_response: response)
|
|
72
|
+
def initialize(type:, http_response:)
|
|
73
|
+
@type = type
|
|
74
|
+
@http_response = http_response
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get the rate limit maximum
|
|
78
|
+
#
|
|
79
|
+
# @api public
|
|
80
|
+
# @return [Integer] the maximum number of requests allowed
|
|
81
|
+
# @example Get the rate limit
|
|
82
|
+
# rate_limit.limit
|
|
83
|
+
def limit = field("limit")
|
|
84
|
+
|
|
85
|
+
# Get the remaining requests
|
|
86
|
+
#
|
|
87
|
+
# @api public
|
|
88
|
+
# @return [Integer] the number of requests remaining
|
|
89
|
+
# @example Get the remaining requests
|
|
90
|
+
# rate_limit.remaining
|
|
91
|
+
def remaining = field("remaining")
|
|
92
|
+
|
|
93
|
+
# Check whether the limit has no requests left
|
|
94
|
+
#
|
|
95
|
+
# @api public
|
|
96
|
+
# @return [Boolean] true if no requests remain in the window
|
|
97
|
+
# @example Wait for a limit that is used up
|
|
98
|
+
# sleep rate_limit.reset_in if rate_limit.exhausted?
|
|
99
|
+
def exhausted? = remaining.zero?
|
|
100
|
+
|
|
101
|
+
# Get the time when the rate limit resets
|
|
102
|
+
#
|
|
103
|
+
# @api public
|
|
104
|
+
# @return [Time] the time when the rate limit resets
|
|
105
|
+
# @example Get the reset time
|
|
106
|
+
# rate_limit.reset_at
|
|
107
|
+
def reset_at = Time.at(field("reset"))
|
|
108
|
+
|
|
109
|
+
# Get the seconds until the rate limit resets
|
|
110
|
+
#
|
|
111
|
+
# @api public
|
|
112
|
+
# @return [Integer] the seconds until the rate limit resets
|
|
113
|
+
# @example Get the reset time in seconds
|
|
114
|
+
# rate_limit.reset_in
|
|
115
|
+
def reset_in
|
|
116
|
+
[(reset_at - Time.now).ceil, 0].max
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# The response the limit was read from, as the client received it
|
|
122
|
+
#
|
|
123
|
+
# Internal to x-core: the limit reads its headers from it, so that a limit promises nothing of Net::HTTP.
|
|
124
|
+
# {#limit}, {#remaining}, and {#reset_at} are the headers it reports, and X::Response#http_response and
|
|
125
|
+
# X::HTTPError#http_response are the response itself.
|
|
126
|
+
#
|
|
127
|
+
# @api private
|
|
128
|
+
# @return [Net::HTTPResponse] the HTTP response the rate limit headers came with
|
|
129
|
+
attr_reader :http_response
|
|
130
|
+
|
|
131
|
+
# Read a field of the rate limit from its header, in base 10
|
|
132
|
+
# @api private
|
|
133
|
+
# @param name [String] the name of the field: limit, remaining, or reset
|
|
134
|
+
# @return [Integer] the value of the field
|
|
135
|
+
def field(name) = Integer(http_response.fetch("x-#{type}-#{name}"), 10)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors/too_many_requests"
|
|
4
|
+
require_relative "setting_validator"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Core
|
|
8
|
+
# Retries requests the API refuses for a rate limit, waiting until the limit resets
|
|
9
|
+
#
|
|
10
|
+
# Internal to x-core: Client retries with it, and takes max_rate_limit_retries and max_rate_limit_wait.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class RateLimitHandler
|
|
14
|
+
# Default maximum number of retries, which retries nothing
|
|
15
|
+
DEFAULT_MAX_RETRIES = 0
|
|
16
|
+
# Default maximum number of seconds to wait for a rate limit to reset, the length of a 15-minute window
|
|
17
|
+
DEFAULT_MAX_WAIT = 900
|
|
18
|
+
# Seconds to wait before the first retry of a request refused without a Retry-After header or a reset time,
|
|
19
|
+
# doubled for each retry after
|
|
20
|
+
UNREPORTED_RESET_WAIT = 60
|
|
21
|
+
# The most seconds added at random to a wait, which keep apart the requests one reset releases
|
|
22
|
+
RESET_JITTER = 5
|
|
23
|
+
# The fiber-local key of the retries counted across the attempts of the request being sent
|
|
24
|
+
RETRIES = :x_core_rate_limit_retries
|
|
25
|
+
# The fiber-local key of the retries Client#with_retries hands down to the request it sends again
|
|
26
|
+
HANDED = :x_core_rate_limit_retries_handed
|
|
27
|
+
|
|
28
|
+
# The maximum number of times to retry a request refused for a rate limit
|
|
29
|
+
# @api private
|
|
30
|
+
# @return [Integer] the maximum number of retries
|
|
31
|
+
# @example Get or set the maximum retries
|
|
32
|
+
# handler.max_rate_limit_retries = 3
|
|
33
|
+
attr_reader :max_rate_limit_retries
|
|
34
|
+
|
|
35
|
+
# The maximum number of seconds to wait for a rate limit to reset before retrying
|
|
36
|
+
# @api private
|
|
37
|
+
# @return [Integer, Float] the maximum wait in seconds
|
|
38
|
+
# @example Get or set the maximum wait
|
|
39
|
+
# handler.max_rate_limit_wait = 60
|
|
40
|
+
attr_reader :max_rate_limit_wait
|
|
41
|
+
|
|
42
|
+
# Initialize a new rate limit handler
|
|
43
|
+
#
|
|
44
|
+
# @api private
|
|
45
|
+
# @param max_rate_limit_retries [Integer] the maximum number of times to retry a request refused for a rate limit
|
|
46
|
+
# @param max_rate_limit_wait [Integer, Float] the maximum number of seconds to wait for a rate limit to reset
|
|
47
|
+
# @return [RateLimitHandler] a new instance
|
|
48
|
+
# @raise [ArgumentError] if the maximum number of retries is not an Integer of at least 0, or the maximum wait
|
|
49
|
+
# is not a number of seconds of at least 0
|
|
50
|
+
# @example Create a rate limit handler
|
|
51
|
+
# handler = X::Core::RateLimitHandler.new(max_rate_limit_retries: 3)
|
|
52
|
+
def initialize(max_rate_limit_retries: DEFAULT_MAX_RETRIES, max_rate_limit_wait: DEFAULT_MAX_WAIT)
|
|
53
|
+
@max_rate_limit_retries = SettingValidator.count!(:max_rate_limit_retries, max_rate_limit_retries)
|
|
54
|
+
@max_rate_limit_wait = SettingValidator.seconds!(:max_rate_limit_wait, max_rate_limit_wait)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Run a request, running it again after a rate limit resets
|
|
58
|
+
#
|
|
59
|
+
# A request is retried while retries remain and the wait the response asks for is within the maximum wait;
|
|
60
|
+
# otherwise the error is raised, as it is for the usage cap of the project, which lasts until the month ends. A response that asks for neither a wait nor a reset time waits a minute
|
|
61
|
+
# before the first retry, doubling the wait for each retry after, as X recommends. The block must build its
|
|
62
|
+
# request anew each time, so that each attempt is signed afresh.
|
|
63
|
+
#
|
|
64
|
+
# Every request of an app shares the app's limits, and so the time they reset, so a few seconds are added at
|
|
65
|
+
# random to each wait, to keep the requests one reset releases from being sent again in one burst.
|
|
66
|
+
#
|
|
67
|
+
# @api private
|
|
68
|
+
# @yield runs the request
|
|
69
|
+
# @return [Object] what the block returns
|
|
70
|
+
# @raise [TooManyRequests] if the request is refused once more than the retries allow, or for too long a wait
|
|
71
|
+
# @example Retry a request
|
|
72
|
+
# handler.handle { client.get("users/me") }
|
|
73
|
+
def handle
|
|
74
|
+
retries = 0
|
|
75
|
+
begin
|
|
76
|
+
yield
|
|
77
|
+
rescue TooManyRequests => e
|
|
78
|
+
retries = counted(retries)
|
|
79
|
+
sleep wait_before_retry(e, retries)
|
|
80
|
+
retry
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Count the retries of each attempt of a request as retries of one request
|
|
85
|
+
#
|
|
86
|
+
# A request that another handler sends again, after a server error, is rate limited anew on each attempt, so its
|
|
87
|
+
# retries are counted across the attempts, rather than from nothing on each, so that max_rate_limit_retries
|
|
88
|
+
# bounds the retries of the request. The count is the request's own: a request a callback of it sends, such as
|
|
89
|
+
# on_response or save_tokens, counts afresh, and leaves the count of the request as it was. A request that
|
|
90
|
+
# Client#with_retries sends again counts on from the retries its earlier attempts took.
|
|
91
|
+
#
|
|
92
|
+
# @api private
|
|
93
|
+
# @yield runs the attempts of the request
|
|
94
|
+
# @return [Object] what the block returns
|
|
95
|
+
# @example Count the rate limits of a request across its attempts
|
|
96
|
+
# handler.counting { retry_handler.handle(idempotent: true) { handler.handle { request } } }
|
|
97
|
+
def counting
|
|
98
|
+
previous = Thread.current[RETRIES]
|
|
99
|
+
handed = Thread.current[HANDED] #: Integer?
|
|
100
|
+
Thread.current[HANDED] = nil
|
|
101
|
+
Thread.current[RETRIES] = handed || 0
|
|
102
|
+
begin
|
|
103
|
+
yield
|
|
104
|
+
ensure
|
|
105
|
+
Thread.current[HANDED] = Thread.current.fetch(RETRIES) if handed
|
|
106
|
+
Thread.current[RETRIES] = previous
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Hand the retries of a request down across the attempts with_retries sends
|
|
111
|
+
#
|
|
112
|
+
# It is what Client#with_retries counts with, so that the request it wraps counts on from the retries its earlier
|
|
113
|
+
# attempts took.
|
|
114
|
+
#
|
|
115
|
+
# @api private
|
|
116
|
+
# @yield runs the attempts of the request
|
|
117
|
+
# @return [Object] what the block returns
|
|
118
|
+
# @example Count the rate limits of a request across the attempts with_retries sends
|
|
119
|
+
# handler.handing_down { retry_handler.handle(idempotent: true) { client.post(...) } }
|
|
120
|
+
def handing_down
|
|
121
|
+
previous = Thread.current[HANDED]
|
|
122
|
+
begin
|
|
123
|
+
Thread.current[HANDED] = 0
|
|
124
|
+
yield
|
|
125
|
+
ensure
|
|
126
|
+
Thread.current[HANDED] = previous
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# The number of a retry, counted across the attempts of a request that are counted
|
|
133
|
+
# @api private
|
|
134
|
+
# @param retries [Integer] the retries counted by the attempt alone
|
|
135
|
+
# @return [Integer] the number of the retry
|
|
136
|
+
def counted(retries)
|
|
137
|
+
shared = Thread.current[RETRIES] #: Integer?
|
|
138
|
+
return retries + 1 unless shared
|
|
139
|
+
|
|
140
|
+
Thread.current[RETRIES] = shared + 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The seconds to wait before a retry, raising the error if it may not retry
|
|
144
|
+
#
|
|
145
|
+
# The random share is added to the wait rather than taken off it, since a request sent before the limit
|
|
146
|
+
# resets is refused again, and so it is not counted against the maximum wait, which is the longest reset a
|
|
147
|
+
# request waits for.
|
|
148
|
+
#
|
|
149
|
+
# @api private
|
|
150
|
+
# @param error [TooManyRequests] the error the request raised
|
|
151
|
+
# @param retries [Integer] the number of the retry, counting from one
|
|
152
|
+
# @return [Float] the seconds the response asks the request to wait, and a random share of RESET_JITTER
|
|
153
|
+
# @raise [TooManyRequests] the error being rescued, if no retries remain, the wait is too long, or the project
|
|
154
|
+
# has reached its usage cap
|
|
155
|
+
def wait_before_retry(error, retries)
|
|
156
|
+
raise if retries > max_rate_limit_retries || error.problem&.usage_capped?
|
|
157
|
+
|
|
158
|
+
wait = error.retry_after || UNREPORTED_RESET_WAIT << (retries - 1)
|
|
159
|
+
raise if wait > max_rate_limit_wait
|
|
160
|
+
|
|
161
|
+
wait + (rand * RESET_JITTER)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
private_constant :RateLimitHandler
|
|
165
|
+
end
|
|
166
|
+
end
|