solana-studio 0.12.2 → 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 +12 -0
- data/lib/solana/client.rb +126 -14
- data/lib/solana_studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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,18 @@ 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
|
+
|
|
7
19
|
## v0.12.2 (2026-10-07)
|
|
8
20
|
|
|
9
21
|
### Fixed
|
data/lib/solana/client.rb
CHANGED
|
@@ -8,6 +8,9 @@ module Solana
|
|
|
8
8
|
class Client
|
|
9
9
|
class RpcError < StandardError
|
|
10
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
|
|
11
14
|
def initialize(message, code: nil)
|
|
12
15
|
@code = code
|
|
13
16
|
super(message)
|
|
@@ -34,6 +37,18 @@ module Solana
|
|
|
34
37
|
# Up to this fraction of RETRY_DELAY is added to every wait, so callers
|
|
35
38
|
# throttled together do not all come back on the same tick.
|
|
36
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)
|
|
37
52
|
# HTTP statuses that mean "not processed, ask again": rate limited, or the
|
|
38
53
|
# provider or a gateway in front of it failed.
|
|
39
54
|
RETRYABLE_HTTP_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
@@ -45,11 +60,69 @@ module Solana
|
|
|
45
60
|
# Hostnames where plain http:// is permitted (local testing only).
|
|
46
61
|
HTTP_OK_HOSTS = %w[localhost 127.0.0.1 ::1 0.0.0.0].freeze
|
|
47
62
|
|
|
48
|
-
|
|
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)
|
|
49
111
|
@rpc_url = rpc_url || ENV.fetch("SOLANA_RPC_URL", DEFAULT_RPC_URL)
|
|
50
112
|
@uri = URI.parse(@rpc_url)
|
|
51
113
|
validate_rpc_scheme!
|
|
52
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)
|
|
53
126
|
end
|
|
54
127
|
|
|
55
128
|
def get_account_info(pubkey, encoding: "base64", commitment: nil)
|
|
@@ -200,8 +273,12 @@ module Solana
|
|
|
200
273
|
params: params
|
|
201
274
|
}
|
|
202
275
|
|
|
203
|
-
|
|
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 }
|
|
204
280
|
begin
|
|
281
|
+
stats.attempts += 1
|
|
205
282
|
response = http_post(body)
|
|
206
283
|
status = http_status(response)
|
|
207
284
|
# The status first: a rate limit or gateway failure often carries a
|
|
@@ -218,23 +295,58 @@ module Solana
|
|
|
218
295
|
|
|
219
296
|
parsed["result"]
|
|
220
297
|
rescue RpcError => e
|
|
221
|
-
# Retry an HTTP 429/5xx, a JSON-RPC rate limit, or blockhash expiry
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
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
|
|
227
303
|
raise
|
|
228
304
|
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
|
|
229
|
-
if retries < MAX_RETRIES
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
end
|
|
234
|
-
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
|
|
235
309
|
end
|
|
236
310
|
end
|
|
237
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
|
+
|
|
238
350
|
# The response's HTTP status as an Integer, or nil when the transport does
|
|
239
351
|
# not report one. A real Net::HTTPResponse always does; host test suites
|
|
240
352
|
# stub #http_post with objects that answer #body only, and those are read
|
|
@@ -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
|