Skip to content

Fix batch 2 of the v0.4.0 review: the HTTP bridge contract - #24

Merged
AsyncAssassin merged 12 commits into
mainfrom
fix/review-v0.4.0-batch-2
Sep 24, 2026
Merged

AsyncAssassin merged 12 commits into
mainfrom
fix/review-v0.4.0-batch-2

Conversation

@AsyncAssassin

@AsyncAssassin AsyncAssassin commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Batch 2 of the fixes from the merged review of v0.4.0: the HTTP bridge contract, B09 to B16, together with the items the review of #19 deferred to it. A short contract spec came first, so the fixes change the published contract (docs/architecture.md, HTTP Bridge Page Contract) as one piece instead of one by one. There is one commit per fix.

Fix What changes Was
B10 Every path and query value is a URI variable that RestClient encodes strictly, so a cursor reaches the bridge byte for byte. +, as in base64, arrived as a space, and a JSON cursor failed before the request, as a retryable outage
B16 Neither provider client follows a redirect. A 3xx is a configuration failure: the run fails at once and health turns DOWN. the bridge client followed it, with any token from base-url; Alchemy's 3xx was retried
B11 A read stops once the provider timeout cancels the fetch, and a refused, oversized, or error body is closed unread, for the bridge and for Alchemy. The stale pool-leak test is replaced (B29). Spring's close() drained the body, so a trickling or endless body held a provider thread; four took the whole pool
B09 A final page may omit its cursor at any height. an idle resync within one finality epoch failed terminally, even with a stored cursor
B13 A bridge 401 or 403 fails the run at once as configuration, with health DOWN. A 404 stays that address's error. every address failed behind a green health indicator
B14 A page that repeats an event with another direction or amount is refused before anything is written. An exact repeat stays idempotent. the first row was stored, the second conflicted, and the address stayed stuck
B12 A full provider pool requeues the run as a continuation (PROVIDER_BUSY, changeset 017), like a busy cursor lease, bounded by max-continuations-per-run. An account pass resumes at that address. five rejections under load failed the run
Credential header, deferred from #19 ASSET_SYNC_PROVIDER_AUTH_HEADER_NAME and ASSET_SYNC_PROVIDER_AUTH_HEADER_VALUE send the bridge credential in a header; no message, log line, or health detail quotes it. a token could only live in the path or query of base-url
Non-JSON body and Alchemy transport text, deferred from #19 Bytes Jackson cannot decode are that address's bad data. Alchemy transport failures are named by their kind, without the URL. retried as an outage; a custom endpoint template's secrets reached last_error
B15 Documented: a bridge that scans block windows returns a cursor on every page. undocumented

The contract section now lists every kind of bridge answer in one table. A test commit takes the "closed" ports of the provider tests from loopback: on macOS a wildcard ServerSocket(0) could get the port of a Tomcat bound to 127.0.0.1 and answer 404 instead of refusing the connection. The last commit applies the review of this PR (see below).

Verification

  • ./gradlew clean check: 344 tests, 0 failures, 2 skipped (the env-gated Alchemy live smoke), and no compiler warnings.
  • Every fix has a test that fails without it: reverting each change (18 reversions, the review fixes included) fails the test written for it.
  • Live on the jar of this branch, under prod against a stub bridge that takes its credential in X-Bridge-Key:
    • The bridge received the cursor c+1/== unchanged, the paged sync succeeded, and two idle resyncs without a cursor at the same height succeeded.
    • A 401 failed the run at once with health DOWN, and the next successful sync brought health back to UP.
    • A 302 failed the run with health DOWN, and the redirect target was never requested.
    • A page with one event in two directions failed and stored no rows.
    • A body trickling past the 2-second provider timeout requeued its run. The next sync on a one-thread provider pool succeeded 4 seconds later, and the bridge saw the slow client leave.
    • The credential was sent on all 10 requests and appeared 0 times in the log.
  • demo on the same jar: an eth-sepolia address syncs through the simulator (SUCCEEDED), and an empty SERVER_ADDRESS still stops the start.

Review

One /code-review round ran on this PR. Its findings that held are fixed in 1818dda, without another round:

  • A self-transfer split across a page boundary still stored its first row; the conflict now names the event, and the docs no longer claim more.
  • A full pool used a backoff that grows with every claim and never ends; it is now a bounded PROVIDER_BUSY continuation.
  • An account sync asked the bridge once per address after a 401, 403, or redirect; a configuration failure of the whole provider now ends it at once, and a gap of one address stays isolated.
  • An empty ASSET_SYNC_PROVIDER_AUTH_HEADER_NAME no longer stops the start.
  • Alchemy's cause chain is scrubbed before URLs are cut.
  • Boot's request factory builder replaces the subclass.
  • The docs are fixed. A page that breaks a rule the service checks leaves provider health alone. A redirect on the Alchemy startup probe stops the process.

Recorded for batch 4 rather than done: B41, closing responses unread and disconnecting from the timeout path in the request factory itself instead of at each call site, and B42, one status table shared by both adapters.

Left out

  • Response headers that trickle in are bounded only by the read timeout of each read (documented). A full deadline needs another HTTP client or a disconnect() from the timeout path.
  • B18 (account pass classification, including a revoked credential), B37, and B38 are batch 3.

The cursor went into the request URI as part of the template. A `+`,
common in base64, stayed as is and the bridge decoded it as a space;
braces, as in a JSON cursor, were taken as template variables, and the
request failed before it was sent with "Not enough variable values",
classified as a retryable provider outage. Every path and query value
is now a URI variable, which RestClient encodes strictly, so the bridge
gets its cursor back byte for byte.
SimpleClientHttpRequestFactory follows redirects for GET, so the bridge
client went wherever a 3xx pointed, with any token from base-url. Both
provider clients now share one request factory that keeps the
configured timeouts and never follows a redirect. A 3xx from the bridge
or Alchemy is a ProviderConfigurationException: the run fails at once,
and provider health turns DOWN, since the operator's endpoint is wrong
for every address and retries cannot fix it. Alchemy's 3xx was retried
as an outage.

The Alchemy client tests now build the production RestClient.
A slow provider body held a provider thread long after the provider
timeout. future.cancel(true) interrupts the thread, but a blocked
socket read ignores the interrupt, and a body that trickles in keeps
every read under the read timeout; readBounded never looked at the
interrupt. Worse, Spring's SimpleClientHttpResponse.close() drains the
rest of the body before closing it, so even the byte limit and error
statuses left the thread reading an oversized or endless body. Four
such responses took the whole pool.

readBounded now checks the interrupt before every read and closes the
body unread on any failure, and both adapters close the bodies of
error statuses unread, so Spring's drain fails fast. The thread comes
back within one read timeout of the cancel. Response headers that
trickle in stay bounded only by the read timeout of each read.

The provider pool test that claimed to cover a leak exercised the fake
provider only; the slow-response tests replace it.
validatePage refused a final page without nextCursor unless it carried
events or a safe or latest height above the stored one, and failed the
run terminally otherwise. A bridge that answers "nothing new" without
repeating its cursor did exactly that whenever two syncs of an address
fell into one finality epoch, even with a stored cursor. The rule
guarded against losing the cursor, which the service has not done since
it started keeping the stored cursor after an empty final page and
sending the checkpoint with every request, so it is gone. Stored
heights are kept as a maximum, so a lower height changes nothing.
Since 0.4.0 every 4xx from the bridge counted as bad data of one
address: the run failed terminally, but health stayed UP. A wrong or
revoked bridge credential therefore failed every address behind a green
health indicator, while Alchemy's 401 and 403 turn health DOWN. The
bridge's 401 and 403 are now ProviderConfigurationException, terminal
without retries and DOWN until a fetch succeeds. A 404 and the other
4xx stay the error of that one address: a bridge may not know an
address.
The natural key of an observed transaction has no direction, so a
bridge that sends a transfer of the address to itself as an INBOUND
and an OUTBOUND row of one log gave a page whose first row was stored
and whose second failed as a conflict with stored immutable fields.
The checkpoint stayed, and every later sync failed on the same page.
validatePage now refuses a page that repeats (txHash, eventIndex) with
another direction or amount, before anything is written, and names the
event. An exact repeat stays harmless, as ingest is idempotent.
When the provider pool had no free thread, the run failed with
SyncCapacityExceededException through requeueFailure, which spends a
failure_attempt, so five rejections under load failed the run although
the provider was never asked. It now goes through the budget-neutral
requeue that a full worker pool already uses, with backoff by claim
count.
0.4.1 refused a user name or password in base-url, because the HTTP
client never sends them, which left the path or query of base-url as
the only place for a bridge token. asset-sync.provider.auth-header-name
(Authorization by default) and auth-header-value now put the credential
in a header of every bridge request. The value is a secret: the
properties mask it in toString, and a rejected credential names the
property, never the value. A header name that is not an HTTP token or
a value with a line break stops startup, since the request could not
carry it.
Jackson reads a provider body that is already in memory, yet bytes it
cannot decode as text, such as invalid UTF-32, raise
CharConversionException, an IOException rather than a
JsonProcessingException. RestClient wrapped it as an I/O error, so the
bridge and Alchemy reported it as a transport failure: retried as an
outage with health DOWN. Both adapters now treat it as bad data of that
address.

Alchemy transport failures are named by their kind, like the bridge's,
through a shared ProviderHttpSupport.transportKind. Their text used to
quote the request URL with the key scrubbed, so anything else a custom
endpoint template held reached last_error, health, and the logs. The
WARN line keeps the cause chain with every URL cut out.
The contract section lists every kind of bridge answer in one table:
its class, what happens to the run, provider health, and the retry
budget. It also states that a bridge scanning block windows must
return a cursor on every page, because the service only passes the
checkpoint of the last ingested event. The failure modes name the
redirect as a configuration failure, the testing guide lists the
contract tests, and the changelog entries of this batch follow the
order of the contract.
A ServerSocket(0) binds the wildcard address, and on macOS it can get
a port that a listener bound to 127.0.0.1 already holds, such as the
Tomcat of a cached local or demo test context. The "closed" port then
answered with that server's 404 instead of refusing the connection,
and the transport test failed. The ports now come from a socket bound
to the loopback address the clients connect to.
The regression review of #24 confirmed these, two by probes:

- A transfer of the address to itself whose two rows fall on either
  side of a page boundary still stored the first row and failed with
  the old conflict text. The conflict now names the event and the
  fields that differ, and the docs say that only a repeat inside one
  page fails before anything is written. The in-page message also fits
  the 240 characters of last_error with a real transaction hash.
- A full provider pool went through the budget-neutral requeue, whose
  backoff grows with every claim (731 s at the eighth) and never ends.
  It is now a continuation with the new reason PROVIDER_BUSY
  (changeset 017): retried after cursor-lease-retry-delay, bounded by
  max-continuations-per-run, and an account pass resumes at the
  address that met it.
- Account syncs isolated every ProviderConfigurationException, so a
  bridge 401, 403, or redirect cost one request per address. A
  configuration failure of the whole provider now ends the run at
  once, while a gap of one address stays isolated, including a chain
  disabled at ingest, now an AddressConfigurationException.
- An empty ASSET_SYNC_PROVIDER_AUTH_HEADER_NAME stopped every start. A
  blank name now means Authorization, and the name is checked only
  when there is a credential to send.
- Alchemy's WARN cause chain cut URLs before scrubbing the key, so a
  key with a quote could leak its tail. It scrubs first now.
- The request factory comes from Boot's ClientHttpRequestFactoryBuilder
  with Redirects.DONT_FOLLOW instead of a subclass.
- Docs: a page that breaks a rule the service checks leaves provider
  health alone instead of setting lastDataError; a redirect answering
  the Alchemy startup probe stops the process; the removed final-page
  rule and the old capacity accounting are gone from architecture.md,
  database.md, and the page KDoc.
- The bridge contract tests shut down their server executor.
@AsyncAssassin
AsyncAssassin merged commit ece0e7b into main Sep 24, 2026
1 check passed
@AsyncAssassin
AsyncAssassin deleted the fix/review-v0.4.0-batch-2 branch September 24, 2026 10:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant