posthaste-rails 0.1.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 +21 -0
- data/README.md +323 -0
- data/lib/posthaste/actionmailer.rb +68 -0
- data/lib/posthaste/delivery_method.rb +106 -0
- data/lib/posthaste/errors.rb +311 -0
- data/lib/posthaste/http_client.rb +256 -0
- data/lib/posthaste/message_mapper.rb +494 -0
- data/lib/posthaste/railtie.rb +31 -0
- data/lib/posthaste/redaction.rb +62 -0
- data/lib/posthaste/result.rb +61 -0
- data/lib/posthaste/version.rb +8 -0
- data/lib/posthaste-rails.rb +6 -0
- metadata +77 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require_relative 'redaction'
|
|
6
|
+
|
|
7
|
+
module Posthaste
|
|
8
|
+
# Every `error.type` the API emits, plus the ones this gem synthesises.
|
|
9
|
+
#
|
|
10
|
+
# Kept as data rather than as a set of classes so a caller can check whether a
|
|
11
|
+
# type is one this version knows about. An unrecognised type is NOT a bug: the
|
|
12
|
+
# API is allowed to add refusal reasons, and one that arrives here unknown
|
|
13
|
+
# becomes the class its HTTP status implies instead of being mistaken for a
|
|
14
|
+
# documented one.
|
|
15
|
+
KNOWN_ERROR_TYPES = %w[
|
|
16
|
+
unauthorized unauthenticated forbidden csrf_failed email_unverified
|
|
17
|
+
invalid_request not_found conflict address_taken
|
|
18
|
+
batch_too_large fanout_too_large unknown_template invalid_template
|
|
19
|
+
template_in_use unknown_stream reserved_slug slug_taken invalid_address
|
|
20
|
+
domain_not_found domain_not_verified suppressed
|
|
21
|
+
rate_limited daily_limit_reached monthly_limit_reached platform_paused
|
|
22
|
+
bulk_send_refused
|
|
23
|
+
attachments_too_many attachments_too_large attachment_type_blocked
|
|
24
|
+
attachment_invalid
|
|
25
|
+
schedule_too_far not_scheduled
|
|
26
|
+
content_blocked
|
|
27
|
+
domain_limit_reached domain_in_use token_required
|
|
28
|
+
cloudflare_token_invalid cloudflare_zone_not_found cloudflare_write_failed
|
|
29
|
+
suppression_protected suppression_platform suppression_hard_bounce
|
|
30
|
+
not_configured provider_error already_subscribed
|
|
31
|
+
internal
|
|
32
|
+
unknown_error connection_error timeout
|
|
33
|
+
configuration_error unmappable_message
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Why a recipient stopped receiving mail. Read off a SuppressedError.
|
|
37
|
+
SuppressionInfo = Struct.new(:address, :reason, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
# The root of everything this gem raises.
|
|
40
|
+
#
|
|
41
|
+
# Rescue `Posthaste::Error` to catch every refusal, or one of the subclasses
|
|
42
|
+
# below to catch a KIND of refusal. Branch on `#type`, never on `#message`:
|
|
43
|
+
# the type is the stable contract and the sentence is not.
|
|
44
|
+
class Error < StandardError
|
|
45
|
+
attr_reader :type, :status, :body, :request_id, :retry_after_seconds
|
|
46
|
+
|
|
47
|
+
def initialize(message, type: 'unknown_error', status: 0, body: nil, request_id: nil,
|
|
48
|
+
retry_after_seconds: nil, api_key: nil)
|
|
49
|
+
# Redacted at CONSTRUCTION, not at print time. An exception message is
|
|
50
|
+
# copied into logs, error reporters and tickets by machinery this gem
|
|
51
|
+
# never sees, so the only safe moment is before the string exists.
|
|
52
|
+
super(Redaction.redact(message, api_key))
|
|
53
|
+
@type = type
|
|
54
|
+
@status = status
|
|
55
|
+
@body = body
|
|
56
|
+
@request_id = request_id
|
|
57
|
+
@retry_after_seconds = retry_after_seconds
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The four 429s mean four different things and nothing about the status
|
|
61
|
+
# says which. These two predicates are the difference between waiting a
|
|
62
|
+
# few seconds and hammering a wall until the calendar moves.
|
|
63
|
+
def rate_limited?
|
|
64
|
+
type == 'rate_limited' || type == 'platform_paused'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quota_exhausted?
|
|
68
|
+
type == 'daily_limit_reached' || type == 'monthly_limit_reached'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Worth repeating the same request later. NOT a promise that repeating it
|
|
72
|
+
# is safe — that depends on whether the request can duplicate an effect,
|
|
73
|
+
# which is what an idempotency key settles.
|
|
74
|
+
def transient?
|
|
75
|
+
rate_limited? || status == 408 || status == 429 || status >= 500
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def error_field(name)
|
|
79
|
+
envelope = body.is_a?(Hash) ? body['error'] : nil
|
|
80
|
+
envelope.is_a?(Hash) ? envelope[name.to_s] : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inspect
|
|
84
|
+
"#<#{self.class.name} type=#{type.inspect} status=#{status} " \
|
|
85
|
+
"message=#{message.inspect}>"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Something is wrong with how the delivery method was configured — most often
|
|
90
|
+
# a missing API key. Raised before any request is attempted.
|
|
91
|
+
class ConfigurationError < Error; end
|
|
92
|
+
|
|
93
|
+
# The `Mail::Message` cannot be expressed as a Posthaste send.
|
|
94
|
+
#
|
|
95
|
+
# Raised BEFORE the request, naming the field, because the alternative — a
|
|
96
|
+
# quiet omission — is a team that migrates and does not notice their Bcc
|
|
97
|
+
# stopped arriving. Nothing is dropped in silence.
|
|
98
|
+
class MappingError < Error; end
|
|
99
|
+
|
|
100
|
+
# A header the platform owns, set by the application. Names the first-class
|
|
101
|
+
# field to use instead, because "you may not set this" without "set that" is
|
|
102
|
+
# a dead end for somebody mid-migration.
|
|
103
|
+
class UnsupportedHeaderError < MappingError; end
|
|
104
|
+
|
|
105
|
+
# The request never produced a response. `status` is 0.
|
|
106
|
+
class ConnectionError < Error; end
|
|
107
|
+
|
|
108
|
+
# The request opened and the server never answered in time.
|
|
109
|
+
class TimeoutError < ConnectionError; end
|
|
110
|
+
|
|
111
|
+
# The server answered, and the answer was a refusal.
|
|
112
|
+
class APIStatusError < Error; end
|
|
113
|
+
|
|
114
|
+
# 401 — the key is missing, malformed, revoked, or from another account.
|
|
115
|
+
class AuthenticationError < APIStatusError; end
|
|
116
|
+
|
|
117
|
+
# 403 — a real key that does not hold `emails:send`.
|
|
118
|
+
class PermissionDeniedError < APIStatusError; end
|
|
119
|
+
|
|
120
|
+
# 400 — the body is wrong. `error.fields` often says where.
|
|
121
|
+
class InvalidRequestError < APIStatusError; end
|
|
122
|
+
|
|
123
|
+
# 404 — no such id, on this account.
|
|
124
|
+
class NotFoundError < APIStatusError; end
|
|
125
|
+
|
|
126
|
+
# 409 — it already exists. Frequently the correct answer to a retry.
|
|
127
|
+
class ConflictError < APIStatusError; end
|
|
128
|
+
|
|
129
|
+
# 422 — well formed, and we will not act on it.
|
|
130
|
+
#
|
|
131
|
+
# Permanent by construction: the same bytes get the same answer, so nothing
|
|
132
|
+
# under this class is ever retried automatically.
|
|
133
|
+
class UnprocessableError < APIStatusError; end
|
|
134
|
+
|
|
135
|
+
# The recipient is on the suppression list. Read `#suppression`.
|
|
136
|
+
#
|
|
137
|
+
# Never work around this. Sending to a suppressed address is how a sending IP
|
|
138
|
+
# gets blocklisted, and the block lands on every other customer sharing it —
|
|
139
|
+
# which is why the API refuses rather than warns.
|
|
140
|
+
class SuppressedError < UnprocessableError
|
|
141
|
+
def suppression
|
|
142
|
+
address = error_field(:address)
|
|
143
|
+
reason = error_field(:reason)
|
|
144
|
+
return nil unless address.is_a?(String)
|
|
145
|
+
|
|
146
|
+
SuppressionInfo.new(address: address, reason: reason.is_a?(String) ? reason : nil)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The `from` domain has not had its DKIM record verified. Publish the record
|
|
151
|
+
# and verify it; there is no flag that skips this.
|
|
152
|
+
class DomainNotVerifiedError < UnprocessableError; end
|
|
153
|
+
|
|
154
|
+
# The pre-send lint refused the CONTENT.
|
|
155
|
+
class ContentBlockedError < UnprocessableError
|
|
156
|
+
# Which check refused it, e.g. `dangerous_link` or `empty_body`.
|
|
157
|
+
def check
|
|
158
|
+
value = error_field(:check)
|
|
159
|
+
value.is_a?(String) ? value : nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The whole lint report, warnings included, so one fix pass can address
|
|
163
|
+
# everything rather than playing whack-a-mole one refusal at a time.
|
|
164
|
+
def findings
|
|
165
|
+
value = error_field(:findings)
|
|
166
|
+
value.is_a?(Array) ? value : []
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Too many files, too many bytes, a blocked type, or unreadable content.
|
|
171
|
+
class AttachmentError < UnprocessableError; end
|
|
172
|
+
|
|
173
|
+
# `scheduledAt` is out of range, or the message already left.
|
|
174
|
+
class ScheduleError < UnprocessableError; end
|
|
175
|
+
|
|
176
|
+
# The named template is unknown, invalid, or still in use.
|
|
177
|
+
class TemplateError < UnprocessableError; end
|
|
178
|
+
|
|
179
|
+
# The named message stream does not exist on this account.
|
|
180
|
+
class StreamError < UnprocessableError; end
|
|
181
|
+
|
|
182
|
+
# A transient throttle. Honour `retry_after_seconds` and come back.
|
|
183
|
+
#
|
|
184
|
+
# Covers `rate_limited` (this key's request rate) and `platform_paused` (the
|
|
185
|
+
# platform's own daily send ceiling). Both clear on their own.
|
|
186
|
+
class RateLimited < APIStatusError; end
|
|
187
|
+
|
|
188
|
+
# The account's allowance is spent — `Retry-After` is hours or days.
|
|
189
|
+
#
|
|
190
|
+
# A SIBLING of RateLimited, never a subclass. Retrying this in process burns
|
|
191
|
+
# your own request budget on calls that are all going to be refused until the
|
|
192
|
+
# calendar moves. Queue it, or alert.
|
|
193
|
+
class QuotaExhausted < APIStatusError; end
|
|
194
|
+
|
|
195
|
+
# 5xx. Safe to retry, if the request is one that repeating cannot duplicate.
|
|
196
|
+
class ServerError < APIStatusError; end
|
|
197
|
+
|
|
198
|
+
# Mapping a refusal onto a class.
|
|
199
|
+
module ErrorMapping
|
|
200
|
+
# By TYPE first, because the type is the stable contract and the status is
|
|
201
|
+
# not specific enough. Only types that say more than their status does are
|
|
202
|
+
# listed; everything else falls through to BY_STATUS, which is the honest
|
|
203
|
+
# answer for a refusal reason this version has never seen.
|
|
204
|
+
BY_TYPE = {
|
|
205
|
+
'unauthorized' => AuthenticationError,
|
|
206
|
+
'unauthenticated' => AuthenticationError,
|
|
207
|
+
'csrf_failed' => AuthenticationError,
|
|
208
|
+
'email_unverified' => AuthenticationError,
|
|
209
|
+
'forbidden' => PermissionDeniedError,
|
|
210
|
+
'invalid_request' => InvalidRequestError,
|
|
211
|
+
'not_found' => NotFoundError,
|
|
212
|
+
'conflict' => ConflictError,
|
|
213
|
+
'address_taken' => ConflictError,
|
|
214
|
+
'suppressed' => SuppressedError,
|
|
215
|
+
'suppression_protected' => UnprocessableError,
|
|
216
|
+
'suppression_platform' => UnprocessableError,
|
|
217
|
+
'suppression_hard_bounce' => UnprocessableError,
|
|
218
|
+
'domain_not_verified' => DomainNotVerifiedError,
|
|
219
|
+
'content_blocked' => ContentBlockedError,
|
|
220
|
+
'attachments_too_many' => AttachmentError,
|
|
221
|
+
'attachments_too_large' => AttachmentError,
|
|
222
|
+
'attachment_type_blocked' => AttachmentError,
|
|
223
|
+
'attachment_invalid' => AttachmentError,
|
|
224
|
+
'schedule_too_far' => ScheduleError,
|
|
225
|
+
'not_scheduled' => ScheduleError,
|
|
226
|
+
'unknown_template' => TemplateError,
|
|
227
|
+
'invalid_template' => TemplateError,
|
|
228
|
+
'template_in_use' => TemplateError,
|
|
229
|
+
'unknown_stream' => StreamError,
|
|
230
|
+
# The four 429s. Two transient, two not.
|
|
231
|
+
'rate_limited' => RateLimited,
|
|
232
|
+
'platform_paused' => RateLimited,
|
|
233
|
+
'daily_limit_reached' => QuotaExhausted,
|
|
234
|
+
'monthly_limit_reached' => QuotaExhausted,
|
|
235
|
+
'internal' => ServerError
|
|
236
|
+
# `connection_error` and `timeout` are deliberately ABSENT. They are
|
|
237
|
+
# synthesised by the transport, which constructs the exception directly,
|
|
238
|
+
# so an entry here would only ever fire on a response that carried one of
|
|
239
|
+
# those strings as an HTTP-level refusal — and mapping a real 408 onto
|
|
240
|
+
# ConnectionError ("never reached the server") would be a lie about what
|
|
241
|
+
# happened.
|
|
242
|
+
}.freeze
|
|
243
|
+
|
|
244
|
+
BY_STATUS = {
|
|
245
|
+
400 => InvalidRequestError,
|
|
246
|
+
401 => AuthenticationError,
|
|
247
|
+
403 => PermissionDeniedError,
|
|
248
|
+
404 => NotFoundError,
|
|
249
|
+
409 => ConflictError,
|
|
250
|
+
422 => UnprocessableError,
|
|
251
|
+
# An unrecognised 429 is treated as transient rather than as spent quota.
|
|
252
|
+
# That direction is the safe one: a wrongly-retried throttle costs a few
|
|
253
|
+
# seconds, while a wrongly-abandoned one drops mail that would have gone.
|
|
254
|
+
429 => RateLimited
|
|
255
|
+
}.freeze
|
|
256
|
+
|
|
257
|
+
module_function
|
|
258
|
+
|
|
259
|
+
def class_for(error_type, status)
|
|
260
|
+
known = BY_TYPE[error_type]
|
|
261
|
+
return known if known
|
|
262
|
+
return ServerError if status >= 500
|
|
263
|
+
|
|
264
|
+
BY_STATUS.fetch(status, APIStatusError)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Build the exception for one refused response.
|
|
268
|
+
def from_response(status, raw_body, retry_after_header, request_id: nil, api_key: nil)
|
|
269
|
+
body = begin
|
|
270
|
+
parsed = raw_body.to_s.empty? ? nil : JSON.parse(raw_body)
|
|
271
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
272
|
+
rescue JSON::ParserError
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
envelope = body.is_a?(Hash) && body['error'].is_a?(Hash) ? body['error'] : {}
|
|
277
|
+
type = envelope['type'].is_a?(String) ? envelope['type'] : 'unknown_error'
|
|
278
|
+
message = envelope['message'].is_a?(String) ? envelope['message'] : "HTTP #{status}"
|
|
279
|
+
|
|
280
|
+
class_for(type, status).new(
|
|
281
|
+
message,
|
|
282
|
+
type: type,
|
|
283
|
+
status: status,
|
|
284
|
+
body: body,
|
|
285
|
+
request_id: request_id,
|
|
286
|
+
retry_after_seconds: parse_retry_after(retry_after_header, envelope),
|
|
287
|
+
api_key: api_key
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# `Retry-After` is seconds or an HTTP-date. The body may also carry it as
|
|
292
|
+
# `retryAfterSeconds`, and the body wins when both are present because it
|
|
293
|
+
# is the value the limiter actually computed.
|
|
294
|
+
def parse_retry_after(header, envelope = {})
|
|
295
|
+
from_body = envelope['retryAfterSeconds']
|
|
296
|
+
return from_body.to_f if from_body.is_a?(Numeric)
|
|
297
|
+
|
|
298
|
+
return nil if header.nil? || header.to_s.strip.empty?
|
|
299
|
+
|
|
300
|
+
text = header.to_s.strip
|
|
301
|
+
return text.to_f if /\A\d+(\.\d+)?\z/.match?(text)
|
|
302
|
+
|
|
303
|
+
begin
|
|
304
|
+
seconds = Time.httpdate(text) - Time.now
|
|
305
|
+
seconds.positive? ? seconds : 0.0
|
|
306
|
+
rescue ArgumentError
|
|
307
|
+
nil
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative 'errors'
|
|
8
|
+
require_relative 'redaction'
|
|
9
|
+
require_relative 'version'
|
|
10
|
+
|
|
11
|
+
module Posthaste
|
|
12
|
+
# The default host. Override it with `base_url:` when self-hosting.
|
|
13
|
+
DEFAULT_BASE_URL = 'https://api.posthastemail.dev'
|
|
14
|
+
|
|
15
|
+
# One HTTP response, in the shape the retry loop wants.
|
|
16
|
+
Response = Struct.new(:status, :body, :headers, keyword_init: true) do
|
|
17
|
+
# HTTP header names are not case-sensitive and the sources these come from
|
|
18
|
+
# disagree about casing: Net::HTTP preserves what the server sent, a
|
|
19
|
+
# hand-written test double does whatever its author typed.
|
|
20
|
+
def header(name)
|
|
21
|
+
wanted = name.to_s.downcase
|
|
22
|
+
pair = (headers || {}).find { |k, _| k.to_s.downcase == wanted }
|
|
23
|
+
pair && pair[1]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The seam every test stubs.
|
|
28
|
+
#
|
|
29
|
+
# A transport takes a fully-built request and returns a Response. It knows
|
|
30
|
+
# nothing about retries, authentication or error typing — all of which live in
|
|
31
|
+
# HttpClient — so a test double is four lines and still exercises the whole
|
|
32
|
+
# policy above it.
|
|
33
|
+
class NetHttpTransport
|
|
34
|
+
def initialize(open_timeout: 10, read_timeout: 30)
|
|
35
|
+
@open_timeout = open_timeout
|
|
36
|
+
@read_timeout = read_timeout
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(method, url, headers, body)
|
|
40
|
+
uri = URI.parse(url)
|
|
41
|
+
request = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
|
|
42
|
+
headers.each { |name, value| request[name] = value }
|
|
43
|
+
request.body = body if body
|
|
44
|
+
|
|
45
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
46
|
+
http.use_ssl = uri.scheme == 'https'
|
|
47
|
+
http.open_timeout = @open_timeout
|
|
48
|
+
http.read_timeout = @read_timeout
|
|
49
|
+
# Redirects are NOT followed. A 3xx would send the bearer token to
|
|
50
|
+
# whatever host the Location names, so it arrives at the caller as an
|
|
51
|
+
# unhandled answer instead.
|
|
52
|
+
response = http.request(request)
|
|
53
|
+
|
|
54
|
+
Response.new(
|
|
55
|
+
status: response.code.to_i,
|
|
56
|
+
body: response.body.to_s,
|
|
57
|
+
headers: response.each_header.to_h
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# No key here, but the timeouts are the only state and printing them is
|
|
62
|
+
# useful. Defined anyway so the class never inherits a default `#inspect`
|
|
63
|
+
# if somebody adds an ivar later.
|
|
64
|
+
def inspect
|
|
65
|
+
"#<Posthaste::NetHttpTransport open_timeout=#{@open_timeout} read_timeout=#{@read_timeout}>"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Authentication, the retry policy, and turning a refusal into an exception.
|
|
70
|
+
#
|
|
71
|
+
# The retry rules are ported from the Python and TypeScript SDKs rather than
|
|
72
|
+
# reinvented, so a Rails app and a Python worker hitting the same API behave
|
|
73
|
+
# the same way under the same throttle.
|
|
74
|
+
class HttpClient
|
|
75
|
+
DEFAULT_MAX_RETRIES = 2
|
|
76
|
+
DEFAULT_MAX_RETRY_DELAY = 60.0
|
|
77
|
+
|
|
78
|
+
# First backoff step, in seconds. Doubles per attempt, capped at 8s, with
|
|
79
|
+
# full jitter applied on top.
|
|
80
|
+
BASE_BACKOFF = 0.5
|
|
81
|
+
MAX_BACKOFF = 8.0
|
|
82
|
+
|
|
83
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, transport: nil,
|
|
84
|
+
max_retries: DEFAULT_MAX_RETRIES, max_retry_delay: DEFAULT_MAX_RETRY_DELAY,
|
|
85
|
+
open_timeout: 10, read_timeout: 30, user_agent: nil,
|
|
86
|
+
sleeper: nil, randomizer: nil)
|
|
87
|
+
@api_key = api_key.to_s
|
|
88
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, '')
|
|
89
|
+
@transport = transport || NetHttpTransport.new(open_timeout: open_timeout,
|
|
90
|
+
read_timeout: read_timeout)
|
|
91
|
+
@max_retries = max_retries
|
|
92
|
+
@max_retry_delay = max_retry_delay
|
|
93
|
+
@user_agent = user_agent || self.class.default_user_agent
|
|
94
|
+
# Injected so the retry tests do not actually wait, and so the jitter is
|
|
95
|
+
# deterministic under test without the policy itself knowing it is a test.
|
|
96
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
97
|
+
@randomizer = randomizer || -> { Kernel.rand }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.default_user_agent
|
|
101
|
+
mailer = defined?(::ActionMailer::VERSION::STRING) ? ::ActionMailer::VERSION::STRING : 'none'
|
|
102
|
+
"posthaste-rails/#{Posthaste::VERSION} ruby/#{RUBY_VERSION} actionmailer/#{mailer}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# `POST /v1/emails`.
|
|
106
|
+
#
|
|
107
|
+
# Two success statuses, and they mean different things:
|
|
108
|
+
# 202 {"status":"queued"} — accepted, a new message exists.
|
|
109
|
+
# 200 {"status":"duplicate"} — an idempotency replay. No new message was
|
|
110
|
+
# created; the id is the original's.
|
|
111
|
+
#
|
|
112
|
+
# `idempotent:` is the whole retry rule. Without an idempotency key a retry
|
|
113
|
+
# after a lost response sends the email TWICE, so a send is only repeated
|
|
114
|
+
# automatically when the caller supplied one.
|
|
115
|
+
def send_email(payload, idempotent:)
|
|
116
|
+
decode(perform('post', '/v1/emails', payload, idempotent: idempotent))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Never prints the key. See Posthaste::Redaction for why this matters.
|
|
120
|
+
def inspect
|
|
121
|
+
"#<Posthaste::HttpClient base_url=#{@base_url.inspect} " \
|
|
122
|
+
"api_key=#{Redaction.describe_key(@api_key).inspect} max_retries=#{@max_retries}>"
|
|
123
|
+
end
|
|
124
|
+
alias to_s inspect
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def decode(response)
|
|
129
|
+
return {} if response.body.to_s.empty?
|
|
130
|
+
|
|
131
|
+
parsed = JSON.parse(response.body)
|
|
132
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
133
|
+
rescue JSON::ParserError => e
|
|
134
|
+
raise ServerError.new(
|
|
135
|
+
"the API answered #{response.status} with a body that is not JSON: #{e.message}",
|
|
136
|
+
type: 'internal', status: response.status, api_key: @api_key
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def perform(method, path, payload, idempotent:)
|
|
141
|
+
url = "#{@base_url}#{path}"
|
|
142
|
+
headers = build_headers(body: !payload.nil?)
|
|
143
|
+
body = payload.nil? ? nil : JSON.generate(payload)
|
|
144
|
+
|
|
145
|
+
attempt = 0
|
|
146
|
+
loop do
|
|
147
|
+
begin
|
|
148
|
+
response = @transport.call(method, url, headers, body)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
error = transport_error(e)
|
|
151
|
+
# A connection that never produced a response is safe to repeat only
|
|
152
|
+
# under the same rule as everything else: the request has to be one
|
|
153
|
+
# that repeating cannot duplicate.
|
|
154
|
+
if idempotent && attempt < @max_retries
|
|
155
|
+
@sleeper.call(jittered_backoff(attempt))
|
|
156
|
+
attempt += 1
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
raise error
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `< 300`, not `< 400`. The transport refuses to follow redirects, so a
|
|
163
|
+
# 3xx arrives here as an unhandled answer rather than as a success —
|
|
164
|
+
# reporting it beats handing back an empty body as though it worked.
|
|
165
|
+
return response if response.status < 300
|
|
166
|
+
|
|
167
|
+
error = ErrorMapping.from_response(
|
|
168
|
+
response.status,
|
|
169
|
+
response.body,
|
|
170
|
+
response.header('retry-after'),
|
|
171
|
+
request_id: response.header('x-request-id'),
|
|
172
|
+
api_key: @api_key
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
wait = retry_delay_for(error, idempotent: idempotent, attempt: attempt)
|
|
176
|
+
raise error if wait.nil?
|
|
177
|
+
|
|
178
|
+
@sleeper.call(wait)
|
|
179
|
+
attempt += 1
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# How long to wait before repeating, or nil for "do not".
|
|
184
|
+
#
|
|
185
|
+
# The decision branches on `error.type` and NOT on the status, which is the
|
|
186
|
+
# whole point. All FOUR of these are 429 and they mean four different
|
|
187
|
+
# things:
|
|
188
|
+
#
|
|
189
|
+
# rate_limited the per-key request limiter. Transient, measured
|
|
190
|
+
# in seconds — exactly what a retry is for.
|
|
191
|
+
# platform_paused the platform-wide daily send ceiling. Also
|
|
192
|
+
# transient, and NOT a statement about this account.
|
|
193
|
+
# daily_limit_reached the account's warmup cap. `Retry-After` is the
|
|
194
|
+
# seconds until midnight UTC.
|
|
195
|
+
# monthly_limit_reached the plan allowance. `Retry-After` can be weeks.
|
|
196
|
+
#
|
|
197
|
+
# A retry loop written against the status treats the last two as a hiccup
|
|
198
|
+
# and hammers a wall it cannot get through until the calendar moves.
|
|
199
|
+
def retry_delay_for(error, idempotent:, attempt:)
|
|
200
|
+
return nil if attempt >= @max_retries
|
|
201
|
+
return nil unless idempotent
|
|
202
|
+
# Quota exhaustion. Never in process, whatever Retry-After says.
|
|
203
|
+
return nil if error.quota_exhausted?
|
|
204
|
+
return nil unless error.transient?
|
|
205
|
+
|
|
206
|
+
requested = error.retry_after_seconds
|
|
207
|
+
unless requested.nil?
|
|
208
|
+
# Honour it — unless honouring it would mean blocking for longer than a
|
|
209
|
+
# caller could reasonably want, in which case hand the error back and
|
|
210
|
+
# let them schedule.
|
|
211
|
+
return nil if requested > @max_retry_delay
|
|
212
|
+
|
|
213
|
+
return [requested, 0.0].max
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
jittered_backoff(attempt)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Exponential with FULL jitter.
|
|
220
|
+
#
|
|
221
|
+
# Without jitter every client that failed on the same upstream blip retries
|
|
222
|
+
# in the same millisecond, and the recovery attempt is itself a thundering
|
|
223
|
+
# herd against a service that has only just come back.
|
|
224
|
+
def jittered_backoff(attempt)
|
|
225
|
+
ceiling = [MAX_BACKOFF, BASE_BACKOFF * (2.0**attempt)].min
|
|
226
|
+
@randomizer.call * ceiling
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_headers(body:)
|
|
230
|
+
headers = {
|
|
231
|
+
'accept' => 'application/json',
|
|
232
|
+
'user-agent' => @user_agent
|
|
233
|
+
}
|
|
234
|
+
headers['content-type'] = 'application/json' if body
|
|
235
|
+
# Last, so nothing above can replace it.
|
|
236
|
+
headers['authorization'] = "Bearer #{@api_key}"
|
|
237
|
+
headers
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def transport_error(cause)
|
|
241
|
+
timeout = cause.is_a?(Timeout::Error) ||
|
|
242
|
+
cause.class.name.include?('Timeout') ||
|
|
243
|
+
cause.is_a?(Net::OpenTimeout) ||
|
|
244
|
+
cause.is_a?(Net::ReadTimeout)
|
|
245
|
+
klass = timeout ? Posthaste::TimeoutError : Posthaste::ConnectionError
|
|
246
|
+
# The cause's message can quote the request line, which can quote the
|
|
247
|
+
# Authorization header, so it goes through `redact` like everything else.
|
|
248
|
+
klass.new(
|
|
249
|
+
"#{cause.class}: #{cause.message}",
|
|
250
|
+
type: timeout ? 'timeout' : 'connection_error',
|
|
251
|
+
status: 0,
|
|
252
|
+
api_key: @api_key
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|