solana-studio 0.12.1 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/lib/solana/client.rb +219 -17
- data/lib/solana_studio/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b9393d6e5a4017b9cee63d30fcab9d19f701a7df0aa199c8f69ed88884c1efc
|
|
4
|
+
data.tar.gz: dec190556084d5b8e87b47d261d16b1bb8cb476bb56b2d89b2e51dca1b87759f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 334a207a8a10c6836708f642e5b62f77bc379aad03c748d6ca4d99b2c44744ccdf7ce5cfd8abb6e5138c49f1a66a60cab6866f80d3c39e62dc4cf847c6d0deee
|
|
7
|
+
data.tar.gz: eba72988237606e308db17446adc229fd9b77bf179684814bd1d0d3665e46e6287aec63af2848eb5105c6f4ef6afe7e78e12471af2b65ef618985e36b032101f
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## v0.12.3 (2026-10-07)
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`Solana::Client#call` has a total-wait budget, so one call can no longer sleep past a web request's timeout** (`lib/solana/client.rb`). The 429/5xx retry shipped in 0.12.2 honours `Retry-After` up to 10s, three times, plus jitter: about 31.5s of sleeping in one call, past Heroku's 30s request timeout on the turf-monster paths that call Solana inside a request. Now a call sums its waits and, when the next wait would carry that sum past its budget, raises its last error at once instead of sleeping; it never sleeps part of a wait. A `Retry-After` larger than what is left raises straight away. The budget is `DEFAULT_WAIT_BUDGET` = 15s, set per client with `Solana::Client.new(wait_budget:)` and per call with `Solana::Client.with_wait_budget(seconds) { ... }`, which applies to every call the current thread makes inside the block, restores the outer budget when it ends, and must be opened inside a `Thread.new` body to reach it. It bounds the waits between attempts only, not each attempt's time on the wire (`open_timeout` 10s, `read_timeout` 30s are unchanged). Network-error retries share the same budget. Linear backoff alone (1s + 2s + 3s) fits the default, so ordinary retries are unchanged.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Retries are visible** (`lib/solana/client.rb`). Every `#call` records a `Solana::Client::CallStats` (method, attempts, retries, seconds waited, budget, and whether the budget stopped it), readable from `Client#last_call_stats` after the call returns or raises (scoped to that client and that thread, so a prepended wrapper such as turf-monster's `Solana::ClientLogger` can read it after `super`), and from the new `RpcError#call_stats` on any error `#call` raises. Each retry, and a budget stop, writes one `warn` line to `Solana::Client.logger` (Rails.logger in a Rails host, nothing elsewhere; a client's own `logger:` wins) naming the method, the retry count, the wait, the total waited and the budget, never the RPC URL or a param. A logger that raises does not change what `#call` returns.
|
|
14
|
+
|
|
15
|
+
### Tests
|
|
16
|
+
- **`test/client_wait_budget_test.rb` (19, new)**: the default budget stops a `Retry-After: 10` call after one 10.5s wait; linear backoff inside the budget still gets all three retries and succeeds; a per-client budget, a zero budget, and `with_wait_budget` (overrides, nests, restores on raise, refuses a bad value without clobbering the outer one); network retries share the budget; a `Retry-After` larger than the remaining budget raises without sleeping, at the first wait or after earlier ones; each retry and the budget stop are logged with the retry count and the total waited, never the URL, key or params; a raising logger is harmless; a module prepended over `#call` in turf's `ClientLogger` shape reads the retry count and time waited on success and on failure; `last_call_stats` is per client; and a `sendTransaction` retried through a 429 and a 503 posts byte-identical bodies, captured at `Net::HTTP#request`. The control lifts the budget (`Float::INFINITY`) and shows the same script sleeping 31.5s, past 30s.
|
|
17
|
+
- **13 mutants against the new code, 12 KILLED**, each reverted after: the budget check removed, `>` made `>=`, the next delay left out of the check, the waited sum not accumulated, the retry count not incremented (loops forever; killed by timeout), the thread-local override ignored, the outer budget not restored, the log line dropped, `call_stats` not attached to the error, `last_call_stats` not scoped to its client, the logger rescue removed, and validation moved after the override is cleared. The survivor was an explicit NaN check, equivalent to `seconds >= 0` (NaN fails it), so the check was removed.
|
|
18
|
+
|
|
19
|
+
## v0.12.2 (2026-10-07)
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **`Solana::Client#call` reads the HTTP status before it parses the body, and retries a plain-text 429** (`lib/solana/client.rb`). Helius answers a rate limit with HTTP 429 and the plain-text body `Too many requests`. The client parsed every body as JSON first, so that answer raised `JSON::ParserError`, which `#call` did not rescue, and the read was never retried: `retryable_error?` only matched a JSON-RPC error `code` 429. On 2026-10-06 this failed 7 of contest 232's reads in turf-monster's production wallet backfill, and every mainnet read (settle and grading included) had the same exposure. Now HTTP 429, 500, 502, 503 and 504 (`RETRYABLE_HTTP_STATUSES`) are retried within the existing budget (`MAX_RETRIES` = 3 retries, linear `RETRY_DELAY` backoff), waiting for `Retry-After` when the server sends one (seconds or an HTTP-date, capped at `MAX_RETRY_AFTER` = 10s), with up to half a `RETRY_DELAY` of jitter on every wait, network retries included. When the retries run out, the client raises the new `Solana::Client::HttpError`, a subclass of `RpcError` whose `code` (and `status`) is the HTTP status, so `rescue RpcError` and `e.code == 429` keep working. Any other non-2xx raises at once without a retry; when its body carries a JSON-RPC error, that error's code and message are raised as before. A body that is not JSON raises `HttpError`, never `JSON::ParserError`; the body excerpt in its message is scrubbed to valid UTF-8, so a Latin-1 or gzip error page cannot raise `Encoding::CompatibilityError` where a host prints the message; and a non-2xx JSON body with no `error` key raises rather than returning a `nil` result that reads as "account not found". A transport that reports no status (a host's test stub of `#http_post` answering `#body` only) is parsed exactly as before. `sendTransaction` is now also retried on a 5xx; like a read timeout, that re-posts the same signed wire, which the cluster deduplicates by signature, and `Solana::Cosign` already treats every send-time error as possibly landed.
|
|
23
|
+
|
|
24
|
+
### Tests
|
|
25
|
+
- **`test/client_http_status_test.rb` (22, new)**: a plain-text 429 then a JSON result returns the result; a 429 on every attempt raises `RpcError` code 429 after exactly `MAX_RETRIES` + 1 attempts; backoff, jitter, `Retry-After` in seconds and as an HTTP-date, its cap and an unreadable value; a JSON-RPC 429 inside HTTP 200 still retries; a 503 then success, a 502 to exhaustion, a 503 `Retry-After`, a 500 carrying a JSON-RPC error retried on its status; a 400 and a 401 raise once without a retry; a JSON-RPC error on a 400 keeps its code; a 404 JSON body without an error raises; a non-JSON 200 raises `RpcError`; a long body is truncated; a Latin-1 or gzip body yields a UTF-8 message a host can interpolate; a status-less transport stub still parses. A control asserts the plain-text body raises `JSON::ParserError`, the exception the old parse-first code let escape. Run against the unfixed client, 19 of the 22 fail (`JSON::ParserError` or the missing `HttpError`).
|
|
26
|
+
- **10 mutants against the new code, all 10 KILLED**, each reverted after: the UTF-8 scrub of the body excerpt, the status-first check, the HTTP branch of `retryable_error?`, `Retry-After`, its cap, the jitter, 400/401 made retryable, 5xx made non-retryable, the non-2xx raise, and `JSON::ParserError` let through. Two survived the first run (the status-first check and the non-2xx raise); the 500-with-JSON-RPC-error and 404-JSON tests were added and both are now killed.
|
|
27
|
+
- **Consumer run: turf-monster's 134 Solana-touching test files (1,761 runs) against this tree** (`gem "solana-studio", path:` in a desk, not committed). The run found the binary-body defect above: two `solana_network_alignment_test.rb` cases (a Latin-1 page, a gzip body) raised `Encoding::CompatibilityError`. It also found that `solana_health_unauthorized_rpc_test.rb` asserted the old `JSON::ParserError` name; turf-monster's sibling PR widens that assertion so it holds on either side of the lock bump.
|
|
28
|
+
|
|
7
29
|
## v0.12.1 (2026-09-30)
|
|
8
30
|
|
|
9
31
|
### Changed
|
data/lib/solana/client.rb
CHANGED
|
@@ -2,32 +2,127 @@ require "net/http"
|
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
4
|
require "openssl"
|
|
5
|
+
require "time"
|
|
5
6
|
|
|
6
7
|
module Solana
|
|
7
8
|
class Client
|
|
8
9
|
class RpcError < StandardError
|
|
9
10
|
attr_reader :code
|
|
11
|
+
# The CallStats of the #call that raised this error (retries, seconds
|
|
12
|
+
# waited, the budget), or nil when the error never left #call.
|
|
13
|
+
attr_accessor :call_stats
|
|
10
14
|
def initialize(message, code: nil)
|
|
11
15
|
@code = code
|
|
12
16
|
super(message)
|
|
13
17
|
end
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
# An answer whose HTTP status, not a JSON-RPC error, says the call failed:
|
|
21
|
+
# a 429 or 5xx after the retries ran out, any other non-2xx without a
|
|
22
|
+
# JSON-RPC error in its body, or a body that is not JSON at all. `code` is
|
|
23
|
+
# the HTTP status, so a caller's `rescue RpcError` and `e.code == 429` keep
|
|
24
|
+
# working; `status` says the same thing by name.
|
|
25
|
+
class HttpError < RpcError
|
|
26
|
+
alias status code
|
|
27
|
+
end
|
|
28
|
+
|
|
16
29
|
class InsecureRpcUrlError < ArgumentError; end
|
|
17
30
|
|
|
18
31
|
MAX_RETRIES = 3
|
|
19
32
|
RETRY_DELAY = 1 # seconds
|
|
33
|
+
# A server's Retry-After is honoured up to this many seconds. Reads run
|
|
34
|
+
# inside requests and jobs; a provider asking for a minute gets the cap,
|
|
35
|
+
# and the retry budget then decides.
|
|
36
|
+
MAX_RETRY_AFTER = 10
|
|
37
|
+
# Up to this fraction of RETRY_DELAY is added to every wait, so callers
|
|
38
|
+
# throttled together do not all come back on the same tick.
|
|
39
|
+
RETRY_JITTER = 0.5
|
|
40
|
+
# The most seconds one #call may spend SLEEPING between attempts, summed
|
|
41
|
+
# over every retry. MAX_RETRIES Retry-After waits at the cap come to about
|
|
42
|
+
# 31.5s, past Heroku's 30s request timeout, so a call stops and raises its
|
|
43
|
+
# last error rather than start a wait that would cross this line. It bounds
|
|
44
|
+
# the waits only, not the time each attempt spends on the wire (see
|
|
45
|
+
# #http_post's open and read timeouts). Set per client with
|
|
46
|
+
# `Client.new(wait_budget:)`, or per call with `Client.with_wait_budget`.
|
|
47
|
+
DEFAULT_WAIT_BUDGET = 15.0
|
|
48
|
+
# What one #call did: how many attempts and retries it made, the seconds it
|
|
49
|
+
# slept, the budget it ran under, and whether that budget is what stopped
|
|
50
|
+
# it. Read it from Client#last_call_stats, or from RpcError#call_stats.
|
|
51
|
+
CallStats = Struct.new(:method, :attempts, :retries, :waited, :budget, :budget_stopped, keyword_init: true)
|
|
52
|
+
# HTTP statuses that mean "not processed, ask again": rate limited, or the
|
|
53
|
+
# provider or a gateway in front of it failed.
|
|
54
|
+
RETRYABLE_HTTP_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
55
|
+
# How much of an unparseable body an error message carries.
|
|
56
|
+
BODY_EXCERPT = 200
|
|
20
57
|
|
|
21
58
|
DEFAULT_RPC_URL = "https://api.devnet.solana.com"
|
|
22
59
|
|
|
23
60
|
# Hostnames where plain http:// is permitted (local testing only).
|
|
24
61
|
HTTP_OK_HOSTS = %w[localhost 127.0.0.1 ::1 0.0.0.0].freeze
|
|
25
62
|
|
|
26
|
-
|
|
63
|
+
# The thread-local key for Client.with_wait_budget, and for the stats of
|
|
64
|
+
# the last #call this thread made.
|
|
65
|
+
WAIT_BUDGET_KEY = :solana_client_wait_budget
|
|
66
|
+
LAST_CALL_KEY = :solana_client_last_call
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Where #call writes one line per retry. Defaults to Rails.logger in a
|
|
70
|
+
# Rails host and to nothing elsewhere; a client's own `logger:` wins.
|
|
71
|
+
attr_writer :logger
|
|
72
|
+
|
|
73
|
+
def logger
|
|
74
|
+
return @logger if defined?(@logger)
|
|
75
|
+
|
|
76
|
+
Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Every #call made by THIS thread (any client) inside the block runs
|
|
80
|
+
# under `seconds` of total wait instead of its client's budget. For a
|
|
81
|
+
# caller inside a web request:
|
|
82
|
+
#
|
|
83
|
+
# Solana::Client.with_wait_budget(4) { vault.fetch_wallet_balances(addr) }
|
|
84
|
+
#
|
|
85
|
+
# Thread-scoped, so a Thread.new body must open its own block. Nesting
|
|
86
|
+
# restores the outer budget when the inner block ends.
|
|
87
|
+
def with_wait_budget(seconds)
|
|
88
|
+
seconds = validate_wait_budget!(seconds)
|
|
89
|
+
previous = Thread.current[WAIT_BUDGET_KEY]
|
|
90
|
+
begin
|
|
91
|
+
Thread.current[WAIT_BUDGET_KEY] = seconds
|
|
92
|
+
yield
|
|
93
|
+
ensure
|
|
94
|
+
Thread.current[WAIT_BUDGET_KEY] = previous
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_wait_budget!(seconds)
|
|
99
|
+
# NaN fails `>= 0`, so it is refused with the negatives.
|
|
100
|
+
unless seconds.is_a?(Numeric) && seconds >= 0
|
|
101
|
+
raise ArgumentError, "wait_budget must be a non-negative number of seconds (got #{seconds.inspect})"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
seconds.to_f
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
attr_reader :wait_budget
|
|
109
|
+
|
|
110
|
+
def initialize(rpc_url: nil, wait_budget: DEFAULT_WAIT_BUDGET, logger: nil)
|
|
27
111
|
@rpc_url = rpc_url || ENV.fetch("SOLANA_RPC_URL", DEFAULT_RPC_URL)
|
|
28
112
|
@uri = URI.parse(@rpc_url)
|
|
29
113
|
validate_rpc_scheme!
|
|
30
114
|
@request_id = 0
|
|
115
|
+
@wait_budget = self.class.validate_wait_budget!(wait_budget)
|
|
116
|
+
@logger = logger
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The CallStats of the last #call THIS client made on THIS thread, or nil.
|
|
120
|
+
# Thread-scoped because one client is shared by request threads. A wrapper
|
|
121
|
+
# prepended over #call (turf-monster's Solana::ClientLogger) reads it after
|
|
122
|
+
# `super` returns or raises.
|
|
123
|
+
def last_call_stats
|
|
124
|
+
stats = Thread.current[LAST_CALL_KEY]
|
|
125
|
+
stats[:stats] if stats && stats[:client].equal?(self)
|
|
31
126
|
end
|
|
32
127
|
|
|
33
128
|
def get_account_info(pubkey, encoding: "base64", commitment: nil)
|
|
@@ -178,35 +273,141 @@ module Solana
|
|
|
178
273
|
params: params
|
|
179
274
|
}
|
|
180
275
|
|
|
181
|
-
|
|
276
|
+
budget = Thread.current[WAIT_BUDGET_KEY] || @wait_budget
|
|
277
|
+
stats = CallStats.new(method: method.to_s, attempts: 0, retries: 0, waited: 0.0,
|
|
278
|
+
budget: budget, budget_stopped: false)
|
|
279
|
+
Thread.current[LAST_CALL_KEY] = { client: self, stats: stats }
|
|
182
280
|
begin
|
|
281
|
+
stats.attempts += 1
|
|
183
282
|
response = http_post(body)
|
|
184
|
-
|
|
283
|
+
status = http_status(response)
|
|
284
|
+
# The status first: a rate limit or gateway failure often carries a
|
|
285
|
+
# body that is not JSON (Helius answers 429 with "Too many requests").
|
|
286
|
+
raise http_error(status, response) if RETRYABLE_HTTP_STATUSES.include?(status)
|
|
185
287
|
|
|
186
|
-
|
|
288
|
+
parsed = parse_body(response, status)
|
|
289
|
+
|
|
290
|
+
if parsed.is_a?(Hash) && parsed["error"]
|
|
187
291
|
error = parsed["error"]
|
|
188
292
|
raise RpcError.new(error["message"], code: error["code"])
|
|
189
293
|
end
|
|
294
|
+
raise http_error(status, response) if status && !(200..299).cover?(status)
|
|
190
295
|
|
|
191
296
|
parsed["result"]
|
|
192
297
|
rescue RpcError => e
|
|
193
|
-
# Retry
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
end
|
|
298
|
+
# Retry an HTTP 429/5xx, a JSON-RPC rate limit, or blockhash expiry,
|
|
299
|
+
# while the wait fits the budget.
|
|
300
|
+
retry if stats.retries < MAX_RETRIES && retryable_error?(e) &&
|
|
301
|
+
wait_for_retry(stats, retry_delay(stats.retries + 1, response), e)
|
|
302
|
+
e.call_stats = stats
|
|
199
303
|
raise
|
|
200
304
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
|
|
201
|
-
if retries < MAX_RETRIES
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
end
|
|
206
|
-
raise RpcError.new("Network error: #{e.message}")
|
|
305
|
+
retry if stats.retries < MAX_RETRIES && wait_for_retry(stats, retry_delay(stats.retries + 1), e)
|
|
306
|
+
error = RpcError.new("Network error: #{e.message}")
|
|
307
|
+
error.call_stats = stats
|
|
308
|
+
raise error
|
|
207
309
|
end
|
|
208
310
|
end
|
|
209
311
|
|
|
312
|
+
# Sleep `delay` before the next attempt and return true, or, when that
|
|
313
|
+
# sleep would carry the call's total wait past its budget, mark the stats
|
|
314
|
+
# and return false so the caller raises its last error now. Never sleeps
|
|
315
|
+
# part of a wait: a retry that cannot have its full delay is not made.
|
|
316
|
+
def wait_for_retry(stats, delay, error)
|
|
317
|
+
if stats.waited + delay > stats.budget
|
|
318
|
+
stats.budget_stopped = true
|
|
319
|
+
log_retry("stopped", stats, delay, error)
|
|
320
|
+
return false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
stats.retries += 1
|
|
324
|
+
log_retry("retry", stats, delay, error)
|
|
325
|
+
sleep delay
|
|
326
|
+
stats.waited += delay
|
|
327
|
+
true
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# One line per retry, and one when the budget stops a call. It names the
|
|
331
|
+
# method, the cause and the numbers only: never the RPC URL, which can
|
|
332
|
+
# carry an API key on its query string, and never a request param.
|
|
333
|
+
def log_retry(event, stats, delay, error)
|
|
334
|
+
logger = @logger || self.class.logger
|
|
335
|
+
return unless logger
|
|
336
|
+
|
|
337
|
+
cause = error.is_a?(RpcError) ? "#{error.class.name.split('::').last} #{error.code}" : error.class.name
|
|
338
|
+
line = if event == "retry"
|
|
339
|
+
format("[Solana::Client] %s retry %d/%d after %s: waiting %.2fs (%.2fs of %.2fs budget)",
|
|
340
|
+
stats.method, stats.retries, MAX_RETRIES, cause, delay, stats.waited + delay, stats.budget)
|
|
341
|
+
else
|
|
342
|
+
format("[Solana::Client] %s wait budget spent after %d retries (%.2fs waited, next wait %.2fs, budget %.2fs): raising %s",
|
|
343
|
+
stats.method, stats.retries, stats.waited, delay, stats.budget, cause)
|
|
344
|
+
end
|
|
345
|
+
logger.warn(line)
|
|
346
|
+
rescue StandardError
|
|
347
|
+
nil # a broken logger never changes what #call returns or raises
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# The response's HTTP status as an Integer, or nil when the transport does
|
|
351
|
+
# not report one. A real Net::HTTPResponse always does; host test suites
|
|
352
|
+
# stub #http_post with objects that answer #body only, and those are read
|
|
353
|
+
# exactly as before.
|
|
354
|
+
def http_status(response)
|
|
355
|
+
return nil unless response.respond_to?(:code)
|
|
356
|
+
|
|
357
|
+
Integer(response.code.to_s, 10)
|
|
358
|
+
rescue ArgumentError
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# JSON.parse, except that a body which is not JSON raises HttpError (an
|
|
363
|
+
# RpcError) carrying the status, never JSON::ParserError.
|
|
364
|
+
def parse_body(response, status)
|
|
365
|
+
JSON.parse(response.body.to_s)
|
|
366
|
+
rescue JSON::ParserError
|
|
367
|
+
raise HttpError.new("RPC response is not JSON (HTTP #{status || 'status unknown'}): #{excerpt(response)}",
|
|
368
|
+
code: status)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def http_error(status, response)
|
|
372
|
+
HttpError.new("HTTP #{status} from RPC: #{excerpt(response)}", code: status)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# The start of the body, safe to interpolate: an upstream error page can be
|
|
376
|
+
# Latin-1 or gzip bytes (Net::HTTP hands those back as ASCII-8BIT), and
|
|
377
|
+
# mixing them into a UTF-8 message raises Encoding::CompatibilityError.
|
|
378
|
+
def excerpt(response)
|
|
379
|
+
text = response.body.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").strip
|
|
380
|
+
text.length > BODY_EXCERPT ? "#{text[0, BODY_EXCERPT]}…" : text
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# The wait before retry number `attempt`: the linear backoff (1s, 2s, 3s at
|
|
384
|
+
# RETRY_DELAY = 1), or the server's Retry-After when it asks for longer,
|
|
385
|
+
# plus jitter.
|
|
386
|
+
def retry_delay(attempt, response = nil)
|
|
387
|
+
base = RETRY_DELAY * attempt
|
|
388
|
+
asked = retry_after_seconds(response)
|
|
389
|
+
base = asked if asked && asked > base
|
|
390
|
+
base + (rand * RETRY_DELAY * RETRY_JITTER)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Retry-After as seconds (RFC 9110: delay-seconds or an HTTP-date), capped
|
|
394
|
+
# at MAX_RETRY_AFTER. nil when absent or unreadable.
|
|
395
|
+
def retry_after_seconds(response)
|
|
396
|
+
return nil unless response.respond_to?(:[])
|
|
397
|
+
|
|
398
|
+
value = response["Retry-After"].to_s.strip
|
|
399
|
+
return nil if value.empty?
|
|
400
|
+
|
|
401
|
+
seconds = if value.match?(/\A\d+\z/)
|
|
402
|
+
value.to_i
|
|
403
|
+
else
|
|
404
|
+
Time.httpdate(value) - Time.now
|
|
405
|
+
end
|
|
406
|
+
seconds.clamp(0, MAX_RETRY_AFTER)
|
|
407
|
+
rescue ArgumentError
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
210
411
|
def http_post(body)
|
|
211
412
|
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
212
413
|
if @uri.scheme == "https"
|
|
@@ -235,7 +436,8 @@ module Solana
|
|
|
235
436
|
end
|
|
236
437
|
|
|
237
438
|
def retryable_error?(error)
|
|
238
|
-
return
|
|
439
|
+
return RETRYABLE_HTTP_STATUSES.include?(error.code) if error.is_a?(HttpError)
|
|
440
|
+
return true if error.code == 429 # JSON-RPC rate limited
|
|
239
441
|
return true if error.message.include?("Blockhash not found")
|
|
240
442
|
false
|
|
241
443
|
end
|
|
@@ -16,5 +16,5 @@ module SolanaStudio
|
|
|
16
16
|
# through the normal cycle. Splitting the version out is the same shape
|
|
17
17
|
# studio-engine already uses (lib/studio/version.rb) and hands each file back
|
|
18
18
|
# to its real owner: this one to the release, the gemspec to the PR.
|
|
19
|
-
VERSION = "0.12.
|
|
19
|
+
VERSION = "0.12.3"
|
|
20
20
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solana-studio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.12.
|
|
4
|
+
version: 0.12.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ed25519
|