restless-sdk 0.3.0 → 0.4.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: 2e4d46a242b46c64f20924eab0d2d41387a77abbc50a3eb008f133f7073fb344
4
- data.tar.gz: b007f007343dbba867b331137b35a106e44a5b5569bbc8ee2762a1390709f94f
3
+ metadata.gz: 82907e8d32a7662ffb2e9aa15b19a7866578451c0b14fe1cb60196a04d26703a
4
+ data.tar.gz: 030f27989031f51461208c12cbe9f1c2499303abfb9d74e713db995aea9fd39b
5
5
  SHA512:
6
- metadata.gz: ff857105fd9427f2f19d8c39c76c743eb57e2ac8a834acea71d43d4bd4caceeb4fe67001188d2e08308b19636da303fbc9faf05b0927a558405765a14176bf2f
7
- data.tar.gz: 56a95c3f28179c558a2c59d566c2bb4916c2d4b223d5660a89fa83db403468f1fdbe22ca8a74486a2b86013f82be2d3326d6eafebe91c13d5a4b5057971d671d
6
+ metadata.gz: c4ce380faaa258e6d4b91fe557ccd47fcf4731bc6e59ba731cab405e207d52118f00c7d4f59f0c432301a600dd9d2e9aa048fe09194a8e96f89cefee654b3663
7
+ data.tar.gz: c500e8a60dcda664336a64c947d66e113aa422cdafe31b251e13c7f0e26be9c55045d57436a1bf87c90ba01bc13315546284c030dd753273360790d475ea7d06
data/CONFORMANCE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  | | |
4
4
  |---|---|
5
- | **Spec version** | 1.2.0 |
5
+ | **Spec version** | 1.3.0 |
6
6
  | **Level** | L2 (core + batching, caches, injection, safety) |
7
7
  | **Reference** | `restlesshq/node` (`@restlessai/sdk`) |
8
8
  | **Driver** | `ruby exe/restless-conformance` |
@@ -79,6 +79,11 @@ the reference. They are the reason this SDK is byte-compatible.
79
79
  | INJECT-005 | `Text.ws_trim`, not `String#strip`. Ruby's strip removes NUL and leaves NBSP; the contract wants exactly the PRIM-002 set. |
80
80
  | BATCH-003 | The environment indicator is `RESTLESS_ENV`, then `RACK_ENV`, then `RAILS_ENV`. Ruby has three spellings in the wild and no single winner. |
81
81
  | BATCH-008 | Test detection keys on those same variables being `test`, plus `RSpec::Core`, `Minitest::Test` and `Test::Unit::TestCase` being defined. |
82
+ | BATCH-009 | A slot is a worker thread, counted under the uploader's `Mutex`. A worker that finishes takes the next batch itself (up to 100, BATCH-010) instead of handing off, and releases its slot in an `ensure`, so a killed thread cannot leak one. `flush` waits on a `ConditionVariable` that every release broadcasts. |
83
+ | BATCH-009 (forking) | Puma and Unicorn fork after the app loads. The child inherits the slot count and queue but none of the threads behind them, so a stale count would block uploads forever. The uploader records `Process.pid` and starts the child from a clean state (new `Mutex`, empty queue, zero slots). The parent's queue is dropped in the child rather than sent twice. |
84
+ | WIRE-026 | "Never reached the ingest" is `Errno::ECONNREFUSED` and `SocketError` (DNS). `Net::HTTP` re-raises a connect failure with the same class, so the classification holds on a real socket (`test/test_uploader.rb` checks one). `Retry-After` is parsed with `Float()` (seconds) and then `Time.httpdate`. |
85
+ | WIRE-027 | `Net::ReadTimeout`, `Net::OpenTimeout`, `Timeout::Error`, `Errno::ECONNRESET`, `EOFError` and everything else fall through to the drop. `max_retries = 0` keeps `Net::HTTP` from resending on its own. |
86
+ | WIRE-028 | `open_timeout`, `read_timeout` and `write_timeout` are per operation, so a slow-trickling response could outlive them. The whole exchange also runs inside `Timeout.timeout(10)`. |
82
87
  | CACHE-* | Both caches are Mutex-guarded. Puma, Falcon and threaded Unicorn all serve requests on many threads, so an unsynchronized Hash is a real race. |
83
88
 
84
89
  ## Exemption: PRIM-034 (unpaired surrogates)
data/README.md CHANGED
@@ -188,6 +188,15 @@ and swallowed (surfaced only under `DEBUG=restless`). Uploads happen on a
188
188
  background thread and are fire-and-forget. Streaming responses
189
189
  (`text/event-stream`) and bodies over 1 MiB are never buffered.
190
190
 
191
+ It also stays bounded when Restless is slow or busy. At most 4 uploads run at
192
+ once, each with a 10 s timeout; captures queue behind them (up to 1000, oldest
193
+ dropped first) and a freed slot sends up to 100 at a time to catch up. An
194
+ upload Restless turns away with `503` or `429` is retried up to twice, waiting
195
+ for its `Retry-After` (at most 10 s). Anything Restless may already have
196
+ stored, such as a `502`, `504`, timeout or dropped connection, is never resent,
197
+ so a log is never doubled. `flush` waits for all of it, retries included. A
198
+ worker forked by Puma or Unicorn starts with its own empty queue.
199
+
191
200
  An exception your app raises is captured with its stack - so the error
192
201
  fingerprint keys on the method that raised, not on the middleware - and then
193
202
  re-raised untouched, leaving your own error handling completely unaffected.
@@ -203,7 +212,7 @@ re-raised untouched, leaving your own error handling completely unaffected.
203
212
 
204
213
  ## Conformance
205
214
 
206
- This SDK implements version 1.2.0 of the Restless SDK Contract at level L2,
215
+ This SDK implements version 1.3.0 of the Restless SDK Contract at level L2,
207
216
  and is verified against the shared cross-language conformance vectors and a
208
217
  differential fuzzer run against the reference implementation. See
209
218
  [CONFORMANCE.md](./CONFORMANCE.md).
data/install.md CHANGED
@@ -50,7 +50,7 @@ The client exposes four things:
50
50
  | `setup(&block)` | Register the per-request callback. Takes a block or any callable. |
51
51
  | `mask(key)` | Hash an end-user API key for safe logging. |
52
52
  | `rack` | The middleware factory to mount. |
53
- | `flush` | Force-upload the current batch (e.g. before exit). |
53
+ | `flush` | Upload everything queued and wait for it, retries included (e.g. before exit). |
54
54
 
55
55
  `Restless.new` and `Restless::Client.new` are the same thing. `mask` is also available as `Restless.mask` without a client, for scripts and tests.
56
56
 
@@ -299,7 +299,16 @@ The handler never runs. The response body is JSON `{"error": "<message>"}`. `enr
299
299
  | `DEBUG=restless` | Print upload diagnostics to stderr |
300
300
  | `RESTLESS_SETUP_MODE=1` | Upload even under a test runner (see below) |
301
301
 
302
- Batching is fixed: 10 requests per batch, a 5000 ms flush interval, a 1000-entry queue that drops oldest on overflow, and immediate flushing against a localhost base URL. Uploads never block a response, and failures are swallowed unless `DEBUG=restless`.
302
+ Batching is fixed, with no config:
303
+
304
+ - 10 requests per batch, a 5000 ms flush interval, and immediate flushing outside production or against a localhost base URL
305
+ - A 1000-entry queue that drops oldest on overflow
306
+ - At most 4 uploads in flight; while capped, each freed slot sends up to 100 queued entries
307
+ - Upload timeout: 10 s
308
+ - Retries: only `503`/`429` (honouring `Retry-After`, max 2 retries, max 10 s wait) and connections that never reached the ingest (refused, DNS failure). Never `502`/`504`, timeouts or dropped connections, which the ingest may already have stored.
309
+ - Safe under forking servers (Puma, Unicorn): a forked worker starts with an empty queue and its own uploads.
310
+
311
+ Uploads run on background threads and never block a response. Failures are swallowed unless `DEBUG=restless`. There is nothing to configure here and nothing to add to the user's code.
303
312
 
304
313
  **Test runs do not upload.** Detected via `RAILS_ENV`/`RACK_ENV` of `test`, or RSpec / Minitest / Test::Unit being loaded. Capture still runs, so your tests exercise the real path.
305
314
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require "net/http"
5
+ require "time"
6
+ require "timeout"
5
7
  require "uri"
6
8
 
7
9
  require_relative "env"
@@ -9,37 +11,40 @@ require_relative "har"
9
11
  require_relative "version"
10
12
 
11
13
  module Restless
12
- # CONTRACT.md sections 8 (wire format) and 9 (batching).
13
- #
14
- # Never raises to callers. Upload failures go to stderr under the debug flag
15
- # and are otherwise swallowed: observability must not break the request path
16
- # (SAFETY-004, SAFETY-008).
14
+ # CONTRACT.md sections 8 (wire format) and 9 (batching). Never raises to
15
+ # callers; failures surface only under the debug flag (SAFETY-004, SAFETY-008).
17
16
  class Uploader
18
17
  BATCH_SIZE = 10 # BATCH-001
19
18
  FLUSH_INTERVAL_MS = 5000 # BATCH-002
20
19
  MAX_QUEUE = 1000 # BATCH-004
21
- HTTP_TIMEOUT_S = 10
20
+ MAX_IN_FLIGHT = 4 # BATCH-009
21
+ MAX_UPLOAD_ENTRIES = 100 # BATCH-010
22
+ HTTP_TIMEOUT_S = 10 # WIRE-028
23
+ MAX_RETRIES = 2 # WIRE-026
24
+ RETRY_BASE_S = 1.0
25
+ MAX_RETRY_DELAY_S = 10.0
26
+
27
+ # Failures where the request never reached the ingest, so a resend cannot
28
+ # duplicate (WIRE-026). SocketError is a DNS lookup failure.
29
+ NOT_SENT_ERRORS = [Errno::ECONNREFUSED, SocketError].freeze
22
30
 
23
31
  attr_reader :base_url, :request_id_prefix
24
32
 
25
- # `sampling_hash` is the SAMPLE-040 hash of a non-default sampling config,
26
- # stamped on every upload as `x-restless-sampling` (SAMPLE-052). Nil when
27
- # sampling is not configured, and then no header is sent at all.
33
+ # `sampling_hash` is the SAMPLE-040 hash stamped on every upload as
34
+ # `x-restless-sampling` (SAMPLE-052); nil sends no header at all.
28
35
  def initialize(api_key:, base_url:, request_id_prefix: nil, on_response: nil,
29
- sampling_hash: nil, transport: nil)
36
+ sampling_hash: nil, transport: nil, sleeper: nil)
30
37
  @api_key = api_key.to_s
31
38
  @sampling_hash = sampling_hash
32
39
  @base_url = base_url.to_s
33
40
  @request_id_prefix = request_id_prefix
34
41
  @on_response = on_response
35
- # Test hook. Anything responding to
36
- # `call(url, headers, body) -> [status, body]`.
42
+ # Test hooks: `transport.call(url, headers, body) -> [status, body, headers]`
43
+ # and `sleeper.call(seconds)` in place of a real retry wait.
37
44
  @transport = transport
45
+ @sleeper = sleeper
38
46
 
39
- @queue = []
40
- @mutex = Mutex.new
41
- @timer = nil
42
- @inflight = []
47
+ reset_state
43
48
 
44
49
  warn_if_insecure
45
50
  end
@@ -66,41 +71,63 @@ module Restless
66
71
  # BATCH-008.
67
72
  return if Env.test_run? && !Env.setup_mode?
68
73
 
69
- batch = nil
70
- @mutex.synchronize do
74
+ reset_after_fork
75
+ batches = @mutex.synchronize do
71
76
  if @queue.length >= MAX_QUEUE
72
- # Drop the OLDEST. The newest entries are the ones an operator is
73
- # actively debugging.
77
+ # Drop the OLDEST: the newest are what an operator is debugging.
74
78
  @queue.shift
75
79
  Env.debug_log("queue at #{MAX_QUEUE} -- dropping oldest captured request")
76
80
  end
77
81
  @queue << captured
78
82
 
79
- if flush_immediately? || @queue.length >= BATCH_SIZE
80
- batch = take_batch
83
+ if flush_immediately?
84
+ take_batches(true)
85
+ elsif @queue.length >= BATCH_SIZE
86
+ take_batches(false)
81
87
  else
82
88
  start_timer
89
+ []
83
90
  end
84
91
  end
85
92
 
86
- upload_async(batch) if batch
93
+ start_workers(batches)
94
+ nil
95
+ rescue StandardError => e
96
+ Env.debug_log("push failed: #{e.class}: #{e.message}")
87
97
  nil
88
98
  end
89
99
 
90
- # BATCH-005, BATCH-006, BATCH-007. Synchronous: resolves when the attempt
91
- # completes.
100
+ # BATCH-005 to BATCH-007. Uploads everything queued, at most MAX_IN_FLIGHT
101
+ # at a time, and returns once all of it has settled, retries included.
92
102
  def flush
93
- batch = @mutex.synchronize { take_batch }
94
- upload(batch) unless batch.nil? || batch.empty?
95
- join_inflight
103
+ reset_after_fork
104
+ @mutex.synchronize do
105
+ cancel_timer
106
+ @draining += 1
107
+ begin
108
+ loop do
109
+ start_workers(take_batches(true))
110
+ break if @queue.empty? && @active.zero?
111
+
112
+ # Timed only as a guard against a worker that vanished without
113
+ # releasing its slot; every real release broadcasts.
114
+ @cond.wait(@mutex, 1.0)
115
+ end
116
+ ensure
117
+ @draining -= 1
118
+ end
119
+ end
120
+ join_reports
121
+ nil
122
+ rescue StandardError => e
123
+ Env.debug_log("flush failed: #{e.class}: #{e.message}")
96
124
  nil
97
125
  end
98
126
 
99
- # SAMPLE-050, SAMPLE-051. POST the sampling report on a background
100
- # thread, then yield (on that thread) however it went. Same gates as an
101
- # upload: nothing under a test runner or without a project key. Never
102
- # raises, never retries.
127
+ # SAMPLE-050, SAMPLE-051. POST the sampling report on a background thread,
128
+ # then yield on that thread. Same gates as an upload; never raises or retries.
103
129
  def send_sampling_report_async(report, &on_done)
130
+ reset_after_fork
104
131
  thread = Thread.new do
105
132
  begin
106
133
  send_sampling_report(report)
@@ -113,7 +140,10 @@ module Restless
113
140
  end
114
141
  end
115
142
  thread.abort_on_exception = false
116
- track(thread)
143
+ @mutex.synchronize do
144
+ @reports.reject! { |t| !t.alive? }
145
+ @reports << thread
146
+ end
117
147
  nil
118
148
  end
119
149
 
@@ -127,9 +157,25 @@ module Restless
127
157
 
128
158
  private
129
159
 
130
- # BATCH-003. Flush every push when the app is not in production, or when
131
- # the ingest is on localhost. Both keep the customer dev loop and
132
- # self-hosted setups low-latency.
160
+ def reset_state
161
+ @pid = Process.pid
162
+ @mutex = Mutex.new
163
+ @cond = ConditionVariable.new
164
+ @queue = []
165
+ @active = 0 # uploads holding a BATCH-009 slot, including retry waits
166
+ @draining = 0 # flushes in progress, which let partial batches through
167
+ @timer = nil
168
+ @reports = []
169
+ end
170
+
171
+ # Puma and Unicorn fork after the app loads: the child inherits this state
172
+ # but none of its threads, so it starts clean rather than count dead slots.
173
+ def reset_after_fork
174
+ reset_state unless @pid == Process.pid
175
+ end
176
+
177
+ # BATCH-003. Outside production, or against a localhost ingest, every push
178
+ # flushes, keeping the dev loop and self-hosted setups low-latency.
133
179
  def flush_immediately?
134
180
  return true unless Env.production?
135
181
  return true if Env.localhost?(@base_url)
@@ -137,16 +183,68 @@ module Restless
137
183
  false
138
184
  end
139
185
 
140
- # Caller holds @mutex.
141
- def take_batch
142
- cancel_timer
143
- return nil if @queue.empty?
186
+ # Caller holds @mutex. Claims a slot per batch; `partial` lets a batch
187
+ # under BATCH_SIZE go (BATCH-010).
188
+ def take_batches(partial)
189
+ # BATCH-007. No key: drop everything, never retry or accumulate.
190
+ unless api_key?
191
+ Env.debug_log("no API key -- dropping batch of #{@queue.length}") unless @queue.empty?
192
+ @queue.clear
193
+ cancel_timer
194
+ return []
195
+ end
144
196
 
145
- batch = @queue
146
- @queue = []
197
+ batches = []
198
+ while @active < MAX_IN_FLIGHT && !@queue.empty? && (partial || @queue.length >= BATCH_SIZE)
199
+ batches << @queue.shift(MAX_UPLOAD_ENTRIES)
200
+ @active += 1
201
+ end
202
+ cancel_timer if @queue.empty?
203
+ batches
204
+ end
205
+
206
+ # Caller holds @mutex. The next batch for a worker whose upload just
207
+ # finished, or nil when it should give its slot back.
208
+ def next_batch
209
+ return nil if @queue.empty? || !api_key?
210
+ return nil unless flush_immediately? || @draining.positive? || @queue.length >= BATCH_SIZE
211
+
212
+ batch = @queue.shift(MAX_UPLOAD_ENTRIES)
213
+ cancel_timer if @queue.empty?
147
214
  batch
148
215
  end
149
216
 
217
+ # Caller holds @mutex. A remainder under BATCH_SIZE waits for the timer.
218
+ def release_slot
219
+ @active -= 1
220
+ start_timer unless @queue.empty?
221
+ @cond.broadcast
222
+ end
223
+
224
+ # SAFETY-008. Uploads run on background threads, never the request thread.
225
+ def start_workers(batches)
226
+ batches.each do |batch|
227
+ thread = Thread.new(batch) { |b| run_worker(b) }
228
+ thread.abort_on_exception = false
229
+ thread.report_on_exception = false
230
+ end
231
+ end
232
+
233
+ # One slot. It keeps sending while a backlog is waiting, then frees itself.
234
+ def run_worker(batch)
235
+ holding = true
236
+ while holding
237
+ send_batch(batch)
238
+ @mutex.synchronize do
239
+ batch = next_batch
240
+ holding = !batch.nil?
241
+ release_slot unless holding
242
+ end
243
+ end
244
+ ensure
245
+ @mutex.synchronize { release_slot } if holding
246
+ end
247
+
150
248
  # Caller holds @mutex.
151
249
  def start_timer
152
250
  return if @timer
@@ -154,11 +252,11 @@ module Restless
154
252
  @timer = Thread.new do
155
253
  sleep(FLUSH_INTERVAL_MS / 1000.0)
156
254
  begin
157
- batch = @mutex.synchronize do
255
+ batches = @mutex.synchronize do
158
256
  @timer = nil
159
- @queue.empty? ? nil : take_batch_unsafe
257
+ take_batches(true)
160
258
  end
161
- upload(batch) if batch
259
+ start_workers(batches)
162
260
  rescue StandardError => e
163
261
  Env.debug_log("flush timer failed: #{e.class}: #{e.message}")
164
262
  end
@@ -166,14 +264,6 @@ module Restless
166
264
  @timer.abort_on_exception = false
167
265
  end
168
266
 
169
- # Like take_batch but without cancelling the timer, which the timer thread
170
- # has already cleared.
171
- def take_batch_unsafe
172
- batch = @queue
173
- @queue = []
174
- batch
175
- end
176
-
177
267
  # Caller holds @mutex.
178
268
  def cancel_timer
179
269
  timer = @timer
@@ -181,20 +271,10 @@ module Restless
181
271
  timer.kill if timer && timer != Thread.current
182
272
  end
183
273
 
184
- # SAFETY-008. The capture path performs no blocking I/O; uploads are
185
- # asynchronous and fire-and-forget.
186
- def upload_async(batch)
187
- thread = Thread.new { upload(batch) }
188
- thread.abort_on_exception = false
189
- track(thread)
190
- end
191
-
192
- # `flush` joins these, so it also waits for an in-flight sampling report.
193
- def track(thread)
194
- @mutex.synchronize do
195
- @inflight.reject! { |t| !t.alive? }
196
- @inflight << thread
197
- end
274
+ def join_reports
275
+ threads = @mutex.synchronize { @reports.dup }
276
+ threads.each { |t| t.join(HTTP_TIMEOUT_S) }
277
+ @mutex.synchronize { @reports.reject! { |t| !t.alive? } }
198
278
  end
199
279
 
200
280
  def send_sampling_report(report)
@@ -216,27 +296,13 @@ module Restless
216
296
  nil
217
297
  end
218
298
 
219
- def join_inflight
220
- threads = @mutex.synchronize { @inflight.dup }
221
- threads.each { |t| t.join(HTTP_TIMEOUT_S) }
222
- @mutex.synchronize { @inflight.reject! { |t| !t.alive? } }
223
- end
224
-
225
- def upload(batch)
226
- return if batch.nil? || batch.empty?
227
-
228
- # BATCH-007. No key: drop the batch, never retry or accumulate.
229
- unless api_key?
230
- Env.debug_log("no API key -- dropping batch of #{batch.length}")
231
- return
232
- end
233
-
299
+ # One upload, holding its slot through any retries. Only what the ingest
300
+ # certainly did not store is resent (WIRE-024, WIRE-026, WIRE-027).
301
+ def send_batch(batch)
234
302
  fingerprints = distinct_fingerprints(batch)
235
- payload = batch.map { |captured| entry_for(captured) }
303
+ # WIRE-004: a JSON ARRAY, even for a single capture.
304
+ body = JSON.generate(batch.map { |captured| entry_for(captured) })
236
305
  url = "#{@base_url}/v1/request" # WIRE-001
237
-
238
- Env.debug_log("uploading #{batch.length} entr#{batch.length == 1 ? 'y' : 'ies'} to #{url}")
239
-
240
306
  headers = {
241
307
  "Content-Type" => "application/json", # WIRE-002
242
308
  "Authorization" => "Bearer #{@api_key}", # WIRE-003
@@ -245,23 +311,85 @@ module Restless
245
311
  }
246
312
  headers["x-restless-sampling"] = @sampling_hash if @sampling_hash # SAMPLE-052
247
313
 
248
- # WIRE-004: a JSON ARRAY, even for a single capture.
249
- status, body = send_request(url, headers, JSON.generate(payload))
314
+ attempt = 0
315
+ loop do
316
+ Env.debug_log("uploading #{batch.length} entr#{batch.length == 1 ? 'y' : 'ies'} to #{url}" \
317
+ "#{attempt.positive? ? " (retry #{attempt})" : ''}")
318
+ begin
319
+ status, response_body, response_headers = send_request(url, headers, body)
320
+ rescue *NOT_SENT_ERRORS => e
321
+ raise unless attempt < MAX_RETRIES
322
+
323
+ Env.debug_log("upload not sent (#{e.class}); retrying")
324
+ pause(retry_delay(nil, attempt))
325
+ attempt += 1
326
+ next
327
+ end
328
+
329
+ if status.is_a?(Integer) && status >= 200 && status < 300
330
+ handle_response(response_body, fingerprints)
331
+ return
332
+ end
333
+
334
+ if [503, 429].include?(status) && attempt < MAX_RETRIES
335
+ wait = retry_delay(header_value(response_headers, "retry-after"), attempt)
336
+ Env.debug_log("upload refused (#{status}); retrying in #{(wait * 1000).round}ms")
337
+ pause(wait)
338
+ attempt += 1
339
+ next
340
+ end
250
341
 
251
- # WIRE-024: a non-2xx is never retried and never raises. The batch is
252
- # dropped.
253
- unless status && status >= 200 && status < 300
254
342
  Env.debug_log("upload failed: #{status.inspect}")
255
343
  return
256
344
  end
257
-
258
- handle_response(body, fingerprints)
259
345
  rescue StandardError => e
260
- # SAFETY-004.
346
+ # SAFETY-004. Timeouts and resets land here and are never retried.
261
347
  Env.debug_log("upload error: #{e.class}: #{e.message}")
262
348
  nil
263
349
  end
264
350
 
351
+ # WIRE-026: the server's Retry-After (seconds or an HTTP date), else
352
+ # jittered exponential backoff; either way capped.
353
+ def retry_delay(retry_after, attempt)
354
+ seconds = parse_retry_after(retry_after)
355
+ if seconds.nil? || !seconds.finite? || seconds.negative?
356
+ seconds = RETRY_BASE_S * (2**attempt) * (0.5 + rand)
357
+ end
358
+ [seconds, MAX_RETRY_DELAY_S].min
359
+ end
360
+
361
+ def parse_retry_after(value)
362
+ text = value.to_s.strip
363
+ return nil if text.empty?
364
+
365
+ begin
366
+ Float(text)
367
+ rescue ArgumentError
368
+ begin
369
+ Time.httpdate(text) - Time.now
370
+ rescue ArgumentError
371
+ nil
372
+ end
373
+ end
374
+ end
375
+
376
+ def header_value(headers, name)
377
+ return nil unless headers.respond_to?(:each_pair)
378
+
379
+ headers.each_pair do |key, value|
380
+ next unless key.to_s.casecmp(name).zero?
381
+
382
+ return value.is_a?(Array) ? value.first : value
383
+ end
384
+ nil
385
+ end
386
+
387
+ def pause(seconds)
388
+ return @sleeper.call(seconds) if @sleeper
389
+
390
+ sleep(seconds) if seconds.positive?
391
+ end
392
+
265
393
  def send_request(url, headers, body)
266
394
  return @transport.call(url, headers, body) if @transport
267
395
 
@@ -270,11 +398,15 @@ module Restless
270
398
  http.use_ssl = uri.scheme == "https"
271
399
  http.open_timeout = HTTP_TIMEOUT_S
272
400
  http.read_timeout = HTTP_TIMEOUT_S
401
+ http.write_timeout = HTTP_TIMEOUT_S if http.respond_to?(:write_timeout=)
402
+ # Net::HTTP never resends a POST anyway; zero keeps WIRE-027 explicit.
403
+ http.max_retries = 0 if http.respond_to?(:max_retries=)
273
404
  request = Net::HTTP::Post.new(uri.request_uri)
274
405
  headers.each { |k, v| request[k] = v }
275
406
  request.body = body
276
- response = http.request(request)
277
- [response.code.to_i, response.body]
407
+ # The per-operation timeouts above do not bound the whole exchange.
408
+ response = Timeout.timeout(HTTP_TIMEOUT_S) { http.request(request) }
409
+ [response.code.to_i, response.body, response.to_hash]
278
410
  end
279
411
 
280
412
  # WIRE-020..023.
@@ -6,7 +6,7 @@ module Restless
6
6
  # META-001: the spec version an SDK implements must be recorded in a
7
7
  # machine-readable form alongside its conformance level.
8
8
 
9
- VERSION = "0.3.0"
9
+ VERSION = "0.4.0"
10
10
 
11
11
  # WIRE-016: distinct per implementation, so the ingest can attribute a
12
12
  # payload to a language.
@@ -17,7 +17,7 @@ module Restless
17
17
  # REDACT-010 requires full Unicode name folding
18
18
  # to require full Unicode lowercase. Keep this in step with
19
19
  # spec/VECTORS_VERSION.
20
- SPEC_VERSION = "1.2.0"
20
+ SPEC_VERSION = "1.3.0"
21
21
 
22
22
  # CONTRACT.md 1.1: L1 is the pure functions, L2 adds batching, caches,
23
23
  # injection and the safety guarantees.
data/lib/restless.rb CHANGED
@@ -23,7 +23,7 @@ require_relative "restless/rack"
23
23
 
24
24
  # Capture your API traffic and send it to Restless.
25
25
  #
26
- # This SDK implements version 1.2.0 of the Restless SDK Contract at level L2.
26
+ # This SDK implements version 1.3.0 of the Restless SDK Contract at level L2.
27
27
  # See CONFORMANCE.md.
28
28
  #
29
29
  # client = Restless.new(ENV["RESTLESS_KEY"])
data/spec/VECTORS_VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "inject/2xx-headers-only",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "fp/404-parameterized",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "har/basic",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "mask/ascii",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "slug/basic",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "redactValue/long",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "reqid/format-with-prefix",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "GENERATED FILE - do not edit by hand. Inputs live in spec/cases.ts; run `npm run spec:vectors` to regenerate.",
3
3
  "$contract": "spec/CONTRACT.md",
4
- "specVersion": "1.2.0",
4
+ "specVersion": "1.3.0",
5
5
  "cases": [
6
6
  {
7
7
  "id": "sampling/config-absent",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: restless-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Restless
@@ -15,7 +15,7 @@ description: |
15
15
  recovered from, and uploads the result to Restless. Works with Rails,
16
16
  Sinatra, Hanami, Grape, Roda and anything else that speaks Rack.
17
17
 
18
- Implements version 1.2.0 of the Restless SDK Contract at
18
+ Implements version 1.3.0 of the Restless SDK Contract at
19
19
  level L2.
20
20
  executables: []
21
21
  extensions: []
@@ -60,7 +60,7 @@ licenses:
60
60
  metadata:
61
61
  source_code_uri: https://github.com/restlesshq/ruby
62
62
  bug_tracker_uri: https://github.com/restlesshq/ruby/issues
63
- restless_spec_version: 1.2.0
63
+ restless_spec_version: 1.3.0
64
64
  restless_conformance_level: L2
65
65
  rdoc_options: []
66
66
  require_paths: