email-validation-sdk 1.0.0 → 1.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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +115 -63
- data/lib/email_validation_sdk/bulk.rb +126 -31
- data/lib/email_validation_sdk/client.rb +51 -22
- data/lib/email_validation_sdk/errors.rb +5 -2
- data/lib/email_validation_sdk/models.rb +44 -2
- data/lib/email_validation_sdk/transport.rb +133 -10
- data/lib/email_validation_sdk/version.rb +1 -1
- data/lib/email_validation_sdk/webhooks.rb +3 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42a80685440b79d131e81f340ddb16dd70462a855e80163f84226c48ba38af54
|
|
4
|
+
data.tar.gz: 3afbbd8c4875d74653bd85a3d74a6be2fedc5a03b98de5143c2f348a393da188
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f98f9cda933bb3c97d28499f7182a80612a85af93b495c3cb5e96a8295933b5a5dcfdc395d5829fd653ac3e6c959d42355cf3e02ea45c25dd3f75bcfc02a3d4
|
|
7
|
+
data.tar.gz: 29d75a98ca4d6fbd475e54240c55ceaff627a47944033f837df48382f368877d82cc8b0ae2b9d0304578b0ff72924f6de4176fd0827ccff92bbd297422494c22
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ All notable changes to the Ruby SDK (`email-validation-sdk`). The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the SDK uses [Semantic Versioning](https://semver.org/):
|
|
5
5
|
a new vocabulary value, option or operation is a minor release; a removed or renamed public symbol is a major one.
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-11-06
|
|
8
|
+
|
|
9
|
+
Hardening: one behaviour for retries, timeouts, errors, bulk transfers and webhooks across the official SDKs.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `request_timeout:` on every method of `Client` and `Bulk` (each poll of `bulk.wait`): replaces `timeout` for that
|
|
14
|
+
one call; a value that is not a finite number of seconds greater than 0 raises `ArgumentError`.
|
|
15
|
+
- `client.bulk.download(job_id, path, resume: true, filter:, columns:, escape_formulas:)`: streams the results into a
|
|
16
|
+
file and resumes a partial one with `Range` (an already complete file, answered `416 bytes */N`, is left as is).
|
|
17
|
+
- `Error#headers` (the answer's response headers) and `HttpRequest#transfer?`.
|
|
18
|
+
- `StatusCounts#extra`: a status this version does not know is kept and re-encoded by `to_h` / `to_json`. It is
|
|
19
|
+
not a `Data` member: `members`, positional `new` and array patterns stay the five statuses of 1.0.0.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Transfers (a `file:` upload, `bulk.results`, `bulk.download`) use `timeout` as an idle timeout instead of a cap on
|
|
24
|
+
the whole attempt, so a slow but progressing upload or download is no longer cut (and a whole-body download is no
|
|
25
|
+
longer retried from byte 0). The timer never runs while a results block is busy. JSON calls keep the hard bound.
|
|
26
|
+
- A transport you pass in is bounded by the client itself (a worker thread per attempt), so one that ignores
|
|
27
|
+
`request.timeout` cannot hang a call. On a timeout the worker gets an error raised (as on a network failure), so a
|
|
28
|
+
Net::HTTP-based transport closes the connection whose answer it did not read; a late answer never reaches the next
|
|
29
|
+
call.
|
|
30
|
+
- A whole file answered to a `range:` request is refused when its head arrives and the body is dropped unread (also
|
|
31
|
+
without a block).
|
|
32
|
+
- `filter: []` and `"all"` inside an Array are refused with `ArgumentError` before any request.
|
|
33
|
+
- A binary `file:` String is sent as is (no copy); an IO is read into one binary String.
|
|
34
|
+
- `timeout` refuses `Float::INFINITY` like `NaN`.
|
|
35
|
+
- Webhooks: signatures joined with `, ` by a proxy (and runs of spaces) verify.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- An answer cut short (the connection closed before its `Content-Length`) raises `ConnectionError`
|
|
40
|
+
`connection_error` instead of passing as complete: a truncated `bulk.results` body, `bulk.download` file or gzip
|
|
41
|
+
body no longer looks whole, and a cut JSON answer to a `GET` is retried instead of failing as `unexpected_response`
|
|
42
|
+
(the built-in transport sets Net::HTTP's `ignore_eof` to false).
|
|
43
|
+
- `unexpected_response` (an answer that is not JSON) no longer keeps the JSON parser's error as its `cause`, whose
|
|
44
|
+
message quoted the start of the body.
|
|
45
|
+
|
|
7
46
|
## [1.0.0] - 2026-11-04
|
|
8
47
|
|
|
9
48
|
First version, with the same behaviour as the JavaScript and Python SDKs (API v1).
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Official Ruby client for the [email-validation.org](https://email-validation.org) API: validate single addresses, read
|
|
4
4
|
credits, usage and service status, run bulk jobs and verify webhook signatures. Immutable result models, typed errors,
|
|
5
5
|
automatic retries for answers that are never charged, and no runtime dependencies (only the standard library:
|
|
6
|
-
`net/http`, `json`, `openssl`). Ruby 3.2 or later.
|
|
6
|
+
`net/http`, `json`, `openssl`). Needs Ruby 3.2 or later; tested on Ruby 4.0.
|
|
7
7
|
|
|
8
8
|
Documentation: https://email-validation.org/docs/sdks/ruby/
|
|
9
9
|
|
|
@@ -35,8 +35,9 @@ puts result.reason # => accepted
|
|
|
35
35
|
puts result.action # => send
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
Every result has one of five statuses: `valid`, `invalid`, `catch_all`, `unknown` or `disposable`; `reason` says why
|
|
39
|
+
and `action` what to do. Addresses at `sandbox.email-validation.org` are free and answer the reason in their local
|
|
40
|
+
part (`catch_all@…`, `no_mailbox@…`, `timeout@…`); `EmailValidation.sandbox_address("catch_all")` builds one.
|
|
40
41
|
|
|
41
42
|
Build the client once and share it: it is safe to use from several threads and keeps connections alive.
|
|
42
43
|
`client.close` releases them.
|
|
@@ -46,22 +47,36 @@ Build the client once and share it: it is safe to use from several threads and k
|
|
|
46
47
|
```ruby
|
|
47
48
|
client = EmailValidation::Client.new(
|
|
48
49
|
api_key: ENV.fetch("EV_API_KEY"), # required; never logged or shown by #inspect
|
|
49
|
-
base_url:
|
|
50
|
-
timeout: 30, # seconds per attempt
|
|
50
|
+
base_url: EmailValidation::EU_BASE_URL, # default EmailValidation::DEFAULT_BASE_URL
|
|
51
|
+
timeout: 30, # seconds per attempt, default 30 (see below)
|
|
51
52
|
max_retries: 2, # retries after the first attempt, default 2
|
|
52
53
|
max_retry_wait: 10 # longest single wait (seconds) the client accepts before giving up, default 10
|
|
53
54
|
)
|
|
55
|
+
|
|
56
|
+
client.credits(request_timeout: 5) # every method takes request_timeout: for that one call
|
|
54
57
|
```
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
- `api_key`: your key (`ev_live_…`). Keep it on the server.
|
|
60
|
+
- `base_url`: `https://api.email-validation.org` by default. EU data residency (validated and stored in the EU):
|
|
61
|
+
`EmailValidation::EU_BASE_URL` (`https://api-eu.email-validation.org`), see
|
|
62
|
+
https://email-validation.org/docs/eu-data-residency/.
|
|
63
|
+
- `timeout`: seconds per attempt. For JSON calls (validate, credits, usage, status, a job's state, start, cancel, an
|
|
64
|
+
upload of `emails:`) it bounds the whole attempt: connect, send and the whole answer. For transfers (a `file:`
|
|
65
|
+
upload, `bulk.results`, `bulk.download`) it is an idle timeout: the attempt fails only when no byte was sent or
|
|
66
|
+
received for that long, so a slow but progressing 100 MB file is never cut. Keep it a few seconds above the
|
|
67
|
+
`timeout:` you pass to `validate` (the time the service may spend, up to 15 s).
|
|
68
|
+
- `request_timeout:` (every method of `client` and `client.bulk`, also each poll of `bulk.wait`): replaces `timeout`
|
|
69
|
+
for that one call, with the same JSON and transfer rules.
|
|
70
|
+
- `max_retries`, `max_retry_wait`: see [Retries, timeouts and cancellation](#retries-timeouts-and-cancellation);
|
|
71
|
+
negative values count as 0.
|
|
72
|
+
- `transport:`: replaces the HTTP stack (see [Bring your own HTTP client](#bring-your-own-http-client)).
|
|
58
73
|
|
|
59
|
-
A missing key, a key containing whitespace, a `base_url` that is not `http://` or `https://`, or a
|
|
60
|
-
`
|
|
74
|
+
A missing key, a key containing whitespace, a `base_url` that is not `http://` or `https://`, or a `timeout` (or
|
|
75
|
+
`request_timeout`) that is not a finite number of seconds greater than 0 raises `ArgumentError` before any request.
|
|
61
76
|
|
|
62
|
-
##
|
|
77
|
+
## Validate an address
|
|
63
78
|
|
|
64
|
-
### `client.validate(email, ip_address: nil, timeout: nil, fresh: nil)` → `ValidationResult`
|
|
79
|
+
### `client.validate(email, ip_address: nil, timeout: nil, fresh: nil, request_timeout: nil)` → `ValidationResult`
|
|
65
80
|
|
|
66
81
|
Checks one address. Costs 1 credit, except `unknown` results and local answers (malformed and sandbox addresses),
|
|
67
82
|
which are free (`result.credits_used` is `0` or `1`).
|
|
@@ -87,36 +102,32 @@ puts result.did_you_mean if result.did_you_mean
|
|
|
87
102
|
`role_account`, `disposable`, `catch_all_domain`, `mx_found`, `mx_record`, `smtp_provider`, `domain_age_days`,
|
|
88
103
|
`processed_at`, `credits_used`, `request_id`.
|
|
89
104
|
|
|
90
|
-
|
|
105
|
+
Results are immutable `Data` objects with the API's field names. `to_h` returns the API's fields (nested models
|
|
106
|
+
converted, `nil` kept) and `to_json` re-encodes the API's JSON. Times are kept as the API's ISO 8601 UTC strings
|
|
107
|
+
(`Time.iso8601(result.processed_at)` parses one). Unknown fields are ignored and unknown values (a reason or provider
|
|
108
|
+
added after this SDK version) are kept as the raw string, so compare with the constants rather than assuming a closed
|
|
109
|
+
list; a status this version does not know in a job's `counts` is kept in `counts.extra` and re-encoded.
|
|
110
|
+
|
|
111
|
+
## Credits, usage and status
|
|
91
112
|
|
|
92
113
|
```ruby
|
|
93
114
|
credits = client.credits
|
|
94
115
|
puts credits.balance, credits.debt, credits.pending
|
|
95
116
|
credits.lots.each { |lot| puts "#{lot.remaining} expire at #{lot.expires_at}" } # soonest-expiring first
|
|
96
|
-
```
|
|
97
117
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Dates are `"YYYY-MM-DD"` strings (UTC) or `Date` objects. `group_by` is `"day"` (default) or `"key"`.
|
|
101
|
-
|
|
102
|
-
```ruby
|
|
103
|
-
usage = client.usage(from: "2026-10-01", to: "2026-10-20")
|
|
118
|
+
usage = client.usage(from: "2026-10-01", to: "2026-10-20") # "YYYY-MM-DD" strings (UTC) or Date objects
|
|
104
119
|
puts usage.totals.credits_used
|
|
105
|
-
usage.items.each { |day| puts "#{day.date}: #{day.credits_used}" }
|
|
120
|
+
usage.items.each { |day| puts "#{day.date}: #{day.credits_used}" } # UsageDay
|
|
106
121
|
|
|
107
122
|
by_key = client.usage(group_by: "key")
|
|
108
123
|
by_key.items.each { |key| puts "#{key.name || "(deleted key)"}: #{key.credits_used}" } # UsageByKey
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
### `client.status` → `ServiceStatus`
|
|
112
124
|
|
|
113
|
-
```ruby
|
|
114
125
|
status = client.status
|
|
115
126
|
puts status.status # operational, degraded or outage
|
|
116
127
|
puts status.components.api # also website, bulk, sign_in, payments, support
|
|
117
128
|
```
|
|
118
129
|
|
|
119
|
-
|
|
130
|
+
## Bulk jobs
|
|
120
131
|
|
|
121
132
|
A key with the `bulk` scope can validate lists of up to 100,000 addresses (JSON) or files up to 100 MB.
|
|
122
133
|
|
|
@@ -124,19 +135,25 @@ A key with the `bulk` scope can validate lists of up to 100,000 addresses (JSON)
|
|
|
124
135
|
# From a list (sent as JSON) …
|
|
125
136
|
job = client.bulk.create(emails: ["accepted@sandbox.email-validation.org", "no_mailbox@sandbox.email-validation.org"])
|
|
126
137
|
|
|
127
|
-
# … or from a CSV / plain-text file: a String, an IO or a Pathname.
|
|
138
|
+
# … or from a CSV / plain-text file: a String (the content), an IO (read once) or a Pathname.
|
|
128
139
|
job = client.bulk.create(file: Pathname.new("newsletter.csv"), column: "email", has_header: true)
|
|
129
140
|
job = client.bulk.create(file: File.open("list.txt", "rb"), content_type: "text/plain")
|
|
130
141
|
|
|
142
|
+
# A job created with start: false waits in "ready": call start before wait.
|
|
143
|
+
job = client.bulk.create(emails: ["accepted@sandbox.email-validation.org"], start: false)
|
|
144
|
+
client.bulk.start(job.id)
|
|
145
|
+
|
|
131
146
|
job = client.bulk.wait(job.id, interval: 5, timeout: 3600) do |progress|
|
|
132
147
|
puts "#{progress.status}: #{progress.processed}/#{progress.unique_emails || "?"}"
|
|
133
148
|
end
|
|
134
149
|
|
|
135
150
|
if job.status == EmailValidation::BulkJobStatus::COMPLETED
|
|
136
|
-
csv = client.bulk.results(job.id, filter: %w[valid catch_all])
|
|
137
|
-
File.open("
|
|
138
|
-
client.bulk.results(job.id) { |chunk| file.write(chunk) }
|
|
151
|
+
csv = client.bulk.results(job.id, filter: %w[valid catch_all]) # whole body as a String
|
|
152
|
+
File.open("valid.csv", "wb") do |file| # or streamed in chunks
|
|
153
|
+
client.bulk.results(job.id, filter: "valid") { |chunk| file.write(chunk) }
|
|
139
154
|
end
|
|
155
|
+
part = client.bulk.results(job.id, range: "bytes=0-1048575") # one part (answered 206)
|
|
156
|
+
bytes = client.bulk.download(job.id, "results.csv") # to a file; a second call resumes
|
|
140
157
|
end
|
|
141
158
|
```
|
|
142
159
|
|
|
@@ -147,20 +164,30 @@ end
|
|
|
147
164
|
| `bulk.start(job_id)` | `POST /v1/bulk/{id}/start` (credits are held now) | `BulkJob` |
|
|
148
165
|
| `bulk.cancel(job_id)` | `DELETE /v1/bulk/{id}` (unused credits return) | `BulkJob` |
|
|
149
166
|
| `bulk.results(job_id, filter: nil, columns: nil, escape_formulas: nil, range: nil) { \|chunk\| … }` | `GET /v1/bulk/{id}/results` | CSV `String`, or `nil` with a block |
|
|
167
|
+
| `bulk.download(job_id, path, resume: true, filter: nil, columns: nil, escape_formulas: nil)` | `GET /v1/bulk/{id}/results` into a file | bytes in the file |
|
|
150
168
|
| `bulk.wait(job_id, interval: 5, timeout: nil, on_progress: nil) { \|job\| … }` | polls `bulk.get` | `BulkJob` |
|
|
151
169
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
- `
|
|
155
|
-
|
|
156
|
-
`
|
|
170
|
+
Every method also takes `request_timeout:`.
|
|
171
|
+
|
|
172
|
+
- `create`: pass exactly one of `emails:` (an Array) or `file:`. A file is sent with its length (never chunked) and
|
|
173
|
+
held in memory once: a binary String is sent as is, an IO or a Pathname is read into one String, so a retry
|
|
174
|
+
resends the same bytes. `start: false` keeps the job in `ready` until you call `start`.
|
|
175
|
+
- `results`: `filter` is `"all"`, one status, or a non-empty Array of statuses (`"all"` cannot be combined; the
|
|
176
|
+
former names `spamtrap`, `abuse`, `do_not_mail` and `risky` are refused: use `%w[catch_all disposable]` for
|
|
177
|
+
`risky`); `columns` is `"original"` (default) or `"email_only"`; `escape_formulas` defaults to `true` on the
|
|
178
|
+
server. The CSV is returned unchanged (never parsed). With a block, chunks reach the block as they arrive, so a
|
|
179
|
+
large file never sits in memory. `range: "bytes=0-1048575"` fetches one part; only `filter: "all"` (or one status
|
|
157
180
|
with `escape_formulas: false`) with `columns: "original"` can be served in parts, and any other shape raises
|
|
158
|
-
`InvalidRequestError` instead of returning the whole file.
|
|
181
|
+
`InvalidRequestError` (status = the answer's) instead of returning the whole file, which is dropped unread.
|
|
182
|
+
- `download`: streams the CSV into `path` (a String or Pathname) and returns the file's length. With `resume: true`
|
|
183
|
+
(default) and a non-empty file there, only the missing end is fetched and appended; an already complete file is
|
|
184
|
+
left as it is. Resuming needs a shape served in parts (as for `range`), otherwise `ArgumentError`; a resume
|
|
185
|
+
answered with the whole file raises `InvalidRequestError` and leaves the file untouched. A failure while writing
|
|
186
|
+
leaves the partial file in place: call `download` again to resume. `resume: false` downloads it again.
|
|
159
187
|
- `wait` returns once the job is `completed`, `failed`, `cancelled` or `expired`, or once it is `ready` with an
|
|
160
188
|
`error_code` (its start was refused, e.g. `insufficient_credits`: buy credits, then call `start`). Check
|
|
161
|
-
`job.status` before downloading.
|
|
162
|
-
|
|
163
|
-
`ConnectionError` with code `timeout`.
|
|
189
|
+
`job.status` before downloading. `interval` is at least 1 second (default 5, also for `nil`); when `timeout`
|
|
190
|
+
(seconds) would pass before the next poll, `wait` raises `ConnectionError` with code `timeout`.
|
|
164
191
|
- A job id is checked locally before any request (`job_` followed by 22 letters and digits); a malformed one raises
|
|
165
192
|
`InvalidRequestError` with status `0`.
|
|
166
193
|
|
|
@@ -168,15 +195,7 @@ end
|
|
|
168
195
|
`local_answered`, `processed`, `credits_reserved`, `credits_charged`, `counts`, `error_code`, `webhook_endpoint_id`,
|
|
169
196
|
`created_at`, `started_at`, `finished_at`, `expires_at`; helpers `terminal?` and `stuck?`.
|
|
170
197
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
Results are immutable `Data` objects with the API's field names. `to_h` returns the API's fields (nested models
|
|
174
|
-
converted, `nil` kept) and `to_json` re-encodes the API's JSON. Times are kept as the API's ISO 8601 UTC strings
|
|
175
|
-
(`Time.iso8601(result.processed_at)` parses one). Unknown fields are ignored and unknown values (a reason or provider
|
|
176
|
-
added after this SDK version) are kept as the raw string, so compare with the constants rather than assuming a closed
|
|
177
|
-
list.
|
|
178
|
-
|
|
179
|
-
## Errors
|
|
198
|
+
## Handle errors
|
|
180
199
|
|
|
181
200
|
Every failure raises a subclass of `EmailValidation::Error` (a `StandardError`). Branch on the class or on `code`,
|
|
182
201
|
never on `message` (it is for people and may change).
|
|
@@ -196,7 +215,9 @@ end
|
|
|
196
215
|
```
|
|
197
216
|
|
|
198
217
|
Every error has `status` (HTTP status, `0` when no answer arrived or the SDK refused the call before sending),
|
|
199
|
-
`code`, `message`, `request_id` (quote it to support), `retry_after` (seconds or `nil`)
|
|
218
|
+
`code`, `message`, `request_id` (quote it to support), `retry_after` (seconds or `nil`), `headers` (the answer's
|
|
219
|
+
response headers, `{}` without an answer) and `cause`. An uncaught error shows its kind, status, code and request id,
|
|
220
|
+
never the key, the request body or the checked address.
|
|
200
221
|
|
|
201
222
|
| Class | When | Codes |
|
|
202
223
|
|---|---|---|
|
|
@@ -213,20 +234,27 @@ Every error has `status` (HTTP status, `0` when no answer arrived or the SDK ref
|
|
|
213
234
|
| `Error` (base) | a 2xx answer that is not JSON | `unexpected_response` |
|
|
214
235
|
|
|
215
236
|
An answer without our JSON error envelope (or with an unknown code) has code `unexpected_response`. Argument mistakes
|
|
216
|
-
(e.g. `emails:` and `file:` together, an unknown `filter`) raise `ArgumentError` before any
|
|
217
|
-
|
|
237
|
+
(e.g. `emails:` and `file:` together, an unknown `filter`, a bad `request_timeout`) raise `ArgumentError` before any
|
|
238
|
+
request.
|
|
218
239
|
|
|
219
|
-
## Retries and
|
|
240
|
+
## Retries, timeouts and cancellation
|
|
220
241
|
|
|
221
|
-
- `429` and `503` answers are never charged, so they are retried for every method, honouring `Retry-After
|
|
222
|
-
`max_retries` times, unless the wait would be longer than `max_retry_wait` seconds.
|
|
223
|
-
retried (it resets the next day).
|
|
242
|
+
- `429` and `503` answers are never charged, so they are retried for every method, honouring `Retry-After` (seconds
|
|
243
|
+
or an HTTP date), up to `max_retries` times, unless the wait would be longer than `max_retry_wait` seconds.
|
|
244
|
+
`key_daily_cap` is never retried (it resets the next day). Without `Retry-After` the wait is exponential with
|
|
245
|
+
jitter (0.25–0.5 s, 0.5–1 s, 1–2 s …, at most 8 s).
|
|
224
246
|
- Network failures and timeouts are retried only for `GET` requests. A validation, bulk upload, start or cancel that
|
|
225
|
-
reached the service may already have been charged or applied, so it is
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
-
|
|
229
|
-
|
|
247
|
+
reached the service may already have been charged or applied, so it is never sent again, neither by the SDK nor by
|
|
248
|
+
Net::HTTP (its own retries are off). A results download is not retried once a chunk has reached your block (call
|
|
249
|
+
`bulk.download` again to resume a file). An answer cut short (the connection closes before the announced
|
|
250
|
+
`Content-Length`) is a `ConnectionError` `connection_error`, never a complete result.
|
|
251
|
+
- Nothing else is retried (`500`, `502`, `504` and other errors are raised at once).
|
|
252
|
+
- `timeout` bounds each attempt, not the whole call: the whole attempt for JSON calls, an idle timeout for transfers
|
|
253
|
+
(uploads of a file, results and downloads), with no cap on a transfer that keeps moving. A results block may take
|
|
254
|
+
as long as it needs: the timer runs only while the SDK waits for data. `request_timeout:` changes the bound for
|
|
255
|
+
one call.
|
|
256
|
+
- Cancellation: Ruby has no standard cancellation for blocking calls, so this SDK has none (the `aborted` code is
|
|
257
|
+
not used). Bound a call with `request_timeout:` or `bulk.wait(timeout:)` instead.
|
|
230
258
|
- Redirects are never followed, so the `Authorization` header never travels to another host.
|
|
231
259
|
|
|
232
260
|
## Verify webhooks
|
|
@@ -247,7 +275,8 @@ when "webhook.test" then puts "Test from endpoint #{event.data.endpoint_id}"
|
|
|
247
275
|
end
|
|
248
276
|
```
|
|
249
277
|
|
|
250
|
-
- Headers: any Hash-like object with header names in any case
|
|
278
|
+
- Headers: any Hash-like object with header names in any case (String or Array values; several values are joined
|
|
279
|
+
with a space, and signatures a proxy joined with `, ` still verify), or a Rack env (`HTTP_WEBHOOK_ID` keys).
|
|
251
280
|
- Failures raise `EmailValidation::WebhookVerificationError` (missing headers, a timestamp outside `tolerance`
|
|
252
281
|
seconds (default 300), an invalid secret, no matching signature or a body that is not JSON). Answer `400` then.
|
|
253
282
|
- Deduplicate on the `webhook-id` header (retries reuse it) and answer `2xx` quickly; do slow work afterwards.
|
|
@@ -289,7 +318,8 @@ EmailValidation::WebhookEventType::CREDITS_LOW # "credits.low"
|
|
|
289
318
|
|
|
290
319
|
The client talks to a transport: any object with `call(request)` that returns an `EmailValidation::HttpResponse`, or
|
|
291
320
|
raises `EmailValidation::TransportError` (with `timeout: true` for a timeout) when no answer arrived. `request` has
|
|
292
|
-
`method`, `url`, `headers` (lower-case names), `body` (a binary String or `nil
|
|
321
|
+
`method`, `url`, `headers` (lower-case names), `body` (a binary String or `nil`; do not change it), `timeout` (seconds
|
|
322
|
+
per attempt) and `transfer?` (true for a file upload or a results download, where `timeout` is an idle timeout).
|
|
293
323
|
The default is `EmailValidation::NetHttpTransport` (Net::HTTP with a keep-alive pool; it honours the standard proxy
|
|
294
324
|
environment variables like Net::HTTP does).
|
|
295
325
|
|
|
@@ -320,8 +350,30 @@ with `(status, headers)` once the response head arrives; if it returns a callabl
|
|
|
320
350
|
return an `HttpResponse` with an empty body. A transport that ignores the block still works (the body is handed over
|
|
321
351
|
in one piece). `request.inspect` never shows the headers or the body.
|
|
322
352
|
|
|
353
|
+
The client bounds a transport you pass itself, even if it ignores `request.timeout`: it runs each attempt on a
|
|
354
|
+
short-lived worker thread (thread-local state of the calling thread is not visible there) and gives up with
|
|
355
|
+
`ConnectionError` `timeout` when the answer, or for a transfer the next chunk, does not arrive in time. It then
|
|
356
|
+
raises an error in the worker, as a network failure would (like `Timeout.timeout` does), so the transport's `rescue`
|
|
357
|
+
and `ensure` clauses run, and ignores whatever the transport does after that. The built-in transport needs no
|
|
358
|
+
worker.
|
|
359
|
+
|
|
360
|
+
Two rules keep a transport from returning wrong data or charging twice:
|
|
361
|
+
|
|
362
|
+
- Never reuse a connection whose answer was not read to the end (after an error or an interrupted read): close it.
|
|
363
|
+
Net::HTTP does this when an error interrupts a request; a pool or a raw socket you manage yourself must do the
|
|
364
|
+
same, or the next call reads the previous call's late answer, the result of another address.
|
|
365
|
+
- Never retry a request yourself, and turn off your HTTP library's own retries: the client's retry rules decide what
|
|
366
|
+
may be sent again. A validation, bulk upload, start or cancel that reached the service may already have been
|
|
367
|
+
charged or applied. `Net::HTTP` resends an idempotent request (a `DELETE`, which cancels a job) once by default:
|
|
368
|
+
set `max_retries = 0` on it, as the built-in transport does; `net-http-persistent` does the same unless its
|
|
369
|
+
`max_retries` is `0`.
|
|
370
|
+
|
|
323
371
|
## Privacy
|
|
324
372
|
|
|
325
373
|
The API key is sent only in the `Authorization` header. It never appears in a URL, in an error message, or in the
|
|
326
|
-
`inspect`/`to_s`/`pp` output of the client, its config or a request
|
|
327
|
-
has no telemetry.
|
|
374
|
+
`inspect`/`to_s`/`pp` output of the client, its config or a request; request bodies (the checked addresses) are not
|
|
375
|
+
shown either. The SDK writes nothing to stdout or stderr and has no telemetry.
|
|
376
|
+
|
|
377
|
+
## Licence
|
|
378
|
+
|
|
379
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -11,7 +11,9 @@ module EmailValidation
|
|
|
11
11
|
BAD_JOB_ID = "Not a bulk job id (expected job_ followed by 22 letters and digits)."
|
|
12
12
|
NOT_RANGEABLE = 'This download cannot be fetched in parts: use filter: "all" (or one status with ' \
|
|
13
13
|
'escape_formulas: false) and columns: "original" with a range.'
|
|
14
|
-
|
|
14
|
+
NOT_RESUMABLE = 'This download cannot be resumed: use filter: "all" (or one status with escape_formulas: false) ' \
|
|
15
|
+
'and columns: "original", or pass resume: false.'
|
|
16
|
+
private_constant :JOB_ID, :CONTENT_TYPES, :FILTERS, :BAD_JOB_ID, :NOT_RANGEABLE, :NOT_RESUMABLE
|
|
15
17
|
|
|
16
18
|
# Wraps an exception raised by the caller's chunk block, so a transport never mistakes it for a network error.
|
|
17
19
|
class SinkError < StandardError
|
|
@@ -24,6 +26,20 @@ module EmailValidation
|
|
|
24
26
|
end
|
|
25
27
|
private_constant :SinkError
|
|
26
28
|
|
|
29
|
+
# Raised when the head of a whole-file answer to a range request arrives, so the transport drops the connection
|
|
30
|
+
# without reading the body (it can be the whole 100 MB file). Outside StandardError, so neither a transport's
|
|
31
|
+
# +rescue => e+ nor a network-error rescue mistakes it for a failure to retry.
|
|
32
|
+
class RangeRefused < Exception # rubocop:disable Lint/InheritException
|
|
33
|
+
attr_reader :status, :request_id
|
|
34
|
+
|
|
35
|
+
def initialize(status, request_id)
|
|
36
|
+
super("range refused")
|
|
37
|
+
@status = status
|
|
38
|
+
@request_id = request_id
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
private_constant :RangeRefused
|
|
42
|
+
|
|
27
43
|
def initialize(client)
|
|
28
44
|
@client = client
|
|
29
45
|
end
|
|
@@ -34,9 +50,11 @@ module EmailValidation
|
|
|
34
50
|
# (up to 100 MB), with +content_type+ "text/csv" (default) or "text/plain".
|
|
35
51
|
#
|
|
36
52
|
# Options: +column+ (1–512 or a header name), +has_header+, +start+ (server default true; with +false+ the job
|
|
37
|
-
# waits in "ready" until #start), +webhook_endpoint_id+.
|
|
53
|
+
# waits in "ready" until #start), +webhook_endpoint_id+. Every Bulk method also takes +request_timeout:+ (seconds
|
|
54
|
+
# per attempt for that call, instead of the client's +timeout+). A file upload is a transfer: the timeout is an
|
|
55
|
+
# idle timeout, so a slow but progressing upload is never cut.
|
|
38
56
|
def create(emails: nil, file: nil, content_type: "text/csv", column: nil, has_header: nil, start: nil,
|
|
39
|
-
webhook_endpoint_id: nil)
|
|
57
|
+
webhook_endpoint_id: nil, request_timeout: nil)
|
|
40
58
|
raise ArgumentError, "pass exactly one of emails: or file:" if emails.nil? == file.nil?
|
|
41
59
|
raise ArgumentError, 'content_type must be "text/csv" or "text/plain"' unless
|
|
42
60
|
CONTENT_TYPES.include?(content_type)
|
|
@@ -47,25 +65,27 @@ module EmailValidation
|
|
|
47
65
|
raise ArgumentError, "emails must be an Array of addresses, not a String" if emails.is_a?(String)
|
|
48
66
|
raise ArgumentError, "emails must be an Array of addresses" unless emails.respond_to?(:to_a)
|
|
49
67
|
|
|
50
|
-
return @client.request_model(BulkJob, "POST", "/v1/bulk", query: query, json: { "emails" => emails.to_a }
|
|
68
|
+
return @client.request_model(BulkJob, "POST", "/v1/bulk", query: query, json: { "emails" => emails.to_a },
|
|
69
|
+
request_timeout: request_timeout)
|
|
51
70
|
end
|
|
52
71
|
@client.request_model(BulkJob, "POST", "/v1/bulk", query: query, body: read_file(file),
|
|
53
|
-
content_type: content_type
|
|
72
|
+
content_type: content_type, transfer: true,
|
|
73
|
+
request_timeout: request_timeout)
|
|
54
74
|
end
|
|
55
75
|
|
|
56
76
|
# The job's current state.
|
|
57
|
-
def get(job_id)
|
|
58
|
-
@client.request_model(BulkJob, "GET", job_path(job_id))
|
|
77
|
+
def get(job_id, request_timeout: nil)
|
|
78
|
+
@client.request_model(BulkJob, "GET", job_path(job_id), request_timeout: request_timeout)
|
|
59
79
|
end
|
|
60
80
|
|
|
61
81
|
# Starts a job waiting in "ready"; credits are held now.
|
|
62
|
-
def start(job_id)
|
|
63
|
-
@client.request_model(BulkJob, "POST", job_path(job_id, "/start"))
|
|
82
|
+
def start(job_id, request_timeout: nil)
|
|
83
|
+
@client.request_model(BulkJob, "POST", job_path(job_id, "/start"), request_timeout: request_timeout)
|
|
64
84
|
end
|
|
65
85
|
|
|
66
86
|
# Cancels a job; unused credits are returned.
|
|
67
|
-
def cancel(job_id)
|
|
68
|
-
@client.request_model(BulkJob, "DELETE", job_path(job_id))
|
|
87
|
+
def cancel(job_id, request_timeout: nil)
|
|
88
|
+
@client.request_model(BulkJob, "DELETE", job_path(job_id), request_timeout: request_timeout)
|
|
69
89
|
end
|
|
70
90
|
|
|
71
91
|
# Downloads the result CSV (UTF-8, returned unchanged; never parsed).
|
|
@@ -75,19 +95,42 @@ module EmailValidation
|
|
|
75
95
|
# - +escape_formulas+: server default true.
|
|
76
96
|
# - +range+: e.g. "bytes=0-1048575", to fetch one part of the file (resume a download). Only
|
|
77
97
|
# +filter: "all"+ (or one status with +escape_formulas: false+) and +columns: "original"+ can be served in parts;
|
|
78
|
-
# any other shape raises InvalidRequestError instead of returning the whole file
|
|
98
|
+
# any other shape raises InvalidRequestError (status = the answer's) instead of returning the whole file, and
|
|
99
|
+
# that body is dropped unread.
|
|
79
100
|
#
|
|
80
101
|
# Without a block it returns the whole body as a String. With a block it yields the body in chunks (binary
|
|
81
|
-
# Strings) as they arrive and returns nil, so a large file never sits in memory:
|
|
102
|
+
# Strings) as they arrive and returns nil, so a large file never sits in memory (#download writes it to a file):
|
|
82
103
|
#
|
|
83
104
|
# File.open("results.csv", "wb") { |f| client.bulk.results(job_id) { |chunk| f.write(chunk) } }
|
|
84
|
-
|
|
105
|
+
#
|
|
106
|
+
# A download is a transfer: the timeout is an idle timeout (each read may wait up to it, never while your block
|
|
107
|
+
# runs). It is retried after a network failure only while no chunk has reached you.
|
|
108
|
+
def results(job_id, filter: nil, columns: nil, escape_formulas: nil, range: nil, request_timeout: nil, &block)
|
|
85
109
|
query = { "filter" => results_filter(filter), "columns" => columns, "escape_formulas" => escape_formulas }
|
|
86
110
|
headers = range ? { "range" => range } : nil
|
|
87
111
|
path = job_path(job_id, "/results")
|
|
88
|
-
return
|
|
112
|
+
return stream_download(path, query, headers, range, block, request_timeout) if block
|
|
89
113
|
|
|
90
|
-
|
|
114
|
+
buffer = +"".b
|
|
115
|
+
body = stream_download(path, query, headers, range, ->(chunk) { buffer << chunk }, request_timeout)
|
|
116
|
+
(body ? body.dup : buffer).force_encoding(Encoding::UTF_8)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Streams the result CSV into the file at +path+ (a String or Pathname) and returns the file's length in bytes;
|
|
120
|
+
# the body never sits in memory. With +resume: true+ (default) and a non-empty file there, only the missing end
|
|
121
|
+
# is fetched (+Range: bytes=<size>-+) and appended; a file that is already complete is left as it is. Resuming
|
|
122
|
+
# needs a shape the API serves in parts (+filter: "all"+, or one status with +escape_formulas: false+, and
|
|
123
|
+
# +columns: "original"+), otherwise ArgumentError; a whole file answered to the range raises InvalidRequestError
|
|
124
|
+
# and leaves the file untouched. A failure while writing leaves the partial file in place: call again to resume.
|
|
125
|
+
def download(job_id, path, resume: true, filter: nil, columns: nil, escape_formulas: nil, request_timeout: nil)
|
|
126
|
+
job_path(job_id)
|
|
127
|
+
size = resume && File.file?(path) ? File.size(path) : 0
|
|
128
|
+
options = { filter: filter, columns: columns, escape_formulas: escape_formulas, request_timeout: request_timeout }
|
|
129
|
+
return write_fresh(job_id, path, options) if size.zero?
|
|
130
|
+
|
|
131
|
+
raise ArgumentError, NOT_RESUMABLE unless resumable?(filter, columns, escape_formulas)
|
|
132
|
+
|
|
133
|
+
append_rest(job_id, path, size, options)
|
|
91
134
|
end
|
|
92
135
|
|
|
93
136
|
# Polls a job until it is completed, failed, cancelled or expired, or until it is "ready" with an +error_code+
|
|
@@ -99,13 +142,15 @@ module EmailValidation
|
|
|
99
142
|
# - +on_progress+ (or a block): called with every polled BulkJob.
|
|
100
143
|
#
|
|
101
144
|
# A job created with +start: false+ waits in "ready" until you call #start, so call #start before #wait.
|
|
102
|
-
|
|
145
|
+
# - +request_timeout+: seconds per attempt of each poll, instead of the client's +timeout+.
|
|
146
|
+
def wait(job_id, interval: 5, timeout: nil, on_progress: nil, request_timeout: nil, &block)
|
|
103
147
|
job_path(job_id)
|
|
148
|
+
Config.check_timeout(request_timeout, "request_timeout") unless request_timeout.nil?
|
|
104
149
|
progress = on_progress || block
|
|
105
150
|
interval = [(interval || 5).to_f, 1.0].max
|
|
106
151
|
deadline = timeout.nil? ? nil : monotonic + timeout
|
|
107
152
|
loop do
|
|
108
|
-
job = get(job_id)
|
|
153
|
+
job = get(job_id, request_timeout: request_timeout)
|
|
109
154
|
progress&.call(job)
|
|
110
155
|
return job if job.terminal? || job.stuck?
|
|
111
156
|
if deadline && monotonic + interval > deadline
|
|
@@ -130,16 +175,27 @@ module EmailValidation
|
|
|
130
175
|
"/v1/bulk/#{job_id}#{suffix}"
|
|
131
176
|
end
|
|
132
177
|
|
|
178
|
+
# "all", one status, or a non-empty Array of statuses (no "all" in it). Retired names (spamtrap, abuse,
|
|
179
|
+
# do_not_mail, risky) are refused like any other unknown value.
|
|
133
180
|
def results_filter(value)
|
|
134
181
|
return nil if value.nil?
|
|
135
182
|
|
|
136
|
-
|
|
183
|
+
if value.is_a?(Array)
|
|
184
|
+
raise ArgumentError, "filter must not be an empty Array (omit it, or use \"all\")" if value.empty?
|
|
185
|
+
|
|
186
|
+
parts = value.map(&:to_s)
|
|
187
|
+
raise ArgumentError, 'filter: "all" cannot be combined with statuses' if parts.include?("all")
|
|
188
|
+
else
|
|
189
|
+
parts = [value.to_s]
|
|
190
|
+
end
|
|
137
191
|
parts.each do |part|
|
|
138
192
|
raise ArgumentError, "unknown filter: #{part.inspect} (use \"all\" or a status)" unless FILTERS.include?(part)
|
|
139
193
|
end
|
|
140
194
|
parts.join(",")
|
|
141
195
|
end
|
|
142
196
|
|
|
197
|
+
# Returns the upload as a String held once: a binary String goes out as is (never copied), a Pathname or an IO
|
|
198
|
+
# is read into one binary String.
|
|
143
199
|
def read_file(file)
|
|
144
200
|
case file
|
|
145
201
|
when String then file
|
|
@@ -150,27 +206,61 @@ module EmailValidation
|
|
|
150
206
|
data = file.read
|
|
151
207
|
raise ArgumentError, "file returned no data" if data.nil?
|
|
152
208
|
|
|
153
|
-
|
|
209
|
+
# A real IO hands back a fresh String: relabel it binary instead of copying it.
|
|
210
|
+
file.is_a?(IO) && !data.frozen? ? data.force_encoding(Encoding::BINARY) : data
|
|
154
211
|
end
|
|
155
212
|
end
|
|
156
213
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
refuse_whole_file(response.status, response.header("x-request-id")) if range && response.status != 206
|
|
160
|
-
response.body.dup.force_encoding(Encoding::UTF_8)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def stream_download(path, query, headers, range, block)
|
|
214
|
+
# Streams the body into +block+ and returns nil, or the whole body when the transport could not stream it.
|
|
215
|
+
def stream_download(path, query, headers, range, block, request_timeout)
|
|
164
216
|
stream = Stream.new(range, block)
|
|
165
|
-
response = @client.perform("GET", path, query: query, accept: "text/csv", headers: headers, stream: stream
|
|
217
|
+
response = @client.perform("GET", path, query: query, accept: "text/csv", headers: headers, stream: stream,
|
|
218
|
+
transfer: true, request_timeout: request_timeout)
|
|
166
219
|
refuse_whole_file(response.status, response.header("x-request-id")) if range && response.status != 206
|
|
220
|
+
return nil if stream.head_seen? || response.body.empty?
|
|
221
|
+
|
|
167
222
|
# A transport without streaming support buffered the body: hand it over in one piece.
|
|
168
|
-
block.call(response.body)
|
|
223
|
+
block.call(response.body)
|
|
169
224
|
nil
|
|
225
|
+
rescue RangeRefused => e
|
|
226
|
+
refuse_whole_file(e.status, e.request_id)
|
|
170
227
|
rescue SinkError => e
|
|
171
228
|
raise e.original, cause: e.original.cause
|
|
172
229
|
end
|
|
173
230
|
|
|
231
|
+
def resumable?(filter, columns, escape_formulas)
|
|
232
|
+
return false unless columns.nil? || columns.to_s == "original"
|
|
233
|
+
|
|
234
|
+
parts = filter.is_a?(Array) ? filter.map(&:to_s) : [filter&.to_s].compact
|
|
235
|
+
return true if parts.empty? || parts == ["all"]
|
|
236
|
+
|
|
237
|
+
parts.size == 1 && STATUSES.include?(parts.first) && escape_formulas == false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The file is created (or truncated) only when the first chunk arrives, so a refused call leaves it as it was.
|
|
241
|
+
def write_fresh(job_id, path, options)
|
|
242
|
+
file = nil
|
|
243
|
+
begin
|
|
244
|
+
results(job_id, **options) { |chunk| (file ||= File.open(path, "wb")).write(chunk) }
|
|
245
|
+
ensure
|
|
246
|
+
file&.close
|
|
247
|
+
end
|
|
248
|
+
File.open(path, "wb").close if file.nil? # an empty result file
|
|
249
|
+
File.size(path)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def append_rest(job_id, path, size, options)
|
|
253
|
+
appended = 0
|
|
254
|
+
File.open(path, "ab") do |file|
|
|
255
|
+
results(job_id, **options, range: "bytes=#{size}-") { |chunk| appended += file.write(chunk) }
|
|
256
|
+
end
|
|
257
|
+
size + appended
|
|
258
|
+
rescue InvalidRequestError => e
|
|
259
|
+
raise unless e.status == 416 && e.headers["content-range"] == "bytes */#{size}"
|
|
260
|
+
|
|
261
|
+
size # already complete
|
|
262
|
+
end
|
|
263
|
+
|
|
174
264
|
def refuse_whole_file(status, request_id)
|
|
175
265
|
raise InvalidRequestError.new(NOT_RANGEABLE, status: status, code: "invalid_request", request_id: request_id)
|
|
176
266
|
end
|
|
@@ -189,11 +279,11 @@ module EmailValidation
|
|
|
189
279
|
@started = false
|
|
190
280
|
end
|
|
191
281
|
|
|
192
|
-
def on_head(status,
|
|
282
|
+
def on_head(status, headers)
|
|
193
283
|
return nil unless (200..299).cover?(status)
|
|
194
284
|
|
|
195
285
|
@head_seen = true
|
|
196
|
-
|
|
286
|
+
raise RangeRefused.new(status, request_id(headers)) if @range && status != 206
|
|
197
287
|
|
|
198
288
|
lambda do |chunk|
|
|
199
289
|
@started = true
|
|
@@ -209,6 +299,11 @@ module EmailValidation
|
|
|
209
299
|
@head_seen
|
|
210
300
|
end
|
|
211
301
|
|
|
302
|
+
def request_id(headers)
|
|
303
|
+
headers.each { |name, value| return value.to_s if name.to_s.casecmp?("x-request-id") }
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
|
|
212
307
|
def started?
|
|
213
308
|
@started
|
|
214
309
|
end
|
|
@@ -18,8 +18,8 @@ module EmailValidation
|
|
|
18
18
|
|
|
19
19
|
url = (base_url || DEFAULT_BASE_URL).to_s
|
|
20
20
|
raise ArgumentError, "base_url must start with http:// or https://" unless url.start_with?("https://", "http://")
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
|
|
22
|
+
Config.check_timeout(timeout, "timeout")
|
|
23
23
|
|
|
24
24
|
@api_key = api_key.dup.freeze
|
|
25
25
|
@base_url = url.sub(%r{/+\z}, "").freeze
|
|
@@ -29,6 +29,14 @@ module EmailValidation
|
|
|
29
29
|
freeze
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# @api private
|
|
33
|
+
# Raises ArgumentError unless +value+ is a finite number of seconds greater than 0.
|
|
34
|
+
def self.check_timeout(value, name)
|
|
35
|
+
return value if value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "#{name} must be a finite number of seconds greater than 0"
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
# @api private
|
|
33
41
|
def authorization
|
|
34
42
|
"Bearer #{@api_key}"
|
|
@@ -84,11 +92,16 @@ module EmailValidation
|
|
|
84
92
|
# Options:
|
|
85
93
|
# - +api_key+ (required): your key (+ev_live_…+). Keep it on the server.
|
|
86
94
|
# - +base_url+: default +https://api.email-validation.org+.
|
|
87
|
-
# - +timeout+: seconds per attempt
|
|
88
|
-
#
|
|
95
|
+
# - +timeout+: seconds per attempt, default 30. For JSON calls it bounds the whole attempt (connect and the whole
|
|
96
|
+
# response); for transfers (a file upload, a results download) it is an idle timeout: the longest time without
|
|
97
|
+
# a byte sent or received, with no cap on the whole transfer. A check may take up to 15 s on our side, so keep
|
|
98
|
+
# it a few seconds above the +timeout+ you pass to #validate. Every method also takes +request_timeout:+, which
|
|
99
|
+
# replaces it for that one call.
|
|
89
100
|
# - +max_retries+: retries after the first attempt, default 2 (negative values count as 0).
|
|
90
101
|
# - +max_retry_wait+: the longest single wait in seconds the client accepts before giving up, default 10.
|
|
91
102
|
# - +transport+: replaces the HTTP stack (see NetHttpTransport); a transport you pass is never closed by #close.
|
|
103
|
+
# It runs on a short-lived worker thread per attempt, so the client bounds it even if it ignores
|
|
104
|
+
# +request.timeout+ (thread-local state of the calling thread is not visible there).
|
|
92
105
|
# - +sleep+, +clock+: injectable for tests (+sleep.call(seconds)+; +clock.call+ returns the current Time).
|
|
93
106
|
def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 2, max_retry_wait: 10, transport: nil,
|
|
94
107
|
sleep: nil, clock: nil)
|
|
@@ -96,6 +109,8 @@ module EmailValidation
|
|
|
96
109
|
max_retry_wait: max_retry_wait)
|
|
97
110
|
@owns_transport = transport.nil?
|
|
98
111
|
@transport = transport || NetHttpTransport.new
|
|
112
|
+
# The built-in transport bounds its own attempts; any other one runs under the SDK's guard (TransportGuard).
|
|
113
|
+
@guarded = !@transport.instance_of?(NetHttpTransport)
|
|
99
114
|
@sleep = sleep || ->(seconds) { Kernel.sleep(seconds) }
|
|
100
115
|
@clock = clock || -> { Time.now }
|
|
101
116
|
@bulk = Bulk.new(self)
|
|
@@ -110,29 +125,32 @@ module EmailValidation
|
|
|
110
125
|
# - +fresh+: +true+ checks the address again even when it was checked in the last 12 months (by default the
|
|
111
126
|
# earlier definite result may be returned; +processed_at+ is when the answer was produced).
|
|
112
127
|
#
|
|
128
|
+
# - +request_timeout+: seconds per attempt for this call only, instead of the client's +timeout+ (every method
|
|
129
|
+
# takes it).
|
|
130
|
+
#
|
|
113
131
|
# A validation is never retried after a network failure or timeout (it may already have been charged).
|
|
114
|
-
def validate(email, ip_address: nil, timeout: nil, fresh: nil)
|
|
132
|
+
def validate(email, ip_address: nil, timeout: nil, fresh: nil, request_timeout: nil)
|
|
115
133
|
raise ArgumentError, "email must be a String" unless email.is_a?(String)
|
|
116
134
|
|
|
117
135
|
body = { "email" => email, "ip_address" => ip_address, "timeout" => timeout, "fresh" => fresh }.compact
|
|
118
|
-
request_model(ValidationResult, "POST", "/v1/validate", json: body)
|
|
136
|
+
request_model(ValidationResult, "POST", "/v1/validate", json: body, request_timeout: request_timeout)
|
|
119
137
|
end
|
|
120
138
|
|
|
121
139
|
# Balance, debt, pending credits and credit lots (soonest-expiring first).
|
|
122
|
-
def credits
|
|
123
|
-
request_model(Credits, "GET", "/v1/credits")
|
|
140
|
+
def credits(request_timeout: nil)
|
|
141
|
+
request_model(Credits, "GET", "/v1/credits", request_timeout: request_timeout)
|
|
124
142
|
end
|
|
125
143
|
|
|
126
144
|
# Credit usage by day (default) or by API key (+group_by: "key"+). Dates are +"YYYY-MM-DD"+ strings (UTC) or
|
|
127
145
|
# Date objects.
|
|
128
|
-
def usage(from: nil, to: nil, group_by: nil)
|
|
146
|
+
def usage(from: nil, to: nil, group_by: nil, request_timeout: nil)
|
|
129
147
|
query = { "from" => from, "to" => to, "group_by" => group_by }
|
|
130
|
-
request_model(UsageResponse, "GET", "/v1/usage", query: query)
|
|
148
|
+
request_model(UsageResponse, "GET", "/v1/usage", query: query, request_timeout: request_timeout)
|
|
131
149
|
end
|
|
132
150
|
|
|
133
151
|
# The public service status (overall and per component).
|
|
134
|
-
def status
|
|
135
|
-
request_model(ServiceStatus, "GET", "/v1/status")
|
|
152
|
+
def status(request_timeout: nil)
|
|
153
|
+
request_model(ServiceStatus, "GET", "/v1/status", request_timeout: request_timeout)
|
|
136
154
|
end
|
|
137
155
|
|
|
138
156
|
# Releases the HTTP connections of the built-in transport. A transport you passed in is left open.
|
|
@@ -164,14 +182,15 @@ module EmailValidation
|
|
|
164
182
|
# @api private
|
|
165
183
|
# Sends one call with the retry policy and returns the 2xx HttpResponse. +stream+, when given, is an object with
|
|
166
184
|
# +on_head(status, headers)+ (passed to the transport as its block) and +started?+ (true once a chunk reached the
|
|
167
|
-
# caller, after which nothing is retried).
|
|
185
|
+
# caller, after which nothing is retried). +transfer+ marks a file upload or a results download (idle timeout).
|
|
168
186
|
def perform(method, path, query: nil, json: nil, body: nil, content_type: nil, accept: JSON_TYPE, headers: nil,
|
|
169
|
-
stream: nil)
|
|
170
|
-
|
|
187
|
+
stream: nil, transfer: false, request_timeout: nil)
|
|
188
|
+
timeout = request_timeout.nil? ? config.timeout : Config.check_timeout(request_timeout, "request_timeout")
|
|
189
|
+
request = build_request(method, path, query, json, body, content_type, accept, headers, timeout, transfer)
|
|
171
190
|
attempt = 0
|
|
172
191
|
loop do
|
|
173
192
|
begin
|
|
174
|
-
response =
|
|
193
|
+
response = send_attempt(request, stream)
|
|
175
194
|
rescue TransportError => e
|
|
176
195
|
if method == "GET" && attempt < config.max_retries && !stream&.started?
|
|
177
196
|
sleep_for(backoff(attempt))
|
|
@@ -208,20 +227,27 @@ module EmailValidation
|
|
|
208
227
|
|
|
209
228
|
private
|
|
210
229
|
|
|
211
|
-
def
|
|
230
|
+
def send_attempt(request, stream)
|
|
231
|
+
return TransportGuard.call(@transport, request, stream) if @guarded
|
|
232
|
+
return @transport.call(request) { |status, headers| stream.on_head(status, headers) } if stream
|
|
233
|
+
|
|
234
|
+
@transport.call(request)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def build_request(method, path, query, json, body, content_type, accept, extra_headers, timeout, transfer)
|
|
212
238
|
url = config.base_url + path
|
|
213
239
|
encoded = encode_query(query)
|
|
214
240
|
url = "#{url}?#{encoded}" unless encoded.empty?
|
|
215
241
|
headers = { "authorization" => config.authorization, "accept" => accept, "user-agent" => USER_AGENT }
|
|
216
242
|
if json
|
|
217
|
-
body = JSON.generate(json).b
|
|
243
|
+
body = JSON.generate(json).b.freeze
|
|
218
244
|
headers["content-type"] = JSON_TYPE
|
|
219
245
|
elsif body
|
|
220
|
-
body = body.b
|
|
246
|
+
body = body.b.freeze unless body.encoding == Encoding::BINARY # a binary String is sent as is, never copied
|
|
221
247
|
headers["content-type"] = content_type if content_type
|
|
222
248
|
end
|
|
223
249
|
extra_headers&.each { |name, value| headers[name.to_s.downcase] = value.to_s }
|
|
224
|
-
HttpRequest.new(method: method, url: url, headers: headers, body: body, timeout:
|
|
250
|
+
HttpRequest.new(method: method, url: url, headers: headers, body: body, timeout: timeout, transfer: transfer)
|
|
225
251
|
end
|
|
226
252
|
|
|
227
253
|
def encode_query(query)
|
|
@@ -241,9 +267,11 @@ module EmailValidation
|
|
|
241
267
|
|
|
242
268
|
parsed
|
|
243
269
|
rescue JSON::ParserError, EncodingError
|
|
270
|
+
# no cause: the parser's message quotes the body, which can hold a checked address or result rows
|
|
244
271
|
raise Error.new("The API answered with something other than JSON.", status: response.status,
|
|
245
272
|
code: "unexpected_response",
|
|
246
|
-
request_id: response.header("x-request-id"))
|
|
273
|
+
request_id: response.header("x-request-id")),
|
|
274
|
+
cause: nil
|
|
247
275
|
end
|
|
248
276
|
|
|
249
277
|
def error_from(response)
|
|
@@ -264,7 +292,8 @@ module EmailValidation
|
|
|
264
292
|
code = "unexpected_response"
|
|
265
293
|
message = "Unexpected HTTP #{response.status} from the API."
|
|
266
294
|
end
|
|
267
|
-
klass.new(message, status: response.status, code: code, request_id: request_id, retry_after: retry_after
|
|
295
|
+
klass.new(message, status: response.status, code: code, request_id: request_id, retry_after: retry_after,
|
|
296
|
+
headers: response.headers)
|
|
268
297
|
end
|
|
269
298
|
|
|
270
299
|
def retry_wait(error, attempt)
|
|
@@ -11,16 +11,19 @@ module EmailValidation
|
|
|
11
11
|
# +timeout+, +unexpected_response+, +aborted+).
|
|
12
12
|
# - +request_id+: our id for the request (quote it to support), when the API answered.
|
|
13
13
|
# - +retry_after+: whole seconds from the +Retry-After+ header, or nil.
|
|
14
|
+
# - +headers+: the answer's response headers (lower-case names), +{}+ when no answer arrived.
|
|
14
15
|
# - +cause+: the underlying exception, when there is one (Ruby sets it automatically).
|
|
15
16
|
class Error < StandardError
|
|
16
|
-
attr_reader :status, :code, :request_id, :retry_after
|
|
17
|
+
attr_reader :status, :code, :request_id, :retry_after, :headers
|
|
17
18
|
|
|
18
|
-
def initialize(message = nil, status: 0, code: "unexpected_response", request_id: nil, retry_after: nil
|
|
19
|
+
def initialize(message = nil, status: 0, code: "unexpected_response", request_id: nil, retry_after: nil,
|
|
20
|
+
headers: {})
|
|
19
21
|
super(message || code)
|
|
20
22
|
@status = status
|
|
21
23
|
@code = code
|
|
22
24
|
@request_id = request_id
|
|
23
25
|
@retry_after = retry_after
|
|
26
|
+
@headers = headers.frozen? ? headers : headers.dup.freeze
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
# The canonical kind name, e.g. "RateLimitError" (the class name without the module).
|
|
@@ -53,13 +53,55 @@ module EmailValidation
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# Counts by result status. In bulk jobs and webhook events the object is partial (only the statuses seen so far),
|
|
56
|
-
# so absent statuses are nil and are left out again by #to_h.
|
|
56
|
+
# so absent statuses are nil and are left out again by #to_h. A status this SDK version does not know yet is kept
|
|
57
|
+
# in #extra (a Hash of the API's key to its count) and re-encoded by #to_h and #to_json. +extra+ is not a Data
|
|
58
|
+
# member, so +members+, +to_a+ and array patterns (+in [valid, invalid, catch_all, unknown, disposable]+) are the
|
|
59
|
+
# five statuses, as in 1.0.0.
|
|
57
60
|
StatusCounts = Data.define(:valid, :invalid, :catch_all, :unknown, :disposable) do
|
|
58
61
|
include Model
|
|
59
62
|
|
|
63
|
+
def self.from_h(hash)
|
|
64
|
+
raise ArgumentError, "#{name} expects a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
values = members.to_h { |m| [m, hash.key?(m.to_s) ? hash[m.to_s] : hash[m]] }
|
|
67
|
+
extra = hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v unless members.include?(k.to_sym) }
|
|
68
|
+
new(**values, extra: extra)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Statuses this SDK version does not know: the API's key (a String) to its count. Empty when there are none.
|
|
72
|
+
attr_reader :extra
|
|
73
|
+
|
|
74
|
+
def initialize(valid: nil, invalid: nil, catch_all: nil, unknown: nil, disposable: nil, extra: {})
|
|
75
|
+
@extra = extra.to_h { |k, v| [k.to_s.freeze, v] }.freeze
|
|
76
|
+
super(valid: valid, invalid: invalid, catch_all: catch_all, unknown: unknown, disposable: disposable)
|
|
77
|
+
end
|
|
78
|
+
|
|
60
79
|
def to_h
|
|
61
|
-
super.compact
|
|
80
|
+
super.compact.merge(extra.transform_keys(&:to_sym))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def with(**changes)
|
|
84
|
+
return self if changes.empty?
|
|
85
|
+
|
|
86
|
+
self.class.new(**members.to_h { |m| [m, public_send(m)] }, extra: extra, **changes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ==(other)
|
|
90
|
+
super && extra == other.extra
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def eql?(other)
|
|
94
|
+
super && extra.eql?(other.extra)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def hash
|
|
98
|
+
[super, extra].hash
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def inspect
|
|
102
|
+
extra.empty? ? super : super.sub(/>\z/, ", extra=#{extra.inspect}>")
|
|
62
103
|
end
|
|
104
|
+
alias_method :to_s, :inspect
|
|
63
105
|
end
|
|
64
106
|
|
|
65
107
|
# The result of one check (+POST /v1/validate+). Fields in the API's order.
|
|
@@ -12,16 +12,25 @@ module EmailValidation
|
|
|
12
12
|
class HttpRequest
|
|
13
13
|
attr_reader :method, :url, :headers, :body, :timeout
|
|
14
14
|
|
|
15
|
-
# +headers+ is a Hash with lower-case names; +body+ is a binary String or nil
|
|
16
|
-
|
|
15
|
+
# +headers+ is a Hash with lower-case names; +body+ is a binary String or nil (a caller's binary String is passed
|
|
16
|
+
# as is, never copied: do not change it); +timeout+ is seconds per attempt; +transfer+ marks a file upload or a
|
|
17
|
+
# results download, for which +timeout+ is an idle timeout instead of a bound on the whole attempt.
|
|
18
|
+
def initialize(method:, url:, headers:, body:, timeout:, transfer: false)
|
|
17
19
|
@method = method
|
|
18
20
|
@url = url
|
|
19
21
|
@headers = headers.freeze
|
|
20
|
-
@body = body
|
|
22
|
+
@body = body
|
|
21
23
|
@timeout = timeout
|
|
24
|
+
@transfer = transfer ? true : false
|
|
22
25
|
freeze
|
|
23
26
|
end
|
|
24
27
|
|
|
28
|
+
# True for a file upload or a results download: +timeout+ is then the longest time without a byte sent or
|
|
29
|
+
# received, with no cap on the whole transfer.
|
|
30
|
+
def transfer?
|
|
31
|
+
@transfer
|
|
32
|
+
end
|
|
33
|
+
|
|
25
34
|
def inspect
|
|
26
35
|
"#<#{self.class.name} #{method} #{url} headers=[hidden] body=[hidden]>"
|
|
27
36
|
end
|
|
@@ -67,8 +76,11 @@ module EmailValidation
|
|
|
67
76
|
end
|
|
68
77
|
|
|
69
78
|
# The default transport, on the standard library's Net::HTTP. Redirects are never followed (Net::HTTP does not
|
|
70
|
-
# follow them), connections are kept alive and reused across threads through a small pool, and
|
|
71
|
-
# +timeout+ bounds every attempt
|
|
79
|
+
# follow them), connections are kept alive and reused across threads through a small pool, and Net::HTTP never
|
|
80
|
+
# resends a request by itself (+max_retries = 0+). The client's +timeout+ bounds every attempt of a JSON call
|
|
81
|
+
# (connect, TLS, request and the whole response); for a transfer (HttpRequest#transfer?) it is an idle timeout:
|
|
82
|
+
# connecting, each write of the upload and each read of the answer may take up to +timeout+, with no cap on the
|
|
83
|
+
# whole transfer, so a slow but progressing 100 MB upload or download is never cut.
|
|
72
84
|
#
|
|
73
85
|
# A transport is any object with +call(request)+ returning an HttpResponse (raise TransportError when no answer
|
|
74
86
|
# arrives). Optional streaming: when +call+ is given a block, the transport calls it once with
|
|
@@ -80,7 +92,13 @@ module EmailValidation
|
|
|
80
92
|
Net::HTTPBadResponse, Zlib::Error
|
|
81
93
|
].freeze
|
|
82
94
|
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error].freeze
|
|
83
|
-
|
|
95
|
+
# The kernel buffers megabytes of an upload: a write can wait long for room while those bytes still move, and
|
|
96
|
+
# after the last write they drain while Net::HTTP waits for the response head. Neither wait is seen as
|
|
97
|
+
# progress, so each gets +timeout + min(size, 4 MiB) / 128 KiB/s+; the body reads after the head
|
|
98
|
+
# are idle-bounded by +timeout+ again.
|
|
99
|
+
UPLOAD_RATE = 128 * 1024
|
|
100
|
+
DRAIN_CAP = 4 * 1024 * 1024
|
|
101
|
+
private_constant :NETWORK_ERRORS, :TIMEOUT_ERRORS, :UPLOAD_RATE, :DRAIN_CAP
|
|
84
102
|
|
|
85
103
|
# Raised by the attempt deadline. A private class outside StandardError, so neither a timeout raised by other
|
|
86
104
|
# code nor a +rescue => e+ in a results block can be confused with it or swallow it.
|
|
@@ -101,10 +119,16 @@ module EmailValidation
|
|
|
101
119
|
conn = nil
|
|
102
120
|
reusable = false
|
|
103
121
|
begin
|
|
104
|
-
response =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
122
|
+
response = if request.transfer?
|
|
123
|
+
conn = checkout(uri, request.timeout)
|
|
124
|
+
allow_drain(conn, request) if request.body
|
|
125
|
+
exchange(conn, uri, request, on_head)
|
|
126
|
+
else
|
|
127
|
+
Timeout.timeout(request.timeout, AttemptTimeout) do
|
|
128
|
+
conn = checkout(uri, request.timeout)
|
|
129
|
+
exchange(conn, uri, request, on_head)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
108
132
|
reusable = true
|
|
109
133
|
response
|
|
110
134
|
rescue AttemptTimeout, *TIMEOUT_ERRORS
|
|
@@ -140,6 +164,7 @@ module EmailValidation
|
|
|
140
164
|
headers = nil
|
|
141
165
|
body = nil
|
|
142
166
|
conn.request(req) do |res|
|
|
167
|
+
conn.read_timeout = request.timeout # the head is in: each body read is idle-bounded by timeout again
|
|
143
168
|
status = res.code.to_i
|
|
144
169
|
headers = {}
|
|
145
170
|
res.each_header { |name, value| headers[name] = value }
|
|
@@ -154,6 +179,12 @@ module EmailValidation
|
|
|
154
179
|
HttpResponse.new(status: status, headers: headers, body: body)
|
|
155
180
|
end
|
|
156
181
|
|
|
182
|
+
def allow_drain(conn, request)
|
|
183
|
+
wait = request.timeout + [request.body.bytesize, DRAIN_CAP].min.fdiv(UPLOAD_RATE)
|
|
184
|
+
conn.write_timeout = wait
|
|
185
|
+
conn.read_timeout = wait
|
|
186
|
+
end
|
|
187
|
+
|
|
157
188
|
def build_request(uri, request)
|
|
158
189
|
has_body = !request.body.nil?
|
|
159
190
|
# Net::HTTPGenericRequest lets a body-less POST go out without a body or a default Content-Type.
|
|
@@ -170,6 +201,9 @@ module EmailValidation
|
|
|
170
201
|
conn = Net::HTTP.new(uri.host, uri.port)
|
|
171
202
|
conn.use_ssl = uri.scheme == "https"
|
|
172
203
|
conn.max_retries = 0 # the client owns the retry policy (a POST that reached us may have been charged)
|
|
204
|
+
# Net::HTTP's default (true) ends a body cut short by a closed connection as if it were complete: a truncated
|
|
205
|
+
# result file or JSON answer would pass as whole. Off, a short body raises EOFError (a connection_error).
|
|
206
|
+
conn.ignore_eof = false
|
|
173
207
|
conn.keep_alive_timeout = @keep_alive_timeout
|
|
174
208
|
configure(conn, timeout)
|
|
175
209
|
conn.start
|
|
@@ -206,3 +240,92 @@ module EmailValidation
|
|
|
206
240
|
end
|
|
207
241
|
end
|
|
208
242
|
end
|
|
243
|
+
|
|
244
|
+
module EmailValidation
|
|
245
|
+
# @api private
|
|
246
|
+
# Runs a caller-supplied transport on a short-lived worker thread, so the SDK itself bounds every attempt even when
|
|
247
|
+
# the transport ignores +request.timeout+. A JSON call gets a hard deadline of +timeout+. A
|
|
248
|
+
# transfer gets +timeout+ plus the time to upload its body at 128 KiB/s until the response head (the SDK cannot see
|
|
249
|
+
# a caller's upload progress), then +timeout+ for each body chunk. The bound is pull-based: it runs only while the
|
|
250
|
+
# SDK waits for the transport, never while the caller's block handles a chunk, and the worker can run at most one
|
|
251
|
+
# chunk ahead. On expiry TransportError(timeout) is raised, and the worker is not killed: a killed thread skips the
|
|
252
|
+
# transport's +rescue+ clauses, which left a kept-alive connection with an unread answer (the next call then read
|
|
253
|
+
# the previous call's answer, another address's result). It gets an Abandoned error raised instead, as a network
|
|
254
|
+
# error would, so a stack such as Net::HTTP closes the connection whose answer it did not read; whatever the
|
|
255
|
+
# transport does after that is discarded, and no SDK code runs on its thread any more.
|
|
256
|
+
module TransportGuard
|
|
257
|
+
UPLOAD_RATE = 128 * 1024 # bytes per second
|
|
258
|
+
private_constant :UPLOAD_RATE
|
|
259
|
+
|
|
260
|
+
# Raised in an abandoned worker. A StandardError, so the transport's own error handling runs (Net::HTTP closes
|
|
261
|
+
# its socket on one).
|
|
262
|
+
class Abandoned < StandardError
|
|
263
|
+
def initialize(message = "The SDK stopped waiting for this request (timeout).")
|
|
264
|
+
super
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
module_function
|
|
269
|
+
|
|
270
|
+
def call(transport, request, stream)
|
|
271
|
+
events = SizedQueue.new(1)
|
|
272
|
+
worker = Thread.new { run(transport, request, stream, events) }
|
|
273
|
+
worker.report_on_exception = false
|
|
274
|
+
deadline = now + first_bound(request)
|
|
275
|
+
sink = nil
|
|
276
|
+
finished = false
|
|
277
|
+
loop do
|
|
278
|
+
event = events.pop(timeout: [deadline - now, 0].max)
|
|
279
|
+
raise TransportError.new("The request timed out.", timeout: true) if event.nil?
|
|
280
|
+
|
|
281
|
+
kind, value = event
|
|
282
|
+
case kind
|
|
283
|
+
when :head then sink = value
|
|
284
|
+
when :chunk then sink.call(value)
|
|
285
|
+
when :done
|
|
286
|
+
finished = true
|
|
287
|
+
return value
|
|
288
|
+
else
|
|
289
|
+
finished = true
|
|
290
|
+
raise value
|
|
291
|
+
end
|
|
292
|
+
deadline = now + request.timeout if request.transfer? # idle: counted from the end of the caller's block
|
|
293
|
+
end
|
|
294
|
+
ensure
|
|
295
|
+
events&.close
|
|
296
|
+
worker.raise(Abandoned) if worker&.alive? && !finished
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def run(transport, request, stream, events)
|
|
300
|
+
response = if stream
|
|
301
|
+
transport.call(request) do |status, headers|
|
|
302
|
+
raise Abandoned if events.closed? # the caller gave up: no SDK code on this thread any more
|
|
303
|
+
|
|
304
|
+
sink = stream.on_head(status, headers)
|
|
305
|
+
events.push([:head, sink])
|
|
306
|
+
sink && ->(chunk) { events.push([:chunk, chunk]) }
|
|
307
|
+
end
|
|
308
|
+
else
|
|
309
|
+
transport.call(request)
|
|
310
|
+
end
|
|
311
|
+
events.push([:done, response])
|
|
312
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- handed to the caller's thread unchanged
|
|
313
|
+
begin
|
|
314
|
+
events.push([:error, e])
|
|
315
|
+
rescue ClosedQueueError
|
|
316
|
+
nil # the caller gave up (timeout) and no longer listens
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def first_bound(request)
|
|
321
|
+
return request.timeout unless request.transfer?
|
|
322
|
+
|
|
323
|
+
request.timeout + (request.body ? request.body.bytesize.fdiv(UPLOAD_RATE) : 0)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def now
|
|
327
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
private_constant :TransportGuard
|
|
331
|
+
end
|
|
@@ -44,8 +44,9 @@ module EmailValidation
|
|
|
44
44
|
|
|
45
45
|
body = bytes(payload)
|
|
46
46
|
expected = OpenSSL::HMAC.digest("SHA256", secret_key(secret), "#{msg_id}.#{timestamp}.".b + body)
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
# Runs of spaces separate entries; one trailing "," is stripped, so values a stack joined with ", " still verify.
|
|
48
|
+
signatures.split(/ +/).each do |entry|
|
|
49
|
+
version, signature = entry.delete_suffix(",").split(",", 2)
|
|
49
50
|
next unless version == "v1" && signature && !signature.empty?
|
|
50
51
|
|
|
51
52
|
given = decode64(signature)
|