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,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