anypost 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 65b858025a9f3fda8fb8a21e6c704503e49eaad8f0fcac5913c4a7808524acb5
4
+ data.tar.gz: 82615f81815ca2da06ae99b3e3a07c8e76eef059ec229c21a1324d5326f75030
5
+ SHA512:
6
+ metadata.gz: dd83c2564df58e7dcc5c1819833578cd60040e1d25f0b0a2a8542c06968870b3b6615287dacddf95a7e058337a823e66f4394662a4fd26ba3ef20faf90219c7d
7
+ data.tar.gz: ce4d157c91e82055a0bd191f6ce3cd6bb8505bd8be8dac779f67e3b592f96855b79c9caf82c8250a93eab85b278e862d36eea2b2d23aca98287d83837f4f2f58
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 unMTA LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,352 @@
1
+ # Anypost Ruby SDK
2
+
3
+ The official Ruby gem for the [Anypost](https://anypost.com) email API.
4
+
5
+ Requires Ruby 3.2+. Built on [Faraday](https://github.com/lostisland/faraday).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ gem install anypost
11
+ ```
12
+
13
+ Or add it to your Gemfile:
14
+
15
+ ```ruby
16
+ gem "anypost"
17
+ ```
18
+
19
+ ## Quickstart
20
+
21
+ ```ruby
22
+ require "anypost"
23
+
24
+ client = Anypost::Client.new("ap_your_api_key")
25
+
26
+ email = client.email.send(
27
+ from: "Acme <you@yourdomain.com>",
28
+ to: ["someone@example.com"],
29
+ subject: "Hello from Anypost",
30
+ html: "<p>It worked.</p>"
31
+ )
32
+
33
+ puts email.id
34
+ ```
35
+
36
+ The constructor also reads `ANYPOST_API_KEY` from the environment:
37
+
38
+ ```ruby
39
+ client = Anypost::Client.new
40
+ ```
41
+
42
+ Keep the key server-side. It is a bearer credential; never ship it to a browser or mobile app.
43
+
44
+ Request bodies are plain hashes with symbol keys that match the API one-to-one. Responses come back as `Anypost::Response` objects: read fields with either method or bracket syntax (`email.id` or `email[:id]`), and nested objects are themselves responses. Call `email.to_h` for the raw decoded hash.
45
+
46
+ ## Sending
47
+
48
+ One of `text`, `html`, or `template_id` is required. All recipients in `to`, `cc`, and `bcc` share one envelope and count against a combined limit of 50.
49
+
50
+ ```ruby
51
+ client.email.send(
52
+ from: "Acme <you@yourdomain.com>",
53
+ to: ["a@example.com", "b@example.com"],
54
+ cc: ["team@example.com"],
55
+ reply_to: "support@yourdomain.com",
56
+ subject: "Receipt #4823",
57
+ html: "<p>Thanks for your order.</p>",
58
+ text: "Thanks for your order.",
59
+ tags: ["receipt"]
60
+ )
61
+ ```
62
+
63
+ Attachment `content` is the raw file bytes — pass what `File.binread` returns and the SDK base64-encodes it. Do not pre-encode it. The request body is capped at 5 MB.
64
+
65
+ ```ruby
66
+ client.email.send(
67
+ from: "you@yourdomain.com",
68
+ to: ["someone@example.com"],
69
+ subject: "Your report",
70
+ text: "Attached.",
71
+ attachments: [
72
+ {filename: "report.pdf", content: File.binread("report.pdf")}
73
+ ]
74
+ )
75
+ ```
76
+
77
+ Send with a published template and per-recipient variables:
78
+
79
+ ```ruby
80
+ client.email.send(
81
+ from: "you@yourdomain.com",
82
+ to: ["someone@example.com"],
83
+ template_id: "template_018f2c5e-3a40-7a91-9c25-3a0b1d5e6f78",
84
+ variables: {name: "Ada", plan: "pro"}
85
+ )
86
+ ```
87
+
88
+ ## Batch
89
+
90
+ Send 1 to 100 independent messages in one request. `defaults` fills any field an entry omits.
91
+
92
+ ```ruby
93
+ result = client.email.send_batch(
94
+ defaults: {from: "you@yourdomain.com"},
95
+ emails: [
96
+ {to: ["a@example.com"], subject: "Hi A", text: "..."},
97
+ {to: ["b@example.com"], subject: "Hi B", text: "..."}
98
+ ]
99
+ )
100
+ ```
101
+
102
+ A batch with mixed outcomes returns HTTP `207` and resolves normally. Inspect each entry rather than rescuing an error:
103
+
104
+ ```ruby
105
+ result.summary # { total:, queued:, failed: }
106
+
107
+ result.data.each do |entry|
108
+ if entry.status == "queued"
109
+ puts "#{entry.index} #{entry.id}"
110
+ else
111
+ puts "#{entry.index} #{entry.error.type} #{entry.error.message}"
112
+ end
113
+ end
114
+ ```
115
+
116
+ ## Domains
117
+
118
+ Manage sending domains under `client.domains`. Add a domain, publish the CNAMEs it returns, then verify.
119
+
120
+ ```ruby
121
+ domain = client.domains.create(name: "example.com")
122
+
123
+ domain.dns_records.each do |record|
124
+ puts "#{record.type} #{record.name} -> #{record.value}"
125
+ end
126
+ ```
127
+
128
+ `verify` always returns the current domain — a still-`pending` domain does not raise. Read `status` and `verification_failure`, and poll while DNS propagates.
129
+
130
+ ```ruby
131
+ checked = client.domains.verify(domain.id)
132
+ puts checked.verification_failure.code unless checked.status == "verified"
133
+ ```
134
+
135
+ `get`, `update` (tracking config only), and `delete` round out the resource:
136
+
137
+ ```ruby
138
+ client.domains.update(domain.id, tracking: {opens_enabled: true, clicks_enabled: true, subdomain: "track"})
139
+ client.domains.delete(domain.id)
140
+ ```
141
+
142
+ ## API keys
143
+
144
+ Manage keys under `client.api_keys`. The plaintext secret comes back only once, on `create`, as `key`:
145
+
146
+ ```ruby
147
+ created = client.api_keys.create(
148
+ name: "Production server",
149
+ permissions: "send_only",
150
+ allowed_domains: ["example.com"]
151
+ )
152
+ puts created.key # store now; never retrievable again
153
+
154
+ client.api_keys.update(created.id, name: "Production server", permissions: "full")
155
+ client.api_keys.delete(created.id)
156
+ ```
157
+
158
+ `get` returns metadata only — `key_prefix`, never the secret. Permission and restriction changes take up to 5 minutes to propagate through the gateway cache.
159
+
160
+ ## Templates
161
+
162
+ Templates use a draft/published model: edits land in a draft, and `publish` promotes it. A template can't be used for sending until it's published.
163
+
164
+ ```ruby
165
+ template = client.templates.create(
166
+ name: "Welcome email",
167
+ kind: "html",
168
+ html: "<h1>Welcome, {{ name }}</h1>"
169
+ )
170
+
171
+ client.templates.update_draft(template.id, subject: "Welcome to Acme", html: "<h1>Welcome, {{ name }}</h1>")
172
+ client.templates.publish(template.id)
173
+ ```
174
+
175
+ `kind` is `html` or `markdown` and is immutable once set. The plain-text body is always derived server-side. `get_draft`, `delete_draft`, `duplicate`, `get`, `update` (name only), and `delete` round out the resource. Send with a published template via `template_id` (see [Sending](#sending)).
176
+
177
+ ## Suppressions
178
+
179
+ A suppression blocks sends to an address, scoped to a `topic`. The wildcard `*` blocks every topic; a specific topic (e.g. `marketing`) leaves transactional traffic untouched. Bounces and complaints write `*` automatically.
180
+
181
+ ```ruby
182
+ client.suppressions.create(email: "alice@example.com", topic: "marketing", note: "Customer requested removal")
183
+
184
+ row = client.suppressions.get("alice@example.com", "*")
185
+ client.suppressions.delete("alice@example.com", "marketing")
186
+ ```
187
+
188
+ `list` accepts `email_contains`, `topic`, `reason`, and `origin` filters. `list_for_email` returns every row for an address across all topics; `delete_for_email` removes them all.
189
+
190
+ ```ruby
191
+ client.suppressions.list(reason: "complaint").each do |s|
192
+ puts "#{s.email} #{s.topic} #{s.suppressed_at}"
193
+ end
194
+ ```
195
+
196
+ ## Webhooks
197
+
198
+ Manage webhook subscriptions under `client.webhooks`. The `signing_secret` comes back only once, on `create`; later reads return only `signing_secret_prefix`.
199
+
200
+ ```ruby
201
+ webhook = client.webhooks.create(
202
+ name: "Production events",
203
+ url: "https://hooks.example.com/anypost",
204
+ events: ["email.delivered", "email.bounced", "email.complained"]
205
+ )
206
+ puts webhook.signing_secret # store now; never retrievable again
207
+ ```
208
+
209
+ `update` sets the name, URL, events, and `status` together — set `status` to `"disabled"` to pause delivery, `"active"` to resume. `test` sends one synthetic `webhook.test` event and returns the outcome even when the endpoint fails. `rotate_secret` issues a new secret and keeps the previous one valid for a 24-hour grace window; `get`, `list`, and `delete` round out the resource.
210
+
211
+ ```ruby
212
+ result = client.webhooks.test(webhook.id)
213
+ puts "#{result.status_code} #{result.error}" unless result.delivered
214
+
215
+ rotated = client.webhooks.rotate_secret(webhook.id)
216
+ ```
217
+
218
+ ### Verifying deliveries
219
+
220
+ `Anypost::WebhookSignature.verify` is a module method — it needs the signing secret, not an API key, so call it in your handler without a client. Pass the **raw** request body (the exact bytes, before JSON parsing), the `Anypost-Signature` header, and the secret. It returns on success and raises `Anypost::WebhookVerificationError` otherwise. `Anypost::WebhookSignature.unwrap` does the same and returns the parsed delivery as a `Response`.
221
+
222
+ ```ruby
223
+ begin
224
+ delivery = Anypost::WebhookSignature.unwrap(raw_body, signature_header, secret)
225
+ delivery.events.each do |event|
226
+ # event.type, event.data.email_id, ...
227
+ end
228
+ rescue Anypost::WebhookVerificationError => e
229
+ # e.reason: :no_match | :timestamp_out_of_tolerance | ...
230
+ halt 400
231
+ end
232
+ ```
233
+
234
+ Reach for `verify` when something else has already parsed the body. Keep the raw bytes for the verify step, then use your parsed object once it passes — a Rack-style handler:
235
+
236
+ ```ruby
237
+ post "/anypost" do
238
+ raw = request.body.read
239
+ begin
240
+ Anypost::WebhookSignature.verify(raw, request.env["HTTP_ANYPOST_SIGNATURE"], secret)
241
+ rescue Anypost::WebhookVerificationError
242
+ halt 400
243
+ end
244
+
245
+ JSON.parse(raw)["events"].each { |event| handle(event) }
246
+ status 204
247
+ end
248
+ ```
249
+
250
+ Deliveries older than five minutes are rejected by default to bound replay; pass `tolerance_seconds:` to widen, narrow, or disable (`0`) that check. During a secret rotation the header carries a `v1=` component per active secret, and a match on any one passes — so deliveries keep verifying while you redeploy.
251
+
252
+ ## Events
253
+
254
+ `client.events.list` pages the team's event stream, newest-first. The window defaults to the last 24 hours and is clamped to your plan's retention. Events are read-only and not addressable by id — there is no `get`.
255
+
256
+ ```ruby
257
+ client.events.list(event_type: "email.bounced").each do |event|
258
+ puts "#{event.occurred_at} #{event.recipient} #{event.bounce_classification}"
259
+ end
260
+ ```
261
+
262
+ Filter by `start`, `end`, `event_type`, `recipient`, `email_id`, `message_id`, `domain`, `topic`, `campaign`, `template_id`, and `tags`. All filters are exact-match, except `tags`, which takes an array and matches an event carrying *any* of the given tags. A filter value that matches no row returns an empty page. This is also how you backfill the gap after a webhook endpoint was disabled — page the events that occurred during the outage once it's healthy.
263
+
264
+ ```ruby
265
+ # Events tagged "onboarding" OR "welcome", that also bounced.
266
+ page = client.events.list(tags: ["onboarding", "welcome"], event_type: "email.bounced")
267
+ ```
268
+
269
+ ## Pagination
270
+
271
+ List endpoints return a `Page`. Read one page directly, or iterate it to walk every page — the client fetches each one as needed.
272
+
273
+ ```ruby
274
+ page = client.domains.list(limit: 50)
275
+ page.data # this page's items
276
+ page.has_more # whether another page exists
277
+ page.next_cursor # pass as :after to fetch it yourself
278
+
279
+ client.domains.list.each do |domain|
280
+ puts domain.name # every domain, across all pages
281
+ end
282
+ ```
283
+
284
+ A `Page` is `Enumerable`, so `map`, `select`, `find`, and friends all walk every page.
285
+
286
+ ## Errors
287
+
288
+ A failed request raises an `Anypost::Error` subclass. Branch on `error.type`, the stable machine-readable code, not on the HTTP status.
289
+
290
+ ```ruby
291
+ begin
292
+ client.email.send(message)
293
+ rescue Anypost::ValidationError => e
294
+ e.errors # {"from" => ["The from field is required."]}
295
+ rescue Anypost::RateLimitError => e
296
+ e.retry_after # seconds, or nil
297
+ rescue Anypost::Error => e
298
+ "#{e.type} #{e.status} #{e.message}"
299
+ end
300
+ ```
301
+
302
+ | Class | `type` | Status |
303
+ |---|---|---|
304
+ | `ValidationError` | `validation_error` | `400`, `422` |
305
+ | `AuthenticationError` | `authentication_error` | `401` |
306
+ | `PermissionError` | `permission_error` | `403` |
307
+ | `NotFoundError` | `not_found` | `404` |
308
+ | `ConflictError` | `idempotency_concurrent`, `webhook_rotation_in_progress` | `409` |
309
+ | `IdempotencyMismatchError` | `idempotency_mismatch` | `422` |
310
+ | `RateLimitError` | `rate_limit_exceeded` | `429` |
311
+ | `PayloadTooLargeError` | `payload_too_large` | `413` |
312
+ | `APIError` | `internal_error`, `provisioning_error` | `5xx` |
313
+ | `APIConnectionError` | `connection_error` | none |
314
+
315
+ Every error carries `type`, `status`, `message`, `request_id`, and the parsed `raw` body.
316
+
317
+ ## Retries and idempotency
318
+
319
+ The client retries `429`, `502`, `503`, and network failures up to `max_retries` times (default 2), with exponential backoff and full jitter. It honors `Retry-After`.
320
+
321
+ Sends are made safe to retry automatically: when retries are enabled and you do not pass an idempotency key, the client generates one and reuses it across attempts, so a retried send cannot deliver twice. Pass your own key (the second argument) to dedupe across process restarts:
322
+
323
+ ```ruby
324
+ client.email.send(message, order_id)
325
+ client.email.send_batch(batch, idempotency_key)
326
+ ```
327
+
328
+ ## Configuration
329
+
330
+ ```ruby
331
+ Anypost::Client.new(
332
+ "ap_your_api_key",
333
+ base_url: "https://api.anypost.com/v1",
334
+ timeout: 30,
335
+ max_retries: 2,
336
+ default_headers: {"X-My-Header" => "value"}
337
+ )
338
+ ```
339
+
340
+ | Option | Default | Description |
341
+ |---|---|---|
342
+ | `base_url` | `https://api.anypost.com/v1` | API base URL. |
343
+ | `timeout` | `30` | Per-request timeout, in seconds. |
344
+ | `max_retries` | `2` | Automatic retries for transient failures. |
345
+ | `default_headers` | `{}` | Extra headers sent on every request. |
346
+ | `connection` | a new one | Bring your own Faraday connection. |
347
+
348
+ The first argument is the API key (`ap_...`); omit it to read `ANYPOST_API_KEY`. `send` and `send_batch` accept a per-call idempotency key as their second argument.
349
+
350
+ ## License
351
+
352
+ MIT
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anypost
4
+ # Client for the Anypost email API.
5
+ #
6
+ # require "anypost"
7
+ #
8
+ # client = Anypost::Client.new("ap_your_api_key") # or Anypost::Client.new to read ANYPOST_API_KEY
9
+ # email = client.email.send(
10
+ # from: "Acme <you@yourdomain.com>",
11
+ # to: ["someone@example.com"],
12
+ # subject: "Hello",
13
+ # html: "<p>It worked.</p>"
14
+ # )
15
+ # email.id
16
+ class Client
17
+ DEFAULT_BASE_URL = "https://api.anypost.com/v1"
18
+ DEFAULT_TIMEOUT = 30
19
+ DEFAULT_MAX_RETRIES = 2
20
+
21
+ # @return [Resources::Email] send operations (`/email`, `/email/batch`)
22
+ attr_reader :email
23
+ # @return [Resources::Domains] sending-domain operations (`/domains`)
24
+ attr_reader :domains
25
+ # @return [Resources::ApiKeys] API-key operations (`/api-keys`)
26
+ attr_reader :api_keys
27
+ # @return [Resources::Templates] template operations, including draft/publish
28
+ attr_reader :templates
29
+ # @return [Resources::Suppressions] suppression-list operations (`/suppressions`)
30
+ attr_reader :suppressions
31
+ # @return [Resources::Webhooks] webhook operations, including test and rotation
32
+ attr_reader :webhooks
33
+ # @return [Resources::Events] read access to the event stream (`/events`)
34
+ attr_reader :events
35
+
36
+ # @param api_key [String, nil] defaults to the ANYPOST_API_KEY environment variable
37
+ def initialize(api_key = nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
38
+ max_retries: DEFAULT_MAX_RETRIES, default_headers: {}, connection: nil, sleeper: nil, jitter: nil)
39
+ key = api_key
40
+ key = ENV["ANYPOST_API_KEY"] if key.nil? || key.empty?
41
+ if key.nil? || key.empty?
42
+ raise ArgumentError,
43
+ "An Anypost API key is required. Pass it to the constructor or set ANYPOST_API_KEY."
44
+ end
45
+
46
+ http = HttpClient.new(
47
+ api_key: key,
48
+ base_url: base_url,
49
+ timeout: timeout,
50
+ max_retries: max_retries,
51
+ default_headers: default_headers,
52
+ connection: connection,
53
+ sleeper: sleeper,
54
+ jitter: jitter
55
+ )
56
+
57
+ @email = Resources::Email.new(http)
58
+ @domains = Resources::Domains.new(http)
59
+ @api_keys = Resources::ApiKeys.new(http)
60
+ @templates = Resources::Templates.new(http)
61
+ @suppressions = Resources::Suppressions.new(http)
62
+ @webhooks = Resources::Webhooks.new(http)
63
+ @events = Resources::Events.new(http)
64
+ @identity = Resources::Identity.new(http)
65
+ end
66
+
67
+ # Identify the team and permission level behind the current API key.
68
+ def whoami
69
+ @identity.whoami
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Anypost
6
+ # Base class for every error raised by the SDK.
7
+ #
8
+ # Branch on {#type} (the stable, machine-readable code) rather than on the
9
+ # HTTP status or the message text.
10
+ class Error < StandardError
11
+ # @return [String] stable, machine-readable error type
12
+ attr_reader :type
13
+ # @return [Integer, nil] HTTP status, or nil when no response was received
14
+ attr_reader :status
15
+ # @return [String, nil] request id from the response, when present
16
+ attr_reader :request_id
17
+ # @return [Object] the parsed response body, or the underlying cause
18
+ attr_reader :raw
19
+
20
+ def initialize(message, type:, status: nil, request_id: nil, raw: nil)
21
+ super(message)
22
+ @type = type
23
+ @status = status
24
+ @request_id = request_id
25
+ @raw = raw
26
+ end
27
+ end
28
+
29
+ # 400/422 — the request body or query failed validation.
30
+ class ValidationError < Error
31
+ # @return [Hash{String => Array<String>}] field path -> list of problems
32
+ attr_reader :errors
33
+
34
+ def initialize(message, errors: {}, **kwargs)
35
+ super(message, **kwargs)
36
+ @errors = errors || {}
37
+ end
38
+ end
39
+
40
+ # 401 — the API key is missing or invalid.
41
+ class AuthenticationError < Error; end
42
+
43
+ # 403 — the key may not perform this action.
44
+ class PermissionError < Error; end
45
+
46
+ # 404 — no such resource for this team.
47
+ class NotFoundError < Error; end
48
+
49
+ # 409 — conflict, idempotency_concurrent, or webhook_rotation_in_progress.
50
+ class ConflictError < Error; end
51
+
52
+ # 422 idempotency_mismatch — a key was reused with a different body.
53
+ class IdempotencyMismatchError < Error; end
54
+
55
+ # 429 — a rate limit was exceeded.
56
+ class RateLimitError < Error
57
+ # @return [Float, nil] parsed Retry-After, in seconds, when the server sent one
58
+ attr_reader :retry_after
59
+
60
+ def initialize(message, retry_after: nil, **kwargs)
61
+ super(message, **kwargs)
62
+ @retry_after = retry_after
63
+ end
64
+ end
65
+
66
+ # 413 — the request body exceeded the 5 MB gateway limit.
67
+ class PayloadTooLargeError < Error; end
68
+
69
+ # A server error (5xx), including internal_error and provisioning_error.
70
+ class APIError < Error; end
71
+
72
+ # No HTTP response was received (network failure, timeout, or abort).
73
+ class APIConnectionError < Error
74
+ def initialize(message, cause: nil)
75
+ super(message, type: "connection_error", raw: cause)
76
+ end
77
+ end
78
+
79
+ # Maps an HTTP response into the right {Error} subclass. Keys primarily on the
80
+ # canonical `error.type`, falling back to the HTTP status.
81
+ #
82
+ # @api private
83
+ module Errors
84
+ REQUEST_ID_HEADERS = ["anypost-request-id", "x-anypost-request-id", "x-request-id"].freeze
85
+
86
+ module_function
87
+
88
+ def from_response(status, body, headers)
89
+ request_id = read_request_id(headers)
90
+ envelope = body.is_a?(Hash) ? body : {}
91
+ error = envelope["error"]
92
+
93
+ errors = {}
94
+ case error
95
+ when Hash
96
+ # Canonical envelope: { error: { type, message, errors? } }.
97
+ type = error["type"] || type_from_status(status)
98
+ message = error["message"] || default_message(status)
99
+ errors = error["errors"] if error["errors"].is_a?(Hash)
100
+ when String
101
+ # Flat envelope: { error: "<code>", message? }.
102
+ type = error
103
+ message = envelope["message"] || error.tr("_", " ")
104
+ else
105
+ type = type_from_status(status)
106
+ message = default_message(status)
107
+ end
108
+
109
+ build(status, type, message, errors || {}, request_id, body, headers)
110
+ end
111
+
112
+ # Parse a Retry-After header (delta-seconds or HTTP-date) into seconds.
113
+ def retry_after_seconds(headers)
114
+ value = header(headers, "retry-after")
115
+ return nil if value.nil? || value.empty?
116
+ return [value.to_f, 0.0].max if /\A\s*\d+(\.\d+)?\s*\z/.match?(value)
117
+
118
+ begin
119
+ target = Time.httpdate(value)
120
+ rescue ArgumentError
121
+ return nil
122
+ end
123
+ [target.to_f - Time.now.to_f, 0.0].max
124
+ end
125
+
126
+ def build(status, type, message, errors, request_id, raw, headers)
127
+ common = {status: status, request_id: request_id, raw: raw}
128
+ case type
129
+ when "validation_error"
130
+ ValidationError.new(message, errors: errors, type: type, **common)
131
+ when "authentication_error"
132
+ AuthenticationError.new(message, type: type, **common)
133
+ when "permission_error"
134
+ PermissionError.new(message, type: type, **common)
135
+ when "not_found"
136
+ NotFoundError.new(message, type: type, **common)
137
+ when "conflict", "idempotency_concurrent", "webhook_rotation_in_progress"
138
+ ConflictError.new(message, type: type, **common)
139
+ when "idempotency_mismatch"
140
+ IdempotencyMismatchError.new(message, type: type, **common)
141
+ when "rate_limit_exceeded"
142
+ RateLimitError.new(message, retry_after: retry_after_seconds(headers), type: type, **common)
143
+ when "payload_too_large"
144
+ PayloadTooLargeError.new(message, type: type, **common)
145
+ when "provisioning_error", "internal_error"
146
+ APIError.new(message, type: type, **common)
147
+ else
148
+ by_status(status, type, message, errors, headers, common)
149
+ end
150
+ end
151
+
152
+ def by_status(status, type, message, errors, headers, common)
153
+ case status
154
+ when 401 then AuthenticationError.new(message, type: type, **common)
155
+ when 403 then PermissionError.new(message, type: type, **common)
156
+ when 404 then NotFoundError.new(message, type: type, **common)
157
+ when 409 then ConflictError.new(message, type: type, **common)
158
+ when 413 then PayloadTooLargeError.new(message, type: type, **common)
159
+ when 429 then RateLimitError.new(message, retry_after: retry_after_seconds(headers), type: type, **common)
160
+ when 400, 422 then ValidationError.new(message, errors: errors, type: type, **common)
161
+ else
162
+ (status >= 500) ? APIError.new(message, type: type, **common) : Error.new(message, type: type, **common)
163
+ end
164
+ end
165
+
166
+ def type_from_status(status)
167
+ case status
168
+ when 400, 422 then "validation_error"
169
+ when 401 then "authentication_error"
170
+ when 403 then "permission_error"
171
+ when 404 then "not_found"
172
+ when 409 then "conflict"
173
+ when 413 then "payload_too_large"
174
+ when 429 then "rate_limit_exceeded"
175
+ else
176
+ (status >= 500) ? "internal_error" : "api_error"
177
+ end
178
+ end
179
+
180
+ def default_message(status)
181
+ "Anypost request failed with status #{status}."
182
+ end
183
+
184
+ def read_request_id(headers)
185
+ REQUEST_ID_HEADERS.each do |name|
186
+ value = header(headers, name)
187
+ return value if value && !value.empty?
188
+ end
189
+ nil
190
+ end
191
+
192
+ # Case-insensitive single-value header lookup over a Hash or Faraday headers.
193
+ def header(headers, name)
194
+ return nil if headers.nil?
195
+
196
+ name = name.downcase
197
+ headers.each do |key, value|
198
+ return value if key.to_s.downcase == name
199
+ end
200
+ nil
201
+ end
202
+ end
203
+ end