Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: distinguish honest JSON-RPC errors from canned-response gaming o…
…n REST

Before: any JSON-RPC envelope received in response to a REST-shaped request
was classified as `rest_protocol_mismatch` with confidence 0.95 and routed
through `isDeceptiveResponsePattern` → CRITICAL signal → strike accumulation.

That is correct for canned successes like `{"jsonrpc":"2.0","result":[]}`
returned regardless of request shape, but it falsely punishes operators
whose backends only speak JSON-RPC: when PATH routes a REST request to
them they correctly reply with their native error format (e.g. -32601
Method not found). That is a capability mismatch, not gaming, yet the
heuristic treats both identically.

Operationally this surfaced as repeated 5-minute cooldowns across ~16
services on operators with JSON-RPC-only nodes — five honest-error events
per session was enough to cross the critical-strike threshold even though
their endpoints were otherwise healthy.

Fix: split the detection into two reasons:
- `rest_protocol_mismatch` — has `result` field (and not `result:null`):
  canned success, still deceptive, still triggers a critical signal.
- `rest_protocol_mismatch_error` — has `error` only (or `result:null`
  alongside `error`, the Geth/Bor/Erigon spec quirk): honest capability
  mismatch, NOT in the deceptive-pattern list, routed to major signal
  (no strike accumulation, no cooldown).

Both still ShouldRetry against a REST-capable peer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
  • Loading branch information
oten91 and claude committed May 5, 2026
commit 2dc300e804d5ff853286efbd6649c9cfc967e53b
1 change: 1 addition & 0 deletions gateway/http_request_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func TestIsDeceptiveResponsePattern(t *testing.T) {
{"jsonrpc_invalid_empty_array", true},
{"jsonrpc_empty_object_result", true},
{"rest_protocol_mismatch", true},
{"rest_protocol_mismatch_error", false},
{"cometbft_invalid_empty_array", true},
{"jsonrpc_fabricated_parse_error", true},
{"jsonrpc_wrapped_service_error", true},
Expand Down
19 changes: 19 additions & 0 deletions qos/heuristic/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,25 @@ func TestProtocolAnalysis_REST(t *testing.T) {
expectedRetry: true,
expectedReason: "rest_protocol_mismatch",
},
{
// Honest JSON-RPC error from a node that only speaks JSON-RPC: PATH routed
// a REST-shaped request to it and the node correctly replied with its
// native error format. This is a capability mismatch, NOT gaming, and
// should be classified separately so the gateway does not punish it as
// a deceptive response.
name: "REST receives JSON-RPC error — honest capability mismatch, not gaming",
response: []byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}`),
expectedRetry: true,
expectedReason: "rest_protocol_mismatch_error",
},
{
// Geth/Bor/Erigon spec quirk: error response with "result":null. Should
// still be treated as an honest error, not a canned success.
name: "REST receives JSON-RPC error with null result — honest capability mismatch",
response: []byte(`{"jsonrpc":"2.0","id":1,"result":null,"error":{"code":-32601,"message":"Method not found"}}`),
expectedRetry: true,
expectedReason: "rest_protocol_mismatch_error",
},
}

for _, tt := range tests {
Expand Down
32 changes: 30 additions & 2 deletions qos/heuristic/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,13 +470,41 @@ func analyzeREST(prefix []byte, fullLength int, requestPath string) AnalysisResu

// Protocol mismatch: a JSON-RPC response to a REST request.
// Real Cosmos SDK REST endpoints never return JSON-RPC envelopes.
// Gaming suppliers (e.g., spacebelt.xyz) return canned {"jsonrpc":"2.0","id":1,"result":[]}
// for ALL requests regardless of protocol type.
//
// Two sub-cases, treated very differently:
//
// 1) Canned success ({"jsonrpc":"2.0","result":...}) — supplier is returning
// a fabricated success regardless of the request shape. This is gaming
// (e.g. spacebelt.xyz returning {"result":[]} for everything). Flagged
// as deceptive — caller will treat as a critical signal.
//
// 2) Honest JSON-RPC error ({"jsonrpc":"2.0","error":{...}}) — supplier's
// backend speaks JSON-RPC only and replied with its native error format
// (e.g. -32601 Method not found) when PATH routed a REST-shaped request
// to it. The response is still a protocol mismatch (we should retry
// against a REST-capable peer), but it's NOT gaming — penalising it as
// deceptive triggers false-positive cooldowns on operators whose nodes
// simply don't support REST. Use a distinct reason so the gateway can
// route this to a major (non-strike) signal.
//
// EXCEPTION: CometBFT endpoints (e.g., /health, /status, /block) ALWAYS return
// JSON-RPC formatted responses even for GET requests. This is expected CometBFT
// behavior, not a protocol mismatch. Skip the check for CometBFT paths.
if bytes.Contains(prefix, jsonrpcVersionField) && !isCometBFTPath(requestPath) {
// Honest JSON-RPC error: has "error" and either no "result" or "result":null
// (the spec-quirk null-result-with-error pattern from Geth/Bor/Erigon).
hasJSONRPCError := bytes.Contains(prefix, jsonrpcErrorField)
hasJSONRPCResult := bytes.Contains(prefix, jsonrpcResultField) &&
!bytes.Contains(prefix, jsonrpcResultNull)
if hasJSONRPCError && !hasJSONRPCResult {
return AnalysisResult{
ShouldRetry: true,
Confidence: 0.95,
Reason: "rest_protocol_mismatch_error",
Structure: StructureValid,
Details: "REST request received a JSON-RPC error response — supplier likely doesn't support REST",
}
}
return AnalysisResult{
ShouldRetry: true,
Confidence: 0.95,
Expand Down