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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cef3267cdbc0f471acc37793832fa635ad5fae74f6a12e885dd852ee9f2e6673
4
- data.tar.gz: 8bf364214be99dc1b0faef104424c0585ac41808b3034b8b26dbde4596e41858
3
+ metadata.gz: 42a80685440b79d131e81f340ddb16dd70462a855e80163f84226c48ba38af54
4
+ data.tar.gz: 3afbbd8c4875d74653bd85a3d74a6be2fedc5a03b98de5143c2f348a393da188
5
5
  SHA512:
6
- metadata.gz: 986d270b3fb54ceef83d8cd1ea279ddd57d4cc5b20136978a58c87d1c41a6de1321442cb69a175d368ddb1037c6b594e15dd1268c08b097af30d4170cd276178
7
- data.tar.gz: 0751cdba9c258ca15890e9d94a862e061292b95c50b73d8b43ac0deb1f420ba6618a9c03726e060dd4e5f81a453210b5885a6c68e9cb2032059537a1f1f59f2f
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
- Addresses at `sandbox.email-validation.org` are free and answer the reason in their local part
39
- (`catch_all@…`, `no_mailbox@…`, `timeout@…`); `EmailValidation.sandbox_address("catch_all")` builds one.
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: "https://api.email-validation.org", # default
50
- timeout: 30, # seconds per attempt (connect + whole response), default 30
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
- EU data residency (validated and stored in the EU): pass `base_url: EmailValidation::EU_BASE_URL` to call `https://api-eu.email-validation.org`
57
- instead of the default `https://api.email-validation.org`. See https://email-validation.org/docs/eu-data-residency/.
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 non-positive
60
- `timeout` raises `ArgumentError` at construction.
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
- ## Methods
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
- ### `client.credits` → `Credits`
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
- ### `client.usage(from: nil, to: nil, group_by: nil)` → `UsageResponse`
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}" } # UsageDay
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
- ### Bulk jobs: `client.bulk`
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]) # whole body as a String
137
- File.open("results.csv", "wb") do |file| # or streamed in chunks
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
- - `create`: pass exactly one of `emails:` (an Array) or `file:`; `start: false` keeps the job in `ready` until you call
153
- `start`. A file given as an IO is read whole, so a retry can resend the same bytes.
154
- - `results`: `filter` is `"all"`, one status, or an Array of statuses; `columns` is `"original"` (default) or
155
- `"email_only"`; `escape_formulas` defaults to `true` on the server. The CSV is returned unchanged (never parsed).
156
- `range: "bytes=0-1048575"` fetches one part of the file to resume a download; only `filter: "all"` (or one status
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. A job created with `start: false` waits in `ready` until you call `start`, so call
162
- `start` before `wait`. `interval` is at least 1 second; when `timeout` (seconds) passes, `wait` raises
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
- ### Models
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`) and `cause`.
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 request. Ruby has no
217
- cancellation mechanism for blocking calls, so the `aborted` code is not used by this SDK.
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 timeouts
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`, up to
222
- `max_retries` times, unless the wait would be longer than `max_retry_wait` seconds. `key_daily_cap` is never
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 not sent again.
226
- - Nothing else is retried. Without `Retry-After` the wait is exponential with jitter (0.25–0.5 s, 0.5–1 s, 1–2 s …,
227
- at most 8 s).
228
- - `timeout` bounds each attempt (connect, TLS and the whole response), not the whole call. For very large result
229
- downloads, raise it or fetch the file in parts with `range:`.
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, or a Rack env (`HTTP_WEBHOOK_ID` keys).
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`) and `timeout` (seconds per attempt).
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. The SDK writes nothing to stdout or stderr and
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
- private_constant :JOB_ID, :CONTENT_TYPES, :FILTERS, :BAD_JOB_ID, :NOT_RANGEABLE
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
- def results(job_id, filter: nil, columns: nil, escape_formulas: nil, range: nil, &block)
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 download(path, query, headers, range) unless block
112
+ return stream_download(path, query, headers, range, block, request_timeout) if block
89
113
 
90
- stream_download(path, query, headers, range, block)
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
- def wait(job_id, interval: 5, timeout: nil, on_progress: nil, &block)
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
- parts = value.is_a?(Array) ? value.map(&:to_s) : [value.to_s]
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
- data
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
- def download(path, query, headers, range)
158
- response = @client.perform("GET", path, query: query, accept: "text/csv", headers: headers)
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) unless stream.head_seen? || response.body.empty?
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, _headers)
282
+ def on_head(status, headers)
193
283
  return nil unless (200..299).cover?(status)
194
284
 
195
285
  @head_seen = true
196
- return ->(_chunk) {} if @range && status != 206
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
- raise ArgumentError, "timeout must be a number of seconds greater than 0" unless
22
- timeout.is_a?(Numeric) && timeout.positive?
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 (connect and the whole response), default 30. A check may take up to 15 s on
88
- # our side, so keep it a few seconds above the +timeout+ you pass to #validate.
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
- request = build_request(method, path, query, json, body, content_type, accept, headers)
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 = stream ? @transport.call(request) { |s, h| stream.on_head(s, h) } : @transport.call(request)
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 build_request(method, path, query, json, body, content_type, accept, extra_headers)
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: config.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; +timeout+ is seconds per attempt.
16
- def initialize(method:, url:, headers:, body:, timeout:)
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&.freeze
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 the client's
71
- # +timeout+ bounds every attempt (connect, TLS, request and the whole response).
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
- private_constant :NETWORK_ERRORS, :TIMEOUT_ERRORS
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 = Timeout.timeout(request.timeout, AttemptTimeout) do
105
- conn = checkout(uri, request.timeout)
106
- exchange(conn, uri, request, on_head)
107
- end
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module EmailValidation
4
4
  # SDK version (Semantic Versioning). Sent in the User-Agent header.
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  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
- signatures.split(/ /).each do |entry|
48
- version, signature = entry.split(",", 2)
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: email-validation-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - email-validation.org