Skip to content

Commit 2a3fec8

Browse files
Toby1009claude
andcommitted
A failure we provoked is not a failure of the host
Following up on "the data seems thin": it was, and for two reasons worth separating. **Of the seed's 85 transfers, six are real and seventy-nine are zero-value address-poisoning dust.** A 6% signal-to-noise ratio, which the `poisoning` analyzer already reports and which is the honest reason the number looked both large and empty. **And two of those six were something I missed by hand.** Tracing manually I scanned forward from the exploit block, so I never saw the funding: 1,000 then 115,495 USDC arriving from 0xa16f5ba4…8968 two thousand blocks *before* the attack, and 116,495 going out to the attack contract. That address is not just the downstream hop I had it as --- it staked the attacker and took the proceeds back. The tool found it because it scans a window rather than following a hunch. Expanding the counterparties then failed, and the cause was mine. The span widens on success up to a ceiling; at 40,000 blocks one endpoint times out, and a timeout is indistinguishable from an unwell host, so it counted toward the circuit breaker. Four concurrent chunks each provoking one took down a node answering in under a second. `CircuitBreaker.forgive` takes back a single recorded failure, for a caller that caused it and is responding to it. Deliberately not `record_success`, which clears the tally: a host genuinely failing in between should not have its slate wiped by an unrelated probe. And the ceiling drops from 40,000 to 20,000, which is a measured value --- the same endpoint served 20,000 in seconds --- rather than an aspiration. The breaker call is guarded with `getattr`, because `client` is typed `Any` and a test double need not carry one. A scan must not fail because the thing it was being polite to is absent; two tests caught that immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXWQ25VgFeMuuMhGWjvdof
1 parent 8764f2a commit 2a3fec8

2 files changed

Lines changed: 38 additions & 2 deletions

File tree

src/chainscope/providers/jsonrpc.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,10 @@
7575
#: per step --- and a failed request that is a timeout is indistinguishable
7676
#: from an unwell host, which is what opened the circuit breaker mid-fetch.
7777
#: So the first chunk is cheap and cautious and the rest ride on what it
78-
#: learned.
79-
_SPAN_CEILING = 40_000
78+
#: learned. The ceiling is a measured value rather than an aspiration: one BSC
79+
#: endpoint served 20,000 blocks in seconds and timed out at 40,000, and a
80+
#: timeout costs far more than the request it saves.
81+
_SPAN_CEILING = 20_000
8082

8183
#: How far back a scan reaches when the caller does not say.
8284
#:
@@ -567,6 +569,17 @@ def _chunk(
567569
narrower = max(_SPAN_FLOOR, span // 2)
568570
self._span_cap = min(self._span_cap, narrower)
569571
self._span = narrower
572+
# This failure was ours: we asked for more than the endpoint
573+
# serves and are about to ask for less. Left on the breaker's
574+
# tally it counts toward declaring a healthy node unwell ---
575+
# measured, four concurrent chunks narrowing once each took
576+
# down an endpoint answering in under a second.
577+
# `getattr`: `client` is typed `Any` and a test double or an
578+
# alternative transport need not carry a breaker. A scan must
579+
# not fail because the thing it was being polite to is absent.
580+
breaker = getattr(self.client, "breaker", None)
581+
if breaker is not None:
582+
breaker.forgive(_host(self.url))
570583
continue
571584
at += span
572585
# It worked, so try more next time. Doubling rather than jumping

src/chainscope/transport/http.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,29 @@ def record_success(self, host: str) -> None:
219219
self._failures.pop(host, None)
220220
self._opened_at.pop(host, None)
221221

222+
def forgive(self, host: str) -> None:
223+
"""Take back one recorded failure.
224+
225+
For a caller that *provoked* the failure and is responding to it. A
226+
log scan discovers an endpoint's block-range limit by asking for too
227+
much and narrowing, and those refusals are probes rather than symptoms
228+
--- but a timeout looks identical to an unwell host from here, so they
229+
were counted, and four concurrent chunks each narrowing once spent the
230+
threshold on a node that was answering in under a second.
231+
232+
Deliberately not `record_success`: that clears the count entirely, and
233+
a host that is genuinely failing in between should not have its slate
234+
wiped by an unrelated probe.
235+
"""
236+
with self._lock:
237+
remaining = self._failures.get(host, 0) - 1
238+
if remaining > 0:
239+
self._failures[host] = remaining
240+
else:
241+
self._failures.pop(host, None)
242+
if remaining < self.threshold:
243+
self._opened_at.pop(host, None)
244+
222245
def record_failure(self, host: str) -> None:
223246
with self._lock:
224247
n = self._failures.get(host, 0) + 1

0 commit comments

Comments
 (0)