thousandmails 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/LICENSE +70 -0
- data/README.md +390 -0
- data/lib/thousandmails/client.rb +159 -0
- data/lib/thousandmails/resources/emails.rb +172 -0
- data/lib/thousandmails/resources/logs.rb +204 -0
- data/lib/thousandmails/resources/realtime.rb +112 -0
- data/lib/thousandmails/resources/stats.rb +122 -0
- data/lib/thousandmails/utils/attachments.rb +291 -0
- data/lib/thousandmails/utils/constants.rb +100 -0
- data/lib/thousandmails/utils/errors.rb +197 -0
- data/lib/thousandmails/utils/http.rb +287 -0
- data/lib/thousandmails/utils/options.rb +29 -0
- data/lib/thousandmails/utils/stream.rb +252 -0
- data/lib/thousandmails/utils/transport.rb +177 -0
- data/lib/thousandmails/utils/validate.rb +263 -0
- data/lib/thousandmails/version.rb +6 -0
- data/lib/thousandmails.rb +36 -0
- metadata +58 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module ThousandMails
|
|
7
|
+
# The transport every resource goes through: URL building, auth header,
|
|
8
|
+
# timeout, retry policy, and turning a non-2xx response into a typed error.
|
|
9
|
+
class Http
|
|
10
|
+
RETRY_BASE_MS = 500
|
|
11
|
+
RETRY_MAX_MS = 8000
|
|
12
|
+
|
|
13
|
+
# Returned by retry_after_ms when the server's hint is longer than
|
|
14
|
+
# RETRY_MAX_MS. Distinct from nil, which means the response carried no hint
|
|
15
|
+
# at all. A real delay is never negative, so -1 is unambiguous.
|
|
16
|
+
TOO_LONG_TO_WAIT = -1
|
|
17
|
+
|
|
18
|
+
# Statuses worth trying again. 429 and 503 are both refusals issued before
|
|
19
|
+
# the handler ran (the rate limiter, and the attachment admission gate), so
|
|
20
|
+
# they are safe to retry whatever the method. A 5xx is ambiguous for a send —
|
|
21
|
+
# the mail may already be on its way — so those only retry when an
|
|
22
|
+
# idempotency key makes a repeat harmless.
|
|
23
|
+
ALWAYS_RETRY = [429, 503].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :api_key, :base_url, :timeout, :max_retries, :default_headers,
|
|
26
|
+
:user_agent, :transport
|
|
27
|
+
# Rate-limit headers from the most recent response, for callers that want to
|
|
28
|
+
# pace themselves rather than wait for a 429.
|
|
29
|
+
attr_reader :last_rate_limit
|
|
30
|
+
|
|
31
|
+
def initialize(api_key: nil, base_url: nil, timeout: nil, max_retries: nil,
|
|
32
|
+
headers: nil, user_agent: nil, transport: nil)
|
|
33
|
+
key = api_key || ENV["THOUSANDMAILS_API_KEY"]
|
|
34
|
+
|
|
35
|
+
if !key.is_a?(String) || key.strip.empty?
|
|
36
|
+
raise ConfigError, "An API key is required: ThousandMails::Client.new(api_key:) " \
|
|
37
|
+
"or the THOUSANDMAILS_API_KEY environment variable"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@api_key = key.strip
|
|
41
|
+
@base_url = self.class.normalise_base_url(
|
|
42
|
+
base_url.nil? || base_url.to_s.empty? ? Constants.default_base_url : base_url
|
|
43
|
+
)
|
|
44
|
+
# Per-attempt timeout in milliseconds; 0 disables. A retried request gets a
|
|
45
|
+
# fresh window rather than sharing one budget across the whole call.
|
|
46
|
+
@timeout = timeout.nil? ? Constants::DEFAULT_TIMEOUT_MS : timeout.to_i
|
|
47
|
+
@max_retries = max_retries.nil? ? Constants::DEFAULT_MAX_RETRIES : max_retries.to_i
|
|
48
|
+
@default_headers = headers || {}
|
|
49
|
+
@user_agent = user_agent || "thousandmails-ruby/#{ThousandMails::VERSION} ruby/#{RUBY_VERSION}"
|
|
50
|
+
|
|
51
|
+
if transport && !transport.is_a?(Transports::Transport)
|
|
52
|
+
raise ConfigError, "transport must be a ThousandMails::Transports::Transport"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@transport = transport || Transports::NetHttpTransport.new
|
|
56
|
+
@last_rate_limit = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Absolute URL for a client-API path, with empty query params dropped.
|
|
60
|
+
def url(path, query = nil)
|
|
61
|
+
target = "#{base_url}#{Constants::CLIENT_PREFIX}#{path}"
|
|
62
|
+
|
|
63
|
+
pairs = (query || {}).reject { |_, value| value.nil? || value == "" }
|
|
64
|
+
return target if pairs.empty?
|
|
65
|
+
|
|
66
|
+
encoded = pairs.map do |name, value|
|
|
67
|
+
rendered = value == true ? "true" : (value == false ? "false" : value.to_s)
|
|
68
|
+
"#{URI.encode_www_form_component(name.to_s)}=#{URI.encode_www_form_component(rendered)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
"#{target}?#{encoded.join('&')}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Perform one API call, retrying per the policy above.
|
|
75
|
+
#
|
|
76
|
+
# @param form [Array(String, String)] `[body, content_type]` for multipart
|
|
77
|
+
# @param parse [Symbol] :json decodes the body, :text returns it as a string
|
|
78
|
+
# @param retry_unsafe [Boolean] allow 5xx retries without an idempotency key
|
|
79
|
+
# @param timeout [Integer] overrides the client timeout; 0 disables it.
|
|
80
|
+
# Applies per attempt, so a retried request gets a fresh window.
|
|
81
|
+
# @return [Hash] `{ data:, status:, headers: }`
|
|
82
|
+
def request(http_method, path, query: nil, json: :none, form: nil,
|
|
83
|
+
idempotency_key: nil, parse: :json, retry_unsafe: false,
|
|
84
|
+
timeout: nil, headers: nil)
|
|
85
|
+
target = url(path, query)
|
|
86
|
+
effective_timeout = timeout.nil? ? @timeout : timeout.to_i
|
|
87
|
+
retryable = http_method == "GET" || !idempotency_key.nil? || retry_unsafe
|
|
88
|
+
|
|
89
|
+
attempt = 0
|
|
90
|
+
|
|
91
|
+
loop do
|
|
92
|
+
request_headers = {
|
|
93
|
+
"Authorization" => "Bearer #{api_key}",
|
|
94
|
+
"Accept" => parse == :text ? "text/csv, text/plain, */*" : "application/json",
|
|
95
|
+
"User-Agent" => user_agent
|
|
96
|
+
}
|
|
97
|
+
request_headers.merge!(default_headers)
|
|
98
|
+
request_headers.merge!(headers) if headers
|
|
99
|
+
|
|
100
|
+
body = nil
|
|
101
|
+
if form
|
|
102
|
+
body, request_headers["Content-Type"] = form
|
|
103
|
+
elsif json != :none
|
|
104
|
+
request_headers["Content-Type"] = "application/json"
|
|
105
|
+
body = JSON.generate(json)
|
|
106
|
+
end
|
|
107
|
+
request_headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
response = transport.send_request(
|
|
111
|
+
Transports::Request.new(http_method, target, request_headers, body, effective_timeout)
|
|
112
|
+
)
|
|
113
|
+
rescue ConnectionError
|
|
114
|
+
# A connection that never produced a response may still have delivered
|
|
115
|
+
# the request, so a send is only replayed when a key makes that safe.
|
|
116
|
+
# TimeoutError subclasses ConnectionError, so a request that timed out
|
|
117
|
+
# is covered by the same rule.
|
|
118
|
+
raise unless retryable && attempt < max_retries
|
|
119
|
+
|
|
120
|
+
sleep(self.class.backoff(attempt) / 1000.0)
|
|
121
|
+
attempt += 1
|
|
122
|
+
next
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@last_rate_limit = self.class.read_rate_limit(response.headers)
|
|
126
|
+
|
|
127
|
+
if response.ok?
|
|
128
|
+
return {
|
|
129
|
+
data: self.class.read_body(response.body, parse),
|
|
130
|
+
status: response.status,
|
|
131
|
+
headers: response.headers
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
should_retry = attempt < max_retries &&
|
|
136
|
+
(ALWAYS_RETRY.include?(response.status) ||
|
|
137
|
+
(response.status >= 500 && retryable))
|
|
138
|
+
|
|
139
|
+
# A Retry-After longer than we are willing to wait means the window will
|
|
140
|
+
# still be shut when the retry lands, so the attempts would be spent for
|
|
141
|
+
# nothing. Give up now instead and let the caller pace itself off
|
|
142
|
+
# `error.retry_after`, which carries the server's real figure.
|
|
143
|
+
hinted = should_retry ? self.class.retry_after_ms(response.headers) : nil
|
|
144
|
+
|
|
145
|
+
if should_retry && hinted != TOO_LONG_TO_WAIT
|
|
146
|
+
sleep((hinted || self.class.backoff(attempt)) / 1000.0)
|
|
147
|
+
attempt += 1
|
|
148
|
+
next
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
raise Errors.from_response(
|
|
152
|
+
response.status,
|
|
153
|
+
self.class.read_body(response.body, :json),
|
|
154
|
+
response.headers,
|
|
155
|
+
http_method,
|
|
156
|
+
target
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `request`, for the endpoints that always answer with a JSON object.
|
|
162
|
+
#
|
|
163
|
+
# A 2xx whose body is empty or isn't JSON means something between here and
|
|
164
|
+
# the API rewrote the response — a proxy error page, a truncated reply. The
|
|
165
|
+
# resource methods are documented to return hashes, so without this the
|
|
166
|
+
# caller would get a NoMethodError from deep inside the SDK instead of
|
|
167
|
+
# something that names the problem.
|
|
168
|
+
def request_object(http_method, path, **options)
|
|
169
|
+
response = request(http_method, path, **options)
|
|
170
|
+
data = response[:data]
|
|
171
|
+
return data if data.is_a?(Hash)
|
|
172
|
+
|
|
173
|
+
shown = data.nil? ? "an empty body" : "a non-JSON body"
|
|
174
|
+
raise ServerError.new(
|
|
175
|
+
"The API returned #{shown} with status #{response[:status]}, " \
|
|
176
|
+
"where a JSON object was expected",
|
|
177
|
+
status: response[:status],
|
|
178
|
+
body: data,
|
|
179
|
+
headers: response[:headers],
|
|
180
|
+
http_method: http_method,
|
|
181
|
+
url: url(path, options[:query])
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Streaming GET — used by the SSE endpoint, which must not time out.
|
|
186
|
+
# Each chunk is handed to the block; returning false from it closes the
|
|
187
|
+
# connection.
|
|
188
|
+
def open(path, query: nil, headers: nil, &on_chunk)
|
|
189
|
+
target = url(path, query)
|
|
190
|
+
|
|
191
|
+
request_headers = {
|
|
192
|
+
"Authorization" => "Bearer #{api_key}",
|
|
193
|
+
"Accept" => "text/event-stream",
|
|
194
|
+
"Cache-Control" => "no-cache",
|
|
195
|
+
"User-Agent" => user_agent
|
|
196
|
+
}
|
|
197
|
+
request_headers.merge!(default_headers)
|
|
198
|
+
request_headers.merge!(headers) if headers
|
|
199
|
+
|
|
200
|
+
response = transport.stream(
|
|
201
|
+
Transports::Request.new("GET", target, request_headers, nil, 0), &on_chunk
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return if response.nil? || response.ok?
|
|
205
|
+
|
|
206
|
+
raise Errors.from_response(
|
|
207
|
+
response.status,
|
|
208
|
+
self.class.read_body(response.body, :json),
|
|
209
|
+
response.headers,
|
|
210
|
+
"GET",
|
|
211
|
+
target
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class << self
|
|
216
|
+
# Percent-encode one path segment, RFC 3986 style.
|
|
217
|
+
#
|
|
218
|
+
# Not URI.encode_www_form_component, which spells a space "+" — correct in
|
|
219
|
+
# a query string, wrong in a path, where the server would read it back as a
|
|
220
|
+
# literal plus. An SMTP message id (`<uuid@domain>`) goes through here.
|
|
221
|
+
def encode_path_segment(value)
|
|
222
|
+
value.to_s.b.gsub(/[^a-zA-Z0-9\-._~]/) { |char| format("%%%02X", char.ord) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Exponential with full jitter, so a fleet retrying together spreads out.
|
|
226
|
+
# @return [Integer] milliseconds to wait
|
|
227
|
+
def backoff(attempt)
|
|
228
|
+
ceiling = [RETRY_BASE_MS * (2**attempt), RETRY_MAX_MS].min
|
|
229
|
+
(ceiling * (0.5 + (rand * 0.5))).round
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A base_url that isn't an absolute URL is a configuration mistake, and it
|
|
233
|
+
# has to be reported as one here. Left to the first request it would
|
|
234
|
+
# surface as a URI error from inside the SDK, on a call that looks like it
|
|
235
|
+
# failed for some other reason entirely.
|
|
236
|
+
def normalise_base_url(base_url)
|
|
237
|
+
trimmed = base_url.to_s.strip.sub(%r{/+\z}, "")
|
|
238
|
+
|
|
239
|
+
parsed = begin
|
|
240
|
+
URI.parse(trimmed)
|
|
241
|
+
rescue URI::InvalidURIError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
unless parsed && %w[http https].include?(parsed.scheme) && parsed.host
|
|
246
|
+
raise ConfigError, "base_url is not a usable absolute URL: \"#{base_url}\""
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
trimmed
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def read_body(raw, parse)
|
|
253
|
+
text = raw.to_s
|
|
254
|
+
return text if parse == :text
|
|
255
|
+
return nil if text.empty?
|
|
256
|
+
|
|
257
|
+
begin
|
|
258
|
+
JSON.parse(text)
|
|
259
|
+
rescue JSON::ParserError
|
|
260
|
+
text
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def read_rate_limit(headers)
|
|
265
|
+
limit = headers["ratelimit-limit"]
|
|
266
|
+
return nil if limit.nil?
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
"limit" => limit.to_i,
|
|
270
|
+
"remaining" => headers["ratelimit-remaining"].to_i,
|
|
271
|
+
"reset" => headers["ratelimit-reset"].to_i
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# How long the server asked us to wait, in ms — or TOO_LONG_TO_WAIT when
|
|
276
|
+
# that exceeds the retry ceiling, or nil when it said nothing.
|
|
277
|
+
def retry_after_ms(headers)
|
|
278
|
+
seconds = Errors.parse_retry_after(headers)
|
|
279
|
+
return nil if seconds.nil?
|
|
280
|
+
|
|
281
|
+
# A date already in the past, or a negative delay, means "now".
|
|
282
|
+
ms = [0.0, seconds].max * 1000
|
|
283
|
+
ms > RETRY_MAX_MS ? TOO_LONG_TO_WAIT : ms.to_i
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ThousandMails
|
|
4
|
+
# Reconciles the two ways a Ruby caller naturally passes a payload.
|
|
5
|
+
#
|
|
6
|
+
# Ruby folds bare keyword arguments into a positional Hash only for methods
|
|
7
|
+
# that take no keywords of their own. Every method here takes options
|
|
8
|
+
# (`timeout:`, `idempotency_key:`), so the natural call
|
|
9
|
+
#
|
|
10
|
+
# client.send_mail(senderemail: "...", to: "...", subject: "...")
|
|
11
|
+
#
|
|
12
|
+
# would otherwise drop the whole message into the options bucket and leave the
|
|
13
|
+
# payload empty. Folding the leftover keywords back into the payload puts them
|
|
14
|
+
# where the caller plainly meant them, while an explicit hash still works when
|
|
15
|
+
# options travel alongside:
|
|
16
|
+
#
|
|
17
|
+
# client.send_mail({ senderemail: "...", to: "..." }, timeout: 5_000)
|
|
18
|
+
module Options
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def merge(payload, extra)
|
|
22
|
+
base = payload.nil? ? {} : payload
|
|
23
|
+
return base if extra.nil? || extra.empty?
|
|
24
|
+
return extra unless base.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
base.merge(extra)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ThousandMails
|
|
6
|
+
# Server-Sent Events client for GET /client/realtime/stream.
|
|
7
|
+
#
|
|
8
|
+
# The endpoint emits a `ready` frame on connect, an `email` frame per recorded
|
|
9
|
+
# event, and a `: ping` comment every 25s to keep proxies from closing an idle
|
|
10
|
+
# connection. Anything that isn't one of those is surfaced as a raw frame
|
|
11
|
+
# rather than dropped, so a new server-side event type doesn't need an SDK
|
|
12
|
+
# release.
|
|
13
|
+
#
|
|
14
|
+
# Usage — either style:
|
|
15
|
+
#
|
|
16
|
+
# stream = thousandmails.realtime.stream
|
|
17
|
+
# stream.on("email") { |event| puts event["type"] }
|
|
18
|
+
# stream.listen
|
|
19
|
+
#
|
|
20
|
+
# thousandmails.realtime.stream.events.each { |event| ... }
|
|
21
|
+
#
|
|
22
|
+
# The Node SDK exposes these as an EventEmitter and an async iterable. Ruby has
|
|
23
|
+
# no event loop in play here, so `listen` blocks and dispatches to handlers
|
|
24
|
+
# while `events` returns an Enumerator that yields one event at a time. Neither
|
|
25
|
+
# queues anything: an event is handed to exactly one consumer and then dropped,
|
|
26
|
+
# so a stream held open for days does not grow.
|
|
27
|
+
class EventStream
|
|
28
|
+
# Set when the loop stopped because of a failure it could not recover from.
|
|
29
|
+
attr_reader :error
|
|
30
|
+
|
|
31
|
+
def initialize(http, reconnect: true, max_reconnects: nil,
|
|
32
|
+
on_event: nil, on_ready: nil, on_error: nil)
|
|
33
|
+
@http = http
|
|
34
|
+
@reconnect = reconnect != false
|
|
35
|
+
@max_reconnects = max_reconnects || Float::INFINITY
|
|
36
|
+
|
|
37
|
+
@listeners = Hash.new { |hash, key| hash[key] = [] }
|
|
38
|
+
@closed = false
|
|
39
|
+
@connected = false
|
|
40
|
+
@ended = false
|
|
41
|
+
@error = nil
|
|
42
|
+
# The last `id:` the server labelled a frame with, replayed as
|
|
43
|
+
# Last-Event-ID on reconnect. See connect_and_read.
|
|
44
|
+
@last_event_id = nil
|
|
45
|
+
|
|
46
|
+
on("email", &on_event) if on_event
|
|
47
|
+
on("ready", &on_ready) if on_ready
|
|
48
|
+
on("error", &on_error) if on_error
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Register a handler. Event names: ready, email, heartbeat, frame, open,
|
|
52
|
+
# close, error, end.
|
|
53
|
+
#
|
|
54
|
+
# Unlike the Node SDK, a stream with no `error` handler is not itself an
|
|
55
|
+
# error — an unhandled failure is reported through `end` and, for `events`,
|
|
56
|
+
# raised at the consumer.
|
|
57
|
+
def on(event, &listener)
|
|
58
|
+
@listeners[event.to_s] << listener
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def connected?
|
|
63
|
+
@connected
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def closed?
|
|
67
|
+
@closed
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Stop listening and close the underlying connection. Safe to call from a
|
|
71
|
+
# handler, from another thread, or twice.
|
|
72
|
+
#
|
|
73
|
+
# From a handler, or by breaking out of `events`, this takes effect at once.
|
|
74
|
+
# From another thread it takes effect when the next frame arrives, because
|
|
75
|
+
# the reader is parked on a blocking socket read until then — at worst one
|
|
76
|
+
# heartbeat interval, which the server sends every 25 seconds. `closed?` is
|
|
77
|
+
# true immediately either way; it is only the socket that lingers.
|
|
78
|
+
def close
|
|
79
|
+
return if @closed
|
|
80
|
+
|
|
81
|
+
@closed = true
|
|
82
|
+
finish
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Run the stream, dispatching to the handlers registered with `on`.
|
|
87
|
+
#
|
|
88
|
+
# Blocks until the connection ends and reconnection is exhausted or
|
|
89
|
+
# disabled, or until a handler calls `close`.
|
|
90
|
+
def listen
|
|
91
|
+
frames { |name, payload| emit(name, payload) }
|
|
92
|
+
nil
|
|
93
|
+
ensure
|
|
94
|
+
finish
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The same loop, as an Enumerator of `email` events.
|
|
98
|
+
#
|
|
99
|
+
# Handlers registered with `on` still fire, so a caller can iterate the email
|
|
100
|
+
# events while watching `error` or `heartbeat` on the side. Breaking out of
|
|
101
|
+
# the loop closes the connection.
|
|
102
|
+
def events
|
|
103
|
+
Enumerator.new do |yielder|
|
|
104
|
+
begin
|
|
105
|
+
frames do |name, payload|
|
|
106
|
+
emit(name, payload)
|
|
107
|
+
yielder << payload if name == "email" && !@closed
|
|
108
|
+
end
|
|
109
|
+
ensure
|
|
110
|
+
# A `break` in the caller's loop lands here, which would otherwise
|
|
111
|
+
# leave the socket open with nobody reading it.
|
|
112
|
+
close
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
raise @error if @error
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# The reconnect loop, yielding `[name, payload]` for every frame. Both entry
|
|
122
|
+
# points drive this; they differ only in what they do with a frame once it
|
|
123
|
+
# arrives.
|
|
124
|
+
def frames(&dispatch)
|
|
125
|
+
failures = 0
|
|
126
|
+
|
|
127
|
+
until @closed
|
|
128
|
+
dropped = false
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
connect_and_read(&dispatch)
|
|
132
|
+
rescue ThousandMails::Error => e
|
|
133
|
+
break if @closed
|
|
134
|
+
|
|
135
|
+
dropped = true
|
|
136
|
+
failures += 1
|
|
137
|
+
dispatch.call("error", e)
|
|
138
|
+
|
|
139
|
+
# An auth failure will never resolve itself, so retrying it only burns
|
|
140
|
+
# the rate-limit budget.
|
|
141
|
+
status = e.respond_to?(:status) ? e.status : nil
|
|
142
|
+
fatal = status == 401 || status == 403 || !@reconnect
|
|
143
|
+
|
|
144
|
+
if fatal || failures >= @max_reconnects
|
|
145
|
+
@error = e
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
dispatch.call("close", nil) unless dropped
|
|
151
|
+
break if @closed || !@reconnect
|
|
152
|
+
|
|
153
|
+
sleep(Http.backoff([failures, 4].min) / 1000.0)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Open the connection and dispatch complete frames. SSE frames are separated
|
|
158
|
+
# by a blank line; a chunk boundary can land anywhere, so partial frames stay
|
|
159
|
+
# in the buffer until their terminator arrives.
|
|
160
|
+
def connect_and_read(&dispatch)
|
|
161
|
+
# Where to resume from, per the SSE spec. The server does not label frames
|
|
162
|
+
# with `id:` today, so this stays unset and a reconnect picks up from the
|
|
163
|
+
# live edge — events that occurred during the gap are missed. Reconcile
|
|
164
|
+
# with client.logs when that matters. The moment the server starts
|
|
165
|
+
# labelling frames, this resumes without an SDK release.
|
|
166
|
+
headers = @last_event_id ? { "Last-Event-ID" => @last_event_id } : nil
|
|
167
|
+
|
|
168
|
+
pending = +""
|
|
169
|
+
opened = false
|
|
170
|
+
|
|
171
|
+
@http.open("/realtime/stream", headers: headers) do |chunk|
|
|
172
|
+
unless opened
|
|
173
|
+
opened = true
|
|
174
|
+
@connected = true
|
|
175
|
+
dispatch.call("open", nil)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Normalised on the whole buffer, not per chunk, so a CRLF split across a
|
|
179
|
+
# chunk boundary still reads as one line ending.
|
|
180
|
+
pending << chunk.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
181
|
+
pending = pending.gsub("\r\n", "\n")
|
|
182
|
+
|
|
183
|
+
while (split = pending.index("\n\n"))
|
|
184
|
+
frame = pending[0, split]
|
|
185
|
+
pending = pending[(split + 2)..]
|
|
186
|
+
|
|
187
|
+
parsed, event_id = self.class.parse_frame(frame)
|
|
188
|
+
@last_event_id = event_id if event_id
|
|
189
|
+
parsed.each { |name, payload| dispatch.call(name, payload) }
|
|
190
|
+
|
|
191
|
+
break if @closed
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
!@closed
|
|
195
|
+
end
|
|
196
|
+
ensure
|
|
197
|
+
@connected = false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def emit(event, payload)
|
|
201
|
+
@listeners[event].each { |listener| listener.call(payload) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Called from both close and the end of a run, so it has to be idempotent —
|
|
205
|
+
# a consumer must not see two `end` events for one stream.
|
|
206
|
+
def finish
|
|
207
|
+
@closed = true
|
|
208
|
+
return if @ended
|
|
209
|
+
|
|
210
|
+
@ended = true
|
|
211
|
+
emit("end", @error)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# One SSE frame, as the events it should be dispatched under.
|
|
215
|
+
#
|
|
216
|
+
# A named frame produces two: a generic `frame` carrying the envelope, then
|
|
217
|
+
# the frame's own name. A comment frame produces only `heartbeat`.
|
|
218
|
+
#
|
|
219
|
+
# @return [Array] `[frames, event_id]`
|
|
220
|
+
def self.parse_frame(frame)
|
|
221
|
+
# A comment frame (": ping") is the heartbeat — proof of life, nothing more.
|
|
222
|
+
return [[["heartbeat", nil]], nil] if frame.strip.empty? || frame.start_with?(":")
|
|
223
|
+
|
|
224
|
+
name = "message"
|
|
225
|
+
data = []
|
|
226
|
+
event_id = nil
|
|
227
|
+
|
|
228
|
+
frame.split("\n").each do |line|
|
|
229
|
+
if line.start_with?("event:")
|
|
230
|
+
name = line[6..].strip
|
|
231
|
+
elsif line.start_with?("data:")
|
|
232
|
+
data << line[5..].sub(/\A /, "")
|
|
233
|
+
elsif line.start_with?("id:")
|
|
234
|
+
# The spec says a field value containing NUL is ignored rather than
|
|
235
|
+
# stored, and that the id survives the frame it arrived on.
|
|
236
|
+
candidate = line[3..].strip
|
|
237
|
+
event_id = candidate unless candidate.empty? || candidate.include?("\0")
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
raw = data.join("\n")
|
|
242
|
+
payload = begin
|
|
243
|
+
JSON.parse(raw)
|
|
244
|
+
rescue JSON::ParserError
|
|
245
|
+
# Leave it as text — a frame the SDK doesn't model is still delivered.
|
|
246
|
+
raw
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
[[["frame", { "event" => name, "data" => payload }], [name, payload]], event_id]
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|