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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +8 -0
  3. data/CHANGELOG.md +353 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +54 -0
  6. data/lib/x/core/app_only_authenticator.rb +198 -0
  7. data/lib/x/core/authenticator.rb +74 -0
  8. data/lib/x/core/authenticator_request.rb +56 -0
  9. data/lib/x/core/bearer_token_authenticator.rb +46 -0
  10. data/lib/x/core/built_response.rb +100 -0
  11. data/lib/x/core/client.rb +728 -0
  12. data/lib/x/core/client_app_only.rb +115 -0
  13. data/lib/x/core/client_credentials.rb +193 -0
  14. data/lib/x/core/client_internals.rb +315 -0
  15. data/lib/x/core/client_memo.rb +58 -0
  16. data/lib/x/core/client_settings.rb +166 -0
  17. data/lib/x/core/client_token_refresh.rb +281 -0
  18. data/lib/x/core/connection.rb +328 -0
  19. data/lib/x/core/connection_pool.rb +123 -0
  20. data/lib/x/core/connection_proxy.rb +151 -0
  21. data/lib/x/core/connection_request.rb +90 -0
  22. data/lib/x/core/credential_holder.rb +78 -0
  23. data/lib/x/core/credential_validator.rb +286 -0
  24. data/lib/x/core/errors/authorization_denied.rb +55 -0
  25. data/lib/x/core/errors/authorization_error.rb +41 -0
  26. data/lib/x/core/errors/bad_gateway.rb +9 -0
  27. data/lib/x/core/errors/bad_request.rb +10 -0
  28. data/lib/x/core/errors/callback_error.rb +88 -0
  29. data/lib/x/core/errors/client_error.rb +17 -0
  30. data/lib/x/core/errors/conflict.rb +9 -0
  31. data/lib/x/core/errors/error.rb +66 -0
  32. data/lib/x/core/errors/forbidden.rb +10 -0
  33. data/lib/x/core/errors/gateway_timeout.rb +9 -0
  34. data/lib/x/core/errors/gone.rb +9 -0
  35. data/lib/x/core/errors/http_error.rb +333 -0
  36. data/lib/x/core/errors/internal_server_error.rb +9 -0
  37. data/lib/x/core/errors/invalid_response.rb +96 -0
  38. data/lib/x/core/errors/method_not_allowed.rb +10 -0
  39. data/lib/x/core/errors/network_error.rb +59 -0
  40. data/lib/x/core/errors/not_acceptable.rb +10 -0
  41. data/lib/x/core/errors/not_found.rb +15 -0
  42. data/lib/x/core/errors/payload_too_large.rb +10 -0
  43. data/lib/x/core/errors/payment_required.rb +10 -0
  44. data/lib/x/core/errors/request_timeout.rb +9 -0
  45. data/lib/x/core/errors/server_error.rb +15 -0
  46. data/lib/x/core/errors/service_unavailable.rb +9 -0
  47. data/lib/x/core/errors/token_report_failed.rb +59 -0
  48. data/lib/x/core/errors/too_many_redirects.rb +53 -0
  49. data/lib/x/core/errors/too_many_requests.rb +80 -0
  50. data/lib/x/core/errors/unauthorized.rb +15 -0
  51. data/lib/x/core/errors/unavailable_for_legal_reasons.rb +10 -0
  52. data/lib/x/core/errors/unprocessable_entity.rb +10 -0
  53. data/lib/x/core/errors/unsupported_format.rb +22 -0
  54. data/lib/x/core/errors/unsupported_media_type.rb +10 -0
  55. data/lib/x/core/errors/unsupported_operation.rb +18 -0
  56. data/lib/x/core/oauth1_authenticator.rb +142 -0
  57. data/lib/x/core/oauth2_authenticator.rb +296 -0
  58. data/lib/x/core/oauth2_authorization.rb +405 -0
  59. data/lib/x/core/oauth2_refresh.rb +272 -0
  60. data/lib/x/core/oauth2_tokens.rb +277 -0
  61. data/lib/x/core/origin.rb +95 -0
  62. data/lib/x/core/problem.rb +302 -0
  63. data/lib/x/core/proxy_setting.rb +22 -0
  64. data/lib/x/core/rate_limit.rb +137 -0
  65. data/lib/x/core/rate_limit_handler.rb +166 -0
  66. data/lib/x/core/redirect_handler.rb +208 -0
  67. data/lib/x/core/refresh_reporter.rb +92 -0
  68. data/lib/x/core/request_builder.rb +155 -0
  69. data/lib/x/core/request_context.rb +85 -0
  70. data/lib/x/core/request_encoding.rb +135 -0
  71. data/lib/x/core/response.rb +196 -0
  72. data/lib/x/core/response_headers.rb +31 -0
  73. data/lib/x/core/response_parser.rb +155 -0
  74. data/lib/x/core/retry_handler.rb +149 -0
  75. data/lib/x/core/setting_validator.rb +399 -0
  76. data/lib/x/core/stream_body.rb +77 -0
  77. data/lib/x/core/stream_response.rb +267 -0
  78. data/lib/x/core/token_endpoint.rb +238 -0
  79. data/lib/x/core/version.rb +25 -0
  80. data/lib/x/core.rb +6 -0
  81. data/sig/manifest.yaml +8 -0
  82. data/sig/x-core.rbs +396 -0
  83. 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