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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14ad71141b369bcdc3c22aad7644fca987cf924efd8aab247b753ba76a52dad3
4
- data.tar.gz: 41c9eb34aecfb9e4c6d5ef6847f64928374549b3859a4ebd0a999e37e4c4188d
3
+ metadata.gz: 4b9393d6e5a4017b9cee63d30fcab9d19f701a7df0aa199c8f69ed88884c1efc
4
+ data.tar.gz: dec190556084d5b8e87b47d261d16b1bb8cb476bb56b2d89b2e51dca1b87759f
5
5
  SHA512:
6
- metadata.gz: 5f27e7398fcc671ef3d1c8e32ab1a43b5fa9044d957b95cfe1c4bb1c9e7e5267e9870a28f07d1e3ba2a7b8b70603dd15d9b9d64798c11da7a80f893d5bbdbee4
7
- data.tar.gz: 6032788536e6174d5b8fb3ee9ddd0e06888ea8b07d9f63305fe947d261e9f36962ea2b288d06b4c9e6718bf463f9bbf436d77707ed0d485e5240d689273aa6ad
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
- def initialize(rpc_url: nil)
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
- retries = 0
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
- if retries < MAX_RETRIES && retryable_error?(e)
223
- retries += 1
224
- sleep retry_delay(retries, response)
225
- retry
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
- retries += 1
231
- sleep retry_delay(retries)
232
- retry
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.2"
19
+ VERSION = "0.12.3"
20
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solana-studio
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.2
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie