solana-studio 0.12.0 → 0.12.2

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: a067bc88c6dbfae4cd19a6a0132c0c54864e20e2c405211d935b91d134855111
4
- data.tar.gz: 2c15956cee0c63082e85dd73f53c601b2f7c5fac5ec6e455830225e649ca8a2a
3
+ metadata.gz: 14ad71141b369bcdc3c22aad7644fca987cf924efd8aab247b753ba76a52dad3
4
+ data.tar.gz: 41c9eb34aecfb9e4c6d5ef6847f64928374549b3859a4ebd0a999e37e4c4188d
5
5
  SHA512:
6
- metadata.gz: 8788bdff89c7b8d0d80d22fe2159c97e49d9955e573dcb41ec4667361ab05e354ca8ff30227a6de4bbf904e79682d309aeeef9c868d720c83850e42824d14706
7
- data.tar.gz: f77ab0deb992f4105085f5ddfb3eb2267c52c6b2373c2eba81401369553fc5b6803fbad70e345761190f7c10ed05d88b413338afc3c7d678109abb334e6cff17
6
+ metadata.gz: 5f27e7398fcc671ef3d1c8e32ab1a43b5fa9044d957b95cfe1c4bb1c9e7e5267e9870a28f07d1e3ba2a7b8b70603dd15d9b9d64798c11da7a80f893d5bbdbee4
7
+ data.tar.gz: 6032788536e6174d5b8fb3ee9ddd0e06888ea8b07d9f63305fe947d261e9f36962ea2b288d06b4c9e6718bf463f9bbf436d77707ed0d485e5240d689273aa6ad
data/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ 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.2 (2026-10-07)
8
+
9
+ ### Fixed
10
+ - **`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.
11
+
12
+ ### Tests
13
+ - **`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`).
14
+ - **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.
15
+ - **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.
16
+
17
+ ## v0.12.1 (2026-09-30)
18
+
19
+ ### Changed
20
+ - **Development bundle: studio-engine floor raised to 0.81.1** (`Gemfile`). 0.81.1 declares `redis < 6`; the old `~> 0.57` let a lock that already held redis 6.0.0 keep studio-engine at 0.81.0 under a conservative update. Development and test only; the gem's runtime dependencies are unchanged.
21
+
7
22
  ## v0.12.0 (2026-09-17)
8
23
 
9
24
  ### Fixed
data/lib/solana/client.rb CHANGED
@@ -2,6 +2,7 @@ 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
@@ -13,10 +14,31 @@ module Solana
13
14
  end
14
15
  end
15
16
 
17
+ # An answer whose HTTP status, not a JSON-RPC error, says the call failed:
18
+ # a 429 or 5xx after the retries ran out, any other non-2xx without a
19
+ # JSON-RPC error in its body, or a body that is not JSON at all. `code` is
20
+ # the HTTP status, so a caller's `rescue RpcError` and `e.code == 429` keep
21
+ # working; `status` says the same thing by name.
22
+ class HttpError < RpcError
23
+ alias status code
24
+ end
25
+
16
26
  class InsecureRpcUrlError < ArgumentError; end
17
27
 
18
28
  MAX_RETRIES = 3
19
29
  RETRY_DELAY = 1 # seconds
30
+ # A server's Retry-After is honoured up to this many seconds. Reads run
31
+ # inside requests and jobs; a provider asking for a minute gets the cap,
32
+ # and the retry budget then decides.
33
+ MAX_RETRY_AFTER = 10
34
+ # Up to this fraction of RETRY_DELAY is added to every wait, so callers
35
+ # throttled together do not all come back on the same tick.
36
+ RETRY_JITTER = 0.5
37
+ # HTTP statuses that mean "not processed, ask again": rate limited, or the
38
+ # provider or a gateway in front of it failed.
39
+ RETRYABLE_HTTP_STATUSES = [429, 500, 502, 503, 504].freeze
40
+ # How much of an unparseable body an error message carries.
41
+ BODY_EXCERPT = 200
20
42
 
21
43
  DEFAULT_RPC_URL = "https://api.devnet.solana.com"
22
44
 
@@ -181,32 +203,99 @@ module Solana
181
203
  retries = 0
182
204
  begin
183
205
  response = http_post(body)
184
- parsed = JSON.parse(response.body)
206
+ status = http_status(response)
207
+ # The status first: a rate limit or gateway failure often carries a
208
+ # body that is not JSON (Helius answers 429 with "Too many requests").
209
+ raise http_error(status, response) if RETRYABLE_HTTP_STATUSES.include?(status)
185
210
 
186
- if parsed["error"]
211
+ parsed = parse_body(response, status)
212
+
213
+ if parsed.is_a?(Hash) && parsed["error"]
187
214
  error = parsed["error"]
188
215
  raise RpcError.new(error["message"], code: error["code"])
189
216
  end
217
+ raise http_error(status, response) if status && !(200..299).cover?(status)
190
218
 
191
219
  parsed["result"]
192
220
  rescue RpcError => e
193
- # Retry on rate limit (429) or blockhash expiry
221
+ # Retry an HTTP 429/5xx, a JSON-RPC rate limit, or blockhash expiry.
194
222
  if retries < MAX_RETRIES && retryable_error?(e)
195
223
  retries += 1
196
- sleep RETRY_DELAY * retries
224
+ sleep retry_delay(retries, response)
197
225
  retry
198
226
  end
199
227
  raise
200
228
  rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
201
229
  if retries < MAX_RETRIES
202
230
  retries += 1
203
- sleep RETRY_DELAY * retries
231
+ sleep retry_delay(retries)
204
232
  retry
205
233
  end
206
234
  raise RpcError.new("Network error: #{e.message}")
207
235
  end
208
236
  end
209
237
 
238
+ # The response's HTTP status as an Integer, or nil when the transport does
239
+ # not report one. A real Net::HTTPResponse always does; host test suites
240
+ # stub #http_post with objects that answer #body only, and those are read
241
+ # exactly as before.
242
+ def http_status(response)
243
+ return nil unless response.respond_to?(:code)
244
+
245
+ Integer(response.code.to_s, 10)
246
+ rescue ArgumentError
247
+ nil
248
+ end
249
+
250
+ # JSON.parse, except that a body which is not JSON raises HttpError (an
251
+ # RpcError) carrying the status, never JSON::ParserError.
252
+ def parse_body(response, status)
253
+ JSON.parse(response.body.to_s)
254
+ rescue JSON::ParserError
255
+ raise HttpError.new("RPC response is not JSON (HTTP #{status || 'status unknown'}): #{excerpt(response)}",
256
+ code: status)
257
+ end
258
+
259
+ def http_error(status, response)
260
+ HttpError.new("HTTP #{status} from RPC: #{excerpt(response)}", code: status)
261
+ end
262
+
263
+ # The start of the body, safe to interpolate: an upstream error page can be
264
+ # Latin-1 or gzip bytes (Net::HTTP hands those back as ASCII-8BIT), and
265
+ # mixing them into a UTF-8 message raises Encoding::CompatibilityError.
266
+ def excerpt(response)
267
+ text = response.body.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").strip
268
+ text.length > BODY_EXCERPT ? "#{text[0, BODY_EXCERPT]}…" : text
269
+ end
270
+
271
+ # The wait before retry number `attempt`: the linear backoff (1s, 2s, 3s at
272
+ # RETRY_DELAY = 1), or the server's Retry-After when it asks for longer,
273
+ # plus jitter.
274
+ def retry_delay(attempt, response = nil)
275
+ base = RETRY_DELAY * attempt
276
+ asked = retry_after_seconds(response)
277
+ base = asked if asked && asked > base
278
+ base + (rand * RETRY_DELAY * RETRY_JITTER)
279
+ end
280
+
281
+ # Retry-After as seconds (RFC 9110: delay-seconds or an HTTP-date), capped
282
+ # at MAX_RETRY_AFTER. nil when absent or unreadable.
283
+ def retry_after_seconds(response)
284
+ return nil unless response.respond_to?(:[])
285
+
286
+ value = response["Retry-After"].to_s.strip
287
+ return nil if value.empty?
288
+
289
+ seconds = if value.match?(/\A\d+\z/)
290
+ value.to_i
291
+ else
292
+ Time.httpdate(value) - Time.now
293
+ end
294
+ seconds.clamp(0, MAX_RETRY_AFTER)
295
+ rescue ArgumentError
296
+ nil
297
+ end
298
+
210
299
  def http_post(body)
211
300
  http = Net::HTTP.new(@uri.host, @uri.port)
212
301
  if @uri.scheme == "https"
@@ -235,7 +324,8 @@ module Solana
235
324
  end
236
325
 
237
326
  def retryable_error?(error)
238
- return true if error.code == 429 # rate limited
327
+ return RETRYABLE_HTTP_STATUSES.include?(error.code) if error.is_a?(HttpError)
328
+ return true if error.code == 429 # JSON-RPC rate limited
239
329
  return true if error.message.include?("Blockhash not found")
240
330
  false
241
331
  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.0"
19
+ VERSION = "0.12.2"
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.0
4
+ version: 0.12.2
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-17 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