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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 26e0e9024da086eab6ef2c5039a616d92e33d3a0857db9709ed6456dcfd2b1c8
4
- data.tar.gz: 3a5284d8b9bce6ac1d5b9e6529557b7fff289719bf004c2d86755ec88d3b7b0d
3
+ metadata.gz: 4b9393d6e5a4017b9cee63d30fcab9d19f701a7df0aa199c8f69ed88884c1efc
4
+ data.tar.gz: dec190556084d5b8e87b47d261d16b1bb8cb476bb56b2d89b2e51dca1b87759f
5
5
  SHA512:
6
- metadata.gz: b8ef4516331e8835754767b457a5309bb04909aa9c02c1564342d29080cdad6f4791c1a8b1fb9b0233321ca1cf3e45937bfe7563b0d68ce58e315ecaf1738911
7
- data.tar.gz: 6b61d94b2e4892e2412fbff5afb4675f3d553e1de9b68d5f6ed2a3013d0a0de3849bd979596f4a16d9905b1391c62b01acc44412c14bbf8a4e7ffab34405260c
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
- 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)
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
- 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 }
182
280
  begin
281
+ stats.attempts += 1
183
282
  response = http_post(body)
184
- parsed = JSON.parse(response.body)
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
- if parsed["error"]
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 on rate limit (429) or blockhash expiry
194
- if retries < MAX_RETRIES && retryable_error?(e)
195
- retries += 1
196
- sleep RETRY_DELAY * retries
197
- retry
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
- retries += 1
203
- sleep RETRY_DELAY * retries
204
- retry
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 true if error.code == 429 # rate limited
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.1"
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.1
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-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ed25519