mailfloss 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/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +203 -0
- data/lib/mailfloss/client.rb +209 -0
- data/lib/mailfloss/errors.rb +64 -0
- data/lib/mailfloss/resources.rb +294 -0
- data/lib/mailfloss/transport.rb +73 -0
- data/lib/mailfloss/version.rb +5 -0
- data/lib/mailfloss.rb +14 -0
- data/sig/mailfloss.rbs +77 -0
- data/sig/resources.rbs +85 -0
- data/sig/types.rbs +266 -0
- metadata +62 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ae5fcc08d152608c98088bd9860eaaceaf47591f7ecca3b23e0a40b9260b4147
|
|
4
|
+
data.tar.gz: 2392c5185dba55e21924bbcdd1ee5593008d8ebe2111189dd5287461f39cf431
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d22fc1c0323c1b97bca607b1caea7287cc29d674fbccf1cf7cd9c2233c60019b854ed01430601af1fb2219594848bdb1c5b672486e2b7acfb3ce4afffd44d9bf
|
|
7
|
+
data.tar.gz: 500f1b5415426ab0ef8036a7ef5539b694f4284787362f0db22bf527fff040b5c2e14634a5fe156b7c5b207ad47219f1ba9846a929a3403f1511e2285830ac22
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-07-06)
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Full coverage of the mailfloss v1 public API (all 26 endpoints):
|
|
8
|
+
verification (`/verify`, `/batch-verify` + status/results/cancel),
|
|
9
|
+
jobs, users, usage reports, `/check-key`, account (read + update),
|
|
10
|
+
organization, integrations (list/detail + connection
|
|
11
|
+
create/get/update/delete/sync/test + keyword rule list/add/delete),
|
|
12
|
+
and erasures.
|
|
13
|
+
- Zero runtime dependencies — stdlib `Net::HTTP`, `JSON`, `SecureRandom`.
|
|
14
|
+
- Automatic retries on 429/5xx (honoring `Retry-After`) and transient
|
|
15
|
+
connection errors, with exponential backoff and full jitter.
|
|
16
|
+
- Automatic `Idempotency-Key` on every POST (caller-overridable).
|
|
17
|
+
- Typed errors: `Mailfloss::APIError` with `status`, `code`, `type`,
|
|
18
|
+
`request_id`.
|
|
19
|
+
- RBS type signatures under `sig/`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mailfloss
|
|
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,203 @@
|
|
|
1
|
+
# mailfloss — official Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby client for the [mailfloss](https://mailfloss.com) email verification API.
|
|
4
|
+
Zero runtime dependencies (stdlib `Net::HTTP`), Ruby 2.6+.
|
|
5
|
+
|
|
6
|
+
API reference: https://developers.mailfloss.com
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
gem install mailfloss
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or in your Gemfile:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "mailfloss", "~> 0.1"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Authentication
|
|
21
|
+
|
|
22
|
+
Grab an API key from your mailfloss dashboard, then either pass it
|
|
23
|
+
directly:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "mailfloss"
|
|
27
|
+
|
|
28
|
+
client = Mailfloss::Client.new(api_key: "mf_rk_your_key")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
...or set the `MAILFLOSS_API_KEY` environment variable and omit the kwarg:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
export MAILFLOSS_API_KEY="mf_rk_your_key"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
client = Mailfloss::Client.new
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If no key is available in either place, `Mailfloss::ConfigurationError` is
|
|
42
|
+
raised. The key is sent as `Authorization: Bearer <key>` on every request.
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
### Verify a single email — `GET /v1/verify`
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
result = client.verify(email: "jane@acme.com")
|
|
50
|
+
|
|
51
|
+
result[:status] # "passed" | "undeliverable" | "risky" | "unknown"
|
|
52
|
+
result[:passed] # true
|
|
53
|
+
result[:reason] # e.g. "valid"
|
|
54
|
+
result[:domain] # "acme.com"
|
|
55
|
+
result[:disposable] # false
|
|
56
|
+
result[:role] # false
|
|
57
|
+
result[:free] # false
|
|
58
|
+
result[:suggestion] # typo suggestion, when detected
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Batch verification — `POST /v1/batch-verify`
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
batch = client.batch_verify.create(
|
|
65
|
+
emails: ["jane@acme.com", "bob@invalid.example"],
|
|
66
|
+
webhook_url: "https://hooks.example.com/mailfloss-done" # optional callback
|
|
67
|
+
)
|
|
68
|
+
batch[:id] # => the batch job id
|
|
69
|
+
|
|
70
|
+
# Poll progress
|
|
71
|
+
status = client.batch_verify.status(batch[:id])
|
|
72
|
+
status[:status] # e.g. "processing"
|
|
73
|
+
status[:progress] # 0.0..1.0
|
|
74
|
+
|
|
75
|
+
# Page through results when done
|
|
76
|
+
page = client.batch_verify.results(batch[:id], per_page: 100)
|
|
77
|
+
page[:results]
|
|
78
|
+
|
|
79
|
+
# Or cancel
|
|
80
|
+
client.batch_verify.cancel(batch[:id]) # => { success: true }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
All responses are JSON parsed with **symbol keys**.
|
|
84
|
+
|
|
85
|
+
## Full surface
|
|
86
|
+
|
|
87
|
+
| Resource | Methods |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `client.verify(email:, timeout: nil)` | GET /verify |
|
|
90
|
+
| `client.batch_verify` | `.create(emails:, webhook_url: nil)` · `.status(id)` · `.results(id, per_page:, next_cursor:)` · `.cancel(id)` |
|
|
91
|
+
| `client.jobs` | `.list(per_page:, cursor:, source:, status:)` · `.get(id)` |
|
|
92
|
+
| `client.users` | `.list(per_page:, cursor:)` · `.get(user_id)` |
|
|
93
|
+
| `client.reports` | `.usage(period:, connection_id:)` — period: `this_week` / `this_month` / `this_year` / `lifetime` |
|
|
94
|
+
| `client.check_key(...)` | GET /check-key |
|
|
95
|
+
| `client.account` | `.get` · `.update(name:, organization:, phone:, vat_id:, country:, address:, notifications:)` |
|
|
96
|
+
| `client.organization` | `.get` — plan, credits, usage, trial, entitlements |
|
|
97
|
+
| `client.integrations` | `.list(per_page:, cursor:)` · `.get(type)` |
|
|
98
|
+
| `client.integrations.connections` | `.create(type, credentials:, name:)` · `.get(type, id)` · `.update(type, id, aggressiveness:, manual_mode:, autofloss:, decay_protection:, action:, checks:)` · `.delete(type, id)` · `.sync(type, id)` · `.test(type, id)` |
|
|
99
|
+
| `client.integrations.keywords` | `.list(type, id, list, per_page:, cursor:)` · `.add(type, id, list, rules:)` · `.delete(type, id, list, rule_id)` — list: `"blacklist"` / `"whitelist"` |
|
|
100
|
+
| `client.erasures` | `.create(emails:, webhook_url: nil)` — GDPR/CCPA data erasure |
|
|
101
|
+
|
|
102
|
+
List endpoints return a cursor-paginated envelope:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
page = client.jobs.list(per_page: 50)
|
|
106
|
+
page[:data] # => [{ id:, status:, source:, ... }, ...]
|
|
107
|
+
page[:pagination][:has_more] # => true
|
|
108
|
+
page[:pagination][:next_cursor] # opaque — pass back as cursor:
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Example: connect an ESP and add keyword rules
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
conn = client.integrations.connections.create(
|
|
115
|
+
"klaviyo",
|
|
116
|
+
credentials: { api_key: "pk_..." },
|
|
117
|
+
name: "Main Klaviyo account"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
client.integrations.connections.update("klaviyo", conn[:id], autofloss: true)
|
|
121
|
+
|
|
122
|
+
client.integrations.keywords.add(
|
|
123
|
+
"klaviyo", conn[:id], "whitelist",
|
|
124
|
+
rules: [{ keyword: "vip", match: "contains", applies_to: { domain: true } }]
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Configuration
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
client = Mailfloss::Client.new(
|
|
132
|
+
api_key: "mf_rk_...", # or ENV["MAILFLOSS_API_KEY"]
|
|
133
|
+
base_url: "https://api.mailfloss.com/v1", # default
|
|
134
|
+
max_retries: 3, # retries after the first attempt
|
|
135
|
+
timeout: 30, # seconds (open/read)
|
|
136
|
+
transport: nil # injectable, see below
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Retries
|
|
141
|
+
|
|
142
|
+
429 and 5xx responses are retried automatically (as are transient
|
|
143
|
+
connection errors). A `Retry-After` header with integer seconds is
|
|
144
|
+
honored; otherwise exponential backoff with full jitter is used
|
|
145
|
+
(base 0.5s, factor 2, capped at 8s).
|
|
146
|
+
|
|
147
|
+
### Idempotency
|
|
148
|
+
|
|
149
|
+
Every `POST` carries an `Idempotency-Key` header — a fresh
|
|
150
|
+
`SecureRandom.uuid` per call by default. Pass `idempotency_key:` on any
|
|
151
|
+
create/cancel/sync/test call to supply your own (useful when you retry at
|
|
152
|
+
the application level):
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
client.batch_verify.create(emails: emails, idempotency_key: "import-2026-07-06")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Errors
|
|
159
|
+
|
|
160
|
+
Non-2xx responses raise `Mailfloss::APIError` (< `Mailfloss::Error`):
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
begin
|
|
164
|
+
client.jobs.get("nope")
|
|
165
|
+
rescue Mailfloss::APIError => e
|
|
166
|
+
e.status # 404
|
|
167
|
+
e.code # "not_found"
|
|
168
|
+
e.message # human-readable message
|
|
169
|
+
e.type # error type, when provided
|
|
170
|
+
e.request_id # for support requests
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Custom transport
|
|
175
|
+
|
|
176
|
+
The low-level transport is injectable — any object responding to
|
|
177
|
+
`call(method, url, headers, body)` and returning an object with `status`,
|
|
178
|
+
`headers`, and `body`. This is how the test suite mocks HTTP without a
|
|
179
|
+
network:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
fake = ->(method, url, headers, body) {
|
|
183
|
+
Mailfloss::Transport::Response.new(200, {}, '{"passed":true,"email":"a@b.com","status":"passed","reason":"valid"}')
|
|
184
|
+
}
|
|
185
|
+
client = Mailfloss::Client.new(api_key: "test", transport: fake)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Type signatures
|
|
189
|
+
|
|
190
|
+
RBS signatures for the client, resources, and all response shapes
|
|
191
|
+
(hand-derived from the OpenAPI spec) ship under `sig/`.
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
ruby test/run_tests.rb
|
|
197
|
+
# or
|
|
198
|
+
ruby -Ilib -Itest test/mailfloss_test.rb
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mailfloss
|
|
8
|
+
# Entry point for the mailfloss API.
|
|
9
|
+
#
|
|
10
|
+
# client = Mailfloss::Client.new(api_key: "mf_rk_...")
|
|
11
|
+
# client.verify(email: "jane@acme.com")
|
|
12
|
+
#
|
|
13
|
+
# The API key falls back to ENV["MAILFLOSS_API_KEY"] when the kwarg is
|
|
14
|
+
# omitted. All requests are retried automatically on 429 / 5xx (honoring
|
|
15
|
+
# Retry-After) and on transient connection errors.
|
|
16
|
+
class Client
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.mailfloss.com/v1"
|
|
18
|
+
DEFAULT_MAX_RETRIES = 3
|
|
19
|
+
DEFAULT_TIMEOUT = 30
|
|
20
|
+
RETRY_BASE_DELAY = 0.5
|
|
21
|
+
RETRY_MAX_DELAY = 8.0
|
|
22
|
+
USER_AGENT = "mailfloss-ruby/#{VERSION}"
|
|
23
|
+
|
|
24
|
+
attr_reader :base_url, :max_retries, :timeout
|
|
25
|
+
|
|
26
|
+
# @param api_key [String, nil] falls back to ENV["MAILFLOSS_API_KEY"]
|
|
27
|
+
# @param base_url [String]
|
|
28
|
+
# @param max_retries [Integer] retries after the initial attempt
|
|
29
|
+
# @param timeout [Numeric] per-request open/read timeout in seconds
|
|
30
|
+
# @param transport [#call, nil] injectable transport:
|
|
31
|
+
# call(method, url, headers, body) -> #status/#headers/#body
|
|
32
|
+
# @param sleeper [#call, nil] injectable sleep proc (tests pass ->(_s){})
|
|
33
|
+
def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, max_retries: DEFAULT_MAX_RETRIES,
|
|
34
|
+
timeout: DEFAULT_TIMEOUT, transport: nil, sleeper: nil)
|
|
35
|
+
@api_key = api_key || ENV["MAILFLOSS_API_KEY"]
|
|
36
|
+
if @api_key.nil? || @api_key.to_s.strip.empty?
|
|
37
|
+
raise ConfigurationError,
|
|
38
|
+
"No API key. Pass api_key: to Mailfloss::Client.new or set the " \
|
|
39
|
+
"MAILFLOSS_API_KEY environment variable."
|
|
40
|
+
end
|
|
41
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
42
|
+
@max_retries = max_retries
|
|
43
|
+
@timeout = timeout
|
|
44
|
+
@transport = transport || Transport.new(timeout: timeout)
|
|
45
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -- Resource namespaces --------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# GET /verify — single-email verification.
|
|
51
|
+
#
|
|
52
|
+
# @param email [String] the address to verify
|
|
53
|
+
# @param timeout [Numeric, String, nil] optional per-request timeout (seconds)
|
|
54
|
+
# @return [Hash] { email:, domain:, status:, reason:, passed:, role:,
|
|
55
|
+
# disposable:, free:, suggestion:, meta: }
|
|
56
|
+
def verify(email:, timeout: nil)
|
|
57
|
+
request(:get, "/verify", query: { email: email, timeout: timeout })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# GET /check-key — validate an API key and optionally return plan/credits.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] { name:, organization:, planType:, freeMatches:, extraCredits: }
|
|
63
|
+
def check_key(api_key: nil, public_key: nil, check_plan: nil, get_token: nil, check_credits: nil)
|
|
64
|
+
request(:get, "/check-key", query: {
|
|
65
|
+
api_key: api_key, public_key: public_key, check_plan: check_plan,
|
|
66
|
+
get_token: get_token, check_credits: check_credits
|
|
67
|
+
})
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def batch_verify
|
|
71
|
+
@batch_verify ||= Resources::BatchVerify.new(self)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def jobs
|
|
75
|
+
@jobs ||= Resources::Jobs.new(self)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def users
|
|
79
|
+
@users ||= Resources::Users.new(self)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reports
|
|
83
|
+
@reports ||= Resources::Reports.new(self)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def account
|
|
87
|
+
@account ||= Resources::Account.new(self)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def organization
|
|
91
|
+
@organization ||= Resources::Organization.new(self)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def integrations
|
|
95
|
+
@integrations ||= Resources::Integrations.new(self)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def erasures
|
|
99
|
+
@erasures ||= Resources::Erasures.new(self)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# -- Request plumbing -----------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Perform an authenticated request against the API.
|
|
105
|
+
#
|
|
106
|
+
# @param method [Symbol] :get, :post, :patch, :delete
|
|
107
|
+
# @param path [String] resource path relative to base_url (e.g. "/verify")
|
|
108
|
+
# @param query [Hash, nil] query params; nil values are dropped
|
|
109
|
+
# @param body [Hash, nil] JSON-serialized when present
|
|
110
|
+
# @param idempotency_key [String, nil] POST only; auto-generated when nil
|
|
111
|
+
# @return [Object, nil] parsed JSON (symbolized keys), or nil on empty body
|
|
112
|
+
def request(method, path, query: nil, body: nil, idempotency_key: nil)
|
|
113
|
+
url = build_url(path, query)
|
|
114
|
+
headers = {
|
|
115
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
116
|
+
"Accept" => "application/json",
|
|
117
|
+
"User-Agent" => USER_AGENT
|
|
118
|
+
}
|
|
119
|
+
headers["Content-Type"] = "application/json" if body
|
|
120
|
+
if method == :post
|
|
121
|
+
headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid
|
|
122
|
+
end
|
|
123
|
+
payload = body ? JSON.generate(body) : nil
|
|
124
|
+
|
|
125
|
+
response = perform_with_retries(method, url, headers, payload)
|
|
126
|
+
status = response_status(response)
|
|
127
|
+
raise APIError.new(status: status, body: response_body(response)) unless (200..299).cover?(status)
|
|
128
|
+
|
|
129
|
+
parse_body(response_body(response))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def build_url(path, query)
|
|
135
|
+
url = @base_url + path
|
|
136
|
+
if query
|
|
137
|
+
pairs = query.reject { |_k, v| v.nil? }
|
|
138
|
+
url += "?#{URI.encode_www_form(pairs)}" unless pairs.empty?
|
|
139
|
+
end
|
|
140
|
+
url
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def perform_with_retries(method, url, headers, payload)
|
|
144
|
+
attempt = 0
|
|
145
|
+
begin
|
|
146
|
+
response = @transport.call(method, url, headers, payload)
|
|
147
|
+
status = response_status(response)
|
|
148
|
+
if retryable_status?(status) && attempt < @max_retries
|
|
149
|
+
delay = retry_after_seconds(response) || backoff_delay(attempt)
|
|
150
|
+
attempt += 1
|
|
151
|
+
@sleeper.call(delay)
|
|
152
|
+
raise RetrySignal
|
|
153
|
+
end
|
|
154
|
+
response
|
|
155
|
+
rescue RetrySignal
|
|
156
|
+
retry
|
|
157
|
+
rescue *Transport::CONNECTION_ERRORS => e
|
|
158
|
+
raise Error, "Connection error: #{e.class}: #{e.message}" unless attempt < @max_retries
|
|
159
|
+
|
|
160
|
+
@sleeper.call(backoff_delay(attempt))
|
|
161
|
+
attempt += 1
|
|
162
|
+
retry
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
RetrySignal = Class.new(StandardError)
|
|
167
|
+
private_constant :RetrySignal
|
|
168
|
+
|
|
169
|
+
def retryable_status?(status)
|
|
170
|
+
status == 429 || (500..599).cover?(status)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def retry_after_seconds(response)
|
|
174
|
+
headers = response_headers(response)
|
|
175
|
+
value = headers.find { |k, _v| k.to_s.downcase == "retry-after" }
|
|
176
|
+
return nil unless value
|
|
177
|
+
|
|
178
|
+
seconds = value[1].to_s
|
|
179
|
+
/\A\d+\z/.match?(seconds) ? seconds.to_i : nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Exponential backoff with full jitter: rand(0..min(cap, base * 2**attempt))
|
|
183
|
+
def backoff_delay(attempt)
|
|
184
|
+
cap = [RETRY_MAX_DELAY, RETRY_BASE_DELAY * (2**attempt)].min
|
|
185
|
+
rand * cap
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def response_status(response)
|
|
189
|
+
response.respond_to?(:status) ? response.status : response.fetch(:status)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def response_headers(response)
|
|
193
|
+
value = response.respond_to?(:headers) ? response.headers : response[:headers]
|
|
194
|
+
value || {}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def response_body(response)
|
|
198
|
+
response.respond_to?(:body) ? response.body : response[:body]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_body(body)
|
|
202
|
+
return nil if body.nil? || body.strip.empty?
|
|
203
|
+
|
|
204
|
+
JSON.parse(body, symbolize_names: true)
|
|
205
|
+
rescue JSON::ParserError
|
|
206
|
+
body
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailfloss
|
|
4
|
+
# Base error class for everything raised by this gem.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the client is misconfigured (e.g. no API key available).
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the API responds with a non-2xx status.
|
|
11
|
+
#
|
|
12
|
+
# The mailfloss API returns errors in the envelope:
|
|
13
|
+
# { "error": { "code": "...", "message": "...", "type": "...", "request_id": "..." } }
|
|
14
|
+
#
|
|
15
|
+
# When the body is not JSON (or not in that envelope) the error degrades
|
|
16
|
+
# gracefully: +code+ becomes "unknown_error" and +message+ falls back to
|
|
17
|
+
# the raw body / a generic message.
|
|
18
|
+
class APIError < Error
|
|
19
|
+
attr_reader :status, :code, :type, :request_id
|
|
20
|
+
|
|
21
|
+
def initialize(status:, body: nil)
|
|
22
|
+
@status = status
|
|
23
|
+
parsed = parse_error(body)
|
|
24
|
+
@code = parsed[:code]
|
|
25
|
+
@type = parsed[:type]
|
|
26
|
+
@request_id = parsed[:request_id]
|
|
27
|
+
super(parsed[:message])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_error(body)
|
|
33
|
+
fallback = {
|
|
34
|
+
code: "unknown_error",
|
|
35
|
+
message: default_message(body),
|
|
36
|
+
type: nil,
|
|
37
|
+
request_id: nil
|
|
38
|
+
}
|
|
39
|
+
return fallback if body.nil? || body.empty?
|
|
40
|
+
|
|
41
|
+
data = JSON.parse(body)
|
|
42
|
+
err = data.is_a?(Hash) ? data["error"] : nil
|
|
43
|
+
return fallback unless err.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
code: err["code"] || "unknown_error",
|
|
47
|
+
message: err["message"] || default_message(body),
|
|
48
|
+
type: err["type"],
|
|
49
|
+
request_id: err["request_id"]
|
|
50
|
+
}
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
fallback
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_message(body)
|
|
56
|
+
snippet = body.to_s.strip
|
|
57
|
+
if snippet.empty?
|
|
58
|
+
"HTTP #{@status}"
|
|
59
|
+
else
|
|
60
|
+
"HTTP #{@status}: #{snippet[0, 200]}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|