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.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +99 -0
- data/LICENSE.txt +21 -0
- data/README.md +61 -0
- data/lib/x/streams/api.rb +41 -0
- data/lib/x/streams/callback_error.rb +53 -0
- data/lib/x/streams/error.rb +34 -0
- data/lib/x/streams/reconnect_handler.rb +322 -0
- data/lib/x/streams/rules_rejected.rb +84 -0
- data/lib/x/streams/stopper.rb +366 -0
- data/lib/x/streams/stream_error.rb +96 -0
- data/lib/x/streams/stream_parser.rb +182 -0
- data/lib/x/streams/stream_rule.rb +182 -0
- data/lib/x/streams/stream_rules.rb +153 -0
- data/lib/x/streams/streaming_client.rb +544 -0
- data/lib/x/streams/validator.rb +119 -0
- data/lib/x/streams/version.rb +24 -0
- data/lib/x/streams.rb +9 -0
- data/sig/manifest.yaml +6 -0
- data/sig/x-streams.rbs +87 -0
- metadata +88 -0
|
@@ -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
|