x-streams 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.
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "x/core"
4
+ require_relative "callback_error"
5
+ require_relative "stopper"
6
+ require_relative "stream_error"
7
+ require_relative "validator"
8
+
9
+ module X
10
+ module Streams
11
+ # Reconnects a stream that drops, backing off as X recommends
12
+ #
13
+ # A stream that ends, loses its connection, or cannot open one, as when the connection is refused, or that X
14
+ # disconnects with an operational-disconnect, reconnects at once, then after a delay that grows by a quarter
15
+ # second each attempt, up to 16 seconds. A server error, a 408 Request Timeout, a 409 Conflict, or a line that is
16
+ # not JSON backs off from 5 seconds, doubling each attempt, up to 320 seconds, and waits longer when the response
17
+ # asks for longer with a Retry-After header, as the retries of a client do; one that asks for longer than
18
+ # max_rate_limit_wait raises at once, as a rate limit that resets later does. A rate limit backs off from a minute,
19
+ # doubling each attempt, as X asks, and waits longer when the limit resets later; one that would wait longer than
20
+ # max_rate_limit_wait raises at once, rather than hold the stream closed for hours, as a limit on the requests of a
21
+ # day would, or keep asking for a connection X keeps refusing. Each of the three backs off on a count of its own,
22
+ # so that the dropped connections before a server error do not lengthen the wait after it.
23
+ #
24
+ # An object, or the keep-alive X sends every 20 seconds, read from a connection that has been open for a minute
25
+ # starts every count over, so that a stream that is quiet but connected is not taken for one that keeps failing,
26
+ # and one that drops after hours reconnects at once. One read from a connection younger than that starts none of
27
+ # them over, so a stream whose connections each deliver an object and drop, as those of a server that is failing
28
+ # do, backs off further with each, and is given up on after max_reconnects of them, rather than reconnected at
29
+ # once without end, which would spend the connections X allows a stream in a window.
30
+ #
31
+ # A connection whose certificate does not verify raises at once rather than reconnect, as a certificate that did
32
+ # not verify once will not the next time either, and reconnects are unlimited by default, so it would otherwise
33
+ # reconnect every 16 seconds for as long as the stream runs. Other errors of the network reconnect however long
34
+ # they last, as a host that does not resolve does, since most pass.
35
+ #
36
+ # Before each wait it passes the error that dropped the stream and the seconds it waits to on_reconnect, whose
37
+ # error stops the stream, as an error of the consumer does.
38
+ #
39
+ # Internal to x-streams: StreamingClient reconnects with it, max_reconnects and on_reconnect are set on the
40
+ # streaming client, which checked them, and max_rate_limit_wait is the client's, which the client checked when it
41
+ # was built.
42
+ #
43
+ # @api private
44
+ class ReconnectHandler
45
+ # Default maximum number of reconnects in a row, which is unlimited, since a stream is meant to run until stopped
46
+ DEFAULT_MAX_RECONNECTS = Float::INFINITY
47
+ # Seconds the wait grows by after each dropped connection
48
+ NETWORK_BACKOFF_STEP = 0.25
49
+ # Longest wait after a dropped connection, in seconds
50
+ MAX_NETWORK_BACKOFF = 16
51
+ # First wait after a server error, in seconds
52
+ HTTP_BACKOFF_START = 5
53
+ # Longest wait after a server error, in seconds
54
+ MAX_HTTP_BACKOFF = 320
55
+ # First wait after a rate limit, in seconds, for a limit that does not say when it resets
56
+ RATE_LIMIT_BACKOFF_START = 60
57
+ # Seconds a connection has been open for before what is read from it starts the counts over, three times the
58
+ # interval of the keep-alive X sends, so that a stream reconnects at once no more than once a minute
59
+ STABLE_CONNECTION = 60
60
+ # The errors a stream reconnects after, which come from the server or the connection rather than the request
61
+ RECONNECTABLE_ERRORS = [NetworkError, ServerError, RequestTimeout, Conflict, TooManyRequests, InvalidResponse].freeze
62
+ # The counts a stream starts with, and starts over at: the reconnects in a row, which max_reconnects limits, and
63
+ # the reconnects after each kind of error, which the wait before the next reconnect after that kind grows with
64
+ FIRST_STATE = {reconnects: 0, network: 0, http: 0, rate_limit: 0}.freeze
65
+ # What OpenSSL says of a certificate that does not verify, such as one that expired, is signed by an authority
66
+ # the system does not trust, or names another host
67
+ UNVERIFIED_CERTIFICATE = "certificate verify failed"
68
+
69
+ # Raised in place of an error the consumer of a stream raised, which is its cause, so that the stream stops
70
+ ConsumerError = Class.new(StandardError) #: singleton(StandardError)
71
+ private_constant :ConsumerError
72
+
73
+ # The longest a stream waits for a rate limit to reset, in seconds
74
+ # @api private
75
+ # @return [Integer, Float] the maximum wait in seconds
76
+ # @example Read the maximum wait
77
+ # handler.max_rate_limit_wait # => 900
78
+ attr_reader :max_rate_limit_wait
79
+
80
+ # The most reconnects in a row without a connection that stays open for a minute
81
+ # @api private
82
+ # @return [Integer, Float] the maximum number of reconnects, or Float::INFINITY for no limit
83
+ # @example Read the maximum reconnects
84
+ # handler.max_reconnects # => 5
85
+ attr_reader :max_reconnects
86
+
87
+ # Initialize a new reconnect handler
88
+ #
89
+ # @api private
90
+ # @param max_reconnects [Integer, Float] the maximum number of reconnects in a row, or Float::INFINITY
91
+ # @param max_rate_limit_wait [Integer, Float] the longest wait for a rate limit to reset, in seconds
92
+ # @param on_reconnect [#call, nil] the callable passed the error that dropped the stream and the seconds the
93
+ # handler waits, before each wait to reconnect, or nil for none
94
+ # @return [ReconnectHandler] a new instance
95
+ # @raise [ArgumentError] if the maximum number of reconnects is neither an Integer of at least 0 nor
96
+ # Float::INFINITY
97
+ # @example Create a reconnect handler
98
+ # handler = X::Streams::ReconnectHandler.new(max_reconnects: 5)
99
+ def initialize(max_reconnects: DEFAULT_MAX_RECONNECTS, max_rate_limit_wait: Client::DEFAULT_MAX_RATE_LIMIT_WAIT, on_reconnect: nil)
100
+ @max_reconnects = Validator.count_or_infinity!(:max_reconnects, max_reconnects)
101
+ @max_rate_limit_wait = max_rate_limit_wait
102
+ @on_reconnect = on_reconnect
103
+ end
104
+
105
+ # Run a stream, running it again whenever it drops
106
+ #
107
+ # An error raised by the consumer stops the stream, even one that would otherwise reconnect, and reaches the
108
+ # caller, as does an error raised by on_reconnect, which is raised from the rescue of the error that dropped the
109
+ # stream, an error raised by the on_response of the client, or by the class an object is parsed into, which the
110
+ # stream tags as a CallbackError, so that one a stream reconnects after, such as an X::ServerError of a request
111
+ # on_response made, is not taken for the stream's own, and any error that is not one a stream reconnects after,
112
+ # such as the StreamError of a line that holds errors other than a disconnect. The stream is run again with while
113
+ # rather than Kernel#loop, which rescues StopIteration, so that a StopIteration raised from an Enumerator that has
114
+ # run out, wherever it is raised, reaches the caller too, rather than end the stream without a word.
115
+ #
116
+ # @api private
117
+ # @param consumer [Proc] the block that receives each object
118
+ # @yield [deliver, alive] runs the stream once
119
+ # @yieldparam deliver [Proc] the block to pass each object to, which passes it on to the consumer
120
+ # @yieldparam alive [Proc] the callable to call for each keep-alive the stream reads
121
+ # @return [nil] once the stream returns rather than raise
122
+ # @raise [NetworkError, ServerError, RequestTimeout, Conflict, TooManyRequests, InvalidResponse, StreamError] if
123
+ # the stream fails with no reconnects left
124
+ # @raise [TooManyRequests] if a rate limit asks the stream to wait longer than max_rate_limit_wait, or the project
125
+ # has reached its usage cap
126
+ # @raise [ServerError, RequestTimeout, Conflict] if its Retry-After header asks the stream to wait longer than
127
+ # max_rate_limit_wait
128
+ # @raise [NetworkError] if the certificate of the connection does not verify, with reconnects left or not
129
+ # @example Reconnect a stream
130
+ # handler.handle(->(post) { puts post }) { |deliver, alive| read_stream(on_keep_alive: alive, &deliver) }
131
+ def handle(consumer, &stream)
132
+ state = FIRST_STATE.dup
133
+ while run_once(stream, consumer, state); end
134
+ rescue ConsumerError => e
135
+ raise cause_of(e)
136
+ rescue CallbackError => e
137
+ raise e.error
138
+ end
139
+
140
+ private
141
+
142
+ # Run a stream once, waiting for the next reconnect when it drops or ends
143
+ #
144
+ # A stream ends by raising, as StreamingClient raises a NetworkError for one the server ended, since X holds a
145
+ # stream open until it drops it, so each reconnect follows an error, which on_reconnect is passed. A stream that
146
+ # returns rather than raise is done, and is not run again. The reconnect is waited for in the rescue of that
147
+ # error, so an error on_reconnect raises is not rescued as one of the stream, even an X::NetworkError, which
148
+ # would otherwise be reconnected after, and reaches the caller as it was raised.
149
+ #
150
+ # Each object and keep-alive the stream reads starts the counts over once the connection has been open for
151
+ # STABLE_CONNECTION seconds, which are counted from when the stream is run, on a clock that never runs back.
152
+ #
153
+ # @api private
154
+ # @param stream [Proc] runs the stream once
155
+ # @param consumer [Proc] the block that receives each object
156
+ # @param state [Hash{Symbol => Integer}] the counts of reconnects, for one call to handle
157
+ # @return [Boolean] true to run the stream again, or false once it returned
158
+ # @raise [NetworkError, ServerError, RequestTimeout, Conflict, TooManyRequests, InvalidResponse, StreamError] if
159
+ # the stream fails with no reconnects left
160
+ def run_once(stream, consumer, state)
161
+ opened_at = now
162
+ alive = -> { state.replace(FIRST_STATE) if now - opened_at >= STABLE_CONNECTION }
163
+ stream.call(delivery_to(consumer, alive), alive)
164
+ false
165
+ rescue *RECONNECTABLE_ERRORS, StreamError => e
166
+ raise unless reconnectable?(e)
167
+ raise if out_of_reconnects?(e, state)
168
+
169
+ true
170
+ end
171
+
172
+ # Check whether a stream reconnects after an error
173
+ #
174
+ # A stream reconnects after a StreamError only when each of its problems is an operational-disconnect, after a
175
+ # TooManyRequests unless it is the usage cap of the project, which lasts until the month ends, and after a
176
+ # NetworkError unless it is a certificate that does not verify, which x-core raises with the
177
+ # OpenSSL::SSL::SSLError as its cause.
178
+ #
179
+ # @api private
180
+ # @param error [StandardError] the error that dropped the stream
181
+ # @return [Boolean] true unless the error is a StreamError of any problem but a disconnect, the usage cap, or a
182
+ # certificate that does not verify
183
+ def reconnectable?(error)
184
+ case error
185
+ when StreamError then error.problems.all?(&:disconnect?)
186
+ when TooManyRequests then !error.problem&.usage_capped?
187
+ when NetworkError then !unverified_certificate?(error.cause)
188
+ else true
189
+ end
190
+ end
191
+
192
+ # Check whether the cause of a NetworkError is a certificate that does not verify
193
+ # @api private
194
+ # @param cause [Exception, nil] the cause of the error
195
+ # @return [Boolean] true if the cause is an OpenSSL::SSL::SSLError of a certificate that does not verify
196
+ def unverified_certificate?(cause) = cause.is_a?(OpenSSL::SSL::SSLError) && cause.message.include?(UNVERIFIED_CERTIFICATE)
197
+
198
+ # The error a consumer raised, which a consumer error stands in for
199
+ # @api private
200
+ # @param error [ConsumerError] the error raised in its place
201
+ # @return [StandardError] the error the consumer raised
202
+ def cause_of(error)
203
+ error.cause #: StandardError
204
+ end
205
+
206
+ # Check whether the reconnects are spent, waiting for the next if not
207
+ # @api private
208
+ # @param error [StandardError] the error that dropped the stream
209
+ # @param state [Hash{Symbol => Integer}] the counts of reconnects, for one call to handle
210
+ # @return [Boolean] true if no reconnects remain, or false once it has waited for the next
211
+ def out_of_reconnects?(error, state)
212
+ return true if count(state, :reconnects) > max_reconnects
213
+
214
+ wait = backoff(error, state)
215
+ announce(error, wait)
216
+ Stopper.pausing(wait) { |seconds| sleep(seconds) }
217
+ false
218
+ end
219
+
220
+ # Pass on_reconnect the error that dropped the stream and the wait to reconnect
221
+ # @api private
222
+ # @param error [StandardError] the error that dropped the stream
223
+ # @param wait [Integer, Float] the seconds to wait
224
+ # @return [void]
225
+ def announce(error, wait) = @on_reconnect&.call(error, wait)
226
+
227
+ # The seconds of a clock that never runs back
228
+ #
229
+ # The clock of the system runs back when it is set, which would make a connection younger than it is.
230
+ #
231
+ # @api private
232
+ # @return [Float] the seconds
233
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
234
+
235
+ # A block that passes an object to the consumer and starts the counts over
236
+ #
237
+ # The counts start over only for a connection open long enough, which restart sees to.
238
+ #
239
+ # @api private
240
+ # @param consumer [Proc] the block that receives each object
241
+ # @param restart [Proc] the callable that starts the counts of reconnects over, for a connection open long enough
242
+ # @return [Proc] the block
243
+ def delivery_to(consumer, restart)
244
+ lambda do |object|
245
+ restart.call
246
+ consumer.call(object)
247
+ rescue
248
+ raise ConsumerError
249
+ end
250
+ end
251
+
252
+ # The wait before a reconnect, by the kind of error
253
+ #
254
+ # The wait grows with the count of reconnects after errors of the same kind, which this counts one more of.
255
+ #
256
+ # @api private
257
+ # @param error [StandardError] the error that dropped the stream
258
+ # @param state [Hash{Symbol => Integer}] the counts of reconnects, for one call to handle
259
+ # @return [Float, Integer] the seconds to wait
260
+ def backoff(error, state)
261
+ case error
262
+ when TooManyRequests then rate_limit_backoff(error, count(state, :rate_limit))
263
+ when HTTPError then http_backoff(error, count(state, :http))
264
+ else network_backoff(count(state, :network))
265
+ end
266
+ end
267
+
268
+ # Count one more reconnect
269
+ # @api private
270
+ # @param state [Hash{Symbol => Integer}] the counts of reconnects, for one call to handle
271
+ # @param name [Symbol] the name of the count
272
+ # @return [Integer] the count, counting this reconnect
273
+ def count(state, name) = state[name] = state.fetch(name) + 1
274
+
275
+ # The wait before a reconnect after a rate limit, at least until it resets
276
+ #
277
+ # X asks a stream refused for a rate limit to back off from RATE_LIMIT_BACKOFF_START, doubling each attempt with
278
+ # no cap, so a limit that resets sooner still waits that long, one that resets later waits until it does, and a
279
+ # stream X keeps refusing raises once the wait passes max_rate_limit_wait.
280
+ #
281
+ # @api private
282
+ # @param error [TooManyRequests] the error the connection raised
283
+ # @param reconnects [Integer] the number of the reconnect, counting from one
284
+ # @return [Integer] the seconds to wait
285
+ # @raise [TooManyRequests] the error, if the wait is longer than max_rate_limit_wait
286
+ def rate_limit_backoff(error, reconnects)
287
+ wait = [error.retry_after, RATE_LIMIT_BACKOFF_START << (reconnects - 1)].compact.max #: Integer
288
+ raise if wait > max_rate_limit_wait
289
+
290
+ wait
291
+ end
292
+
293
+ # The wait before a reconnect after a dropped connection
294
+ # @api private
295
+ # @param reconnects [Integer] the number of the reconnect, counting from one
296
+ # @return [Float, Integer] the seconds to wait
297
+ def network_backoff(reconnects) = [NETWORK_BACKOFF_STEP * (reconnects - 1), MAX_NETWORK_BACKOFF].min
298
+
299
+ # The wait before a reconnect after a server error, a refusal, or a bad line
300
+ #
301
+ # A response that asks for a wait with a Retry-After header, as a 503 that names the time its endpoint is
302
+ # expected back does, waits that long when it is longer than the backoff, as a request a client sends again
303
+ # does. One that asks for longer than max_rate_limit_wait raises at once, as a rate limit that resets later does,
304
+ # rather than hold the stream closed for longer than a client would wait for its requests; the backoff alone
305
+ # never raises.
306
+ #
307
+ # @api private
308
+ # @param error [HTTPError] the error the connection raised
309
+ # @param reconnects [Integer] the number of the reconnect, counting from one
310
+ # @return [Integer] the seconds to wait
311
+ # @raise [HTTPError] the error, if it asks for a wait longer than max_rate_limit_wait
312
+ def http_backoff(error, reconnects)
313
+ requested = error.retry_after
314
+ raise if requested.to_i > max_rate_limit_wait
315
+
316
+ backoff = [HTTP_BACKOFF_START << (reconnects - 1), MAX_HTTP_BACKOFF].min
317
+ [requested, backoff].compact.max #: Integer
318
+ end
319
+ end
320
+ private_constant :ReconnectHandler
321
+ end
322
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "x/core"
4
+ require_relative "error"
5
+
6
+ module X
7
+ # Raised when the API rejected some of the rules of the filtered stream, and no block was given for them
8
+ #
9
+ # The API reports the rules it does not add or delete, such as a rule that is invalid, as errors of a
10
+ # response that otherwise succeeds. StreamingClient#add_rules and StreamingClient#delete_rules yield each of them
11
+ # to a block, and, without one, raise this error rather than drop them, so that a rule that was not added, or a
12
+ # rule a dry run found invalid, is never passed over in silence. A rule the app already has is not one the API
13
+ # rejected, so add_rules raises nothing for it, and it is neither among the {#problems} nor the rules {#added}. The
14
+ # rules that were changed stay changed, so the error holds what the method would have returned, the rules add_rules
15
+ # added in {#added}, or the number of rules delete_rules deleted in {#deleted_count}, beside the {#problems} the
16
+ # API reported. The API may add none of the rules it is given alongside one it rejects, and report that one alone, so
17
+ # {#added}, and not the {#problems}, says which rules were added.
18
+ #
19
+ # @api public
20
+ # @example Report the rules that were not added
21
+ # begin
22
+ # streaming_client.add_rules(["ruby", "from:"])
23
+ # rescue X::RulesRejected => e
24
+ # warn e.problems.map(&:title).join(", ")
25
+ # added = e.added # => [], when the API added none of the rules alongside the one it rejected
26
+ # end
27
+ class RulesRejected < Streams::Error
28
+ # The problems the API reported of the rules it rejected
29
+ #
30
+ # A rule the app already had is not among them, since the API did not reject it.
31
+ #
32
+ # @api public
33
+ # @return [Array<Problem>] the problems, frozen, in the order the API reported them
34
+ # @example Read the title of each problem
35
+ # error.problems.map(&:title) # => ["UnprocessableEntity"]
36
+ attr_reader :problems
37
+
38
+ # The rules add_rules added
39
+ #
40
+ # They are what add_rules would have returned, had it been given a block, so a rule the app already had is not
41
+ # among them: StreamingClient#rules reads it.
42
+ #
43
+ # @api public
44
+ # @return [Array<StreamRule>, nil] the rules, frozen, or nil for an error delete_rules raised, or one built without
45
+ # them, such as one a test built
46
+ # @example Read the rules that were added
47
+ # error.added.map(&:value) # => [], when the API added none of the rules alongside the one it rejected
48
+ attr_reader :added
49
+
50
+ # The number of rules delete_rules deleted
51
+ #
52
+ # It is what delete_rules would have returned, had it been given a block.
53
+ #
54
+ # @api public
55
+ # @return [Integer, nil] the number, or nil for an error add_rules raised, or one built without it, such as one a
56
+ # test built
57
+ # @example Read the number of rules that were deleted
58
+ # error.deleted_count # => 1
59
+ attr_reader :deleted_count
60
+
61
+ # Initialize a new RulesRejected
62
+ #
63
+ # Public, so that code that rescues a RulesRejected can be tested with one built by hand, as StreamingClient builds
64
+ # one for the problems of a change of the rules, and raised with a message alone, as any exception is. The message
65
+ # is the one given, or else the title and detail of each problem.
66
+ #
67
+ # @api public
68
+ # @param message [String, nil] the message, or nil for the one the problems give
69
+ # @param problems [Array<Problem>] the problems the API reported of the rules it rejected
70
+ # @param added [Array<StreamRule>, nil] the rules add_rules added
71
+ # @param deleted_count [Integer, nil] the number of rules delete_rules deleted
72
+ # @return [RulesRejected] a new instance
73
+ # @example Create an error
74
+ # error = X::RulesRejected.new(problems: X::Problem.all_from(body), added: [])
75
+ # @example Raise the error with a message alone, as a test stub may
76
+ # raise X::RulesRejected, "The rules were not added"
77
+ def initialize(message = nil, problems: [], added: nil, deleted_count: nil)
78
+ @problems = problems.dup.freeze
79
+ @added = added.dup.freeze
80
+ @deleted_count = deleted_count
81
+ super(message || describe(problems))
82
+ end
83
+ end
84
+ end