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 +4 -4
- data/CHANGELOG.md +15 -0
- data/lib/solana/client.rb +96 -6
- 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: 14ad71141b369bcdc3c22aad7644fca987cf924efd8aab247b753ba76a52dad3
|
|
4
|
+
data.tar.gz: 41c9eb34aecfb9e4c6d5ef6847f64928374549b3859a4ebd0a999e37e4c4188d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ed25519
|