Skip to content

feat(connectors): add the HTTP source webhook gateway connector - #3798

Open
mlevkov wants to merge 98 commits into
apache:masterfrom
mlevkov:http-source-connector
Open

feat(connectors): add the HTTP source webhook gateway connector#3798
mlevkov wants to merge 98 commits into
apache:masterfrom
mlevkov:http-source-connector

Conversation

@mlevkov

@mlevkov mlevkov commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Implements the webhook gateway accepted in #3039. Iggy currently has no way to receive a webhook; every provider that pushes events over HTTP needs a separate service in front whose only job is to accept a POST and republish it. This connector removes that hop.

Shape

One plugin .so is loaded once regardless of how many [[source]] entries reference it, so the listener lives in a process-global registry keyed by listen address rather than on any single instance. The first open() binds the public and admin ports; later opens validate their body limit, admin address, management token and instance name against the running listener before joining; the last close() releases both ports, which the runtime's stop-then-start restart flow depends on. A single port can therefore serve many providers, each routed to its own topic.

  • POST /topics/{topic_path} — named path, one per instance, guarded by an optional bearer token
  • POST /e/{endpoint_id} — secret path, 128 bits in the URL itself, with optional per-endpoint bearer or HMAC on top

Requests resolve against an ArcSwap route table rebuilt whole on every control-plane change, so one atomic load yields both the endpoint's auth rules and the destination bridge. HMAC is verified over the raw body in constant time. Revoked endpoints answer 404 alongside paths that never existed, so a leaked URL cannot be used to confirm it was once live.

Endpoints can be registered, re-keyed and revoked at runtime through a token-guarded API on the admin listener (absent entirely when no token is configured). Those endpoints ride the SDK's ConnectorState, and revocation writes a tombstone that outranks TOML on restore, so a stale config file cannot resurrect an endpoint an operator revoked.

Delivery semantics

Best-effort in both directions, and the README leads with it rather than burying it. HTTP 200 means accepted into an in-memory buffer, not durably stored; both the loss and duplicate windows are enumerated with what mitigates each. A full bridge answers 429 with Retry-After rather than blocking.

Depends on #3795 for the full backpressure story. The bridge is bounded today, so 429 fires on an arrival burst the poll loop cannot keep up with. What is missing is the coupling: until the bounded runtime forwarding channel lands, poll() drains into an unbounded channel, so a slow Iggy does not propagate back into 429. The README documents this rather than implying the chain is complete.

State is attached only to an empty batch. The runtime saves state solely on the success branch of the Iggy send, and an empty send always succeeds, so a management mutation cannot be lost to an unrelated send failure.

Deviations from the design doc, all deliberate

  • topic_path and instance_name are explicit config fields. Only plugin_config crosses the FFI, so the plugin cannot see its connector key or its [[streams]] entry. Same resolution the design already accepted for the named path.
  • The 503 "instance closing" row collapses into 404. Deregistration is atomic under the registry lock, so no observable window exists. 503 survives on GET /health when no instance is joined.
  • Stripe and Twilio HMAC are not supported. This validator takes a hex digest of the body behind a fixed prefix, which covers GitHub and most generic partner webhooks. Stripe signs {timestamp}.{body} behind a compound header and Twilio signs URL plus sorted params as base64. The design's example config showed Stripe working; it would not have. Documented with the forward-and-verify-downstream workaround, and the shipped example uses bearer instead.
  • schema = "raw" is mandatory and now stated as such — the connector always produces raw bodies, and a JSON encoder rejects every message.

Testing

110 unit tests and 5 integration tests. The integration suite needs no containers, since the connector is itself the HTTP server and the test client is the webhook sender: it covers a signed POST reaching Iggy byte-for-byte with headers intact, two instances sharing one listener, the register/POST/revoke flow, a dynamic endpoint with its secret surviving a connector restart, and a revoked static endpoint staying dead across a restart that re-reads the TOML still declaring it.

No integration test for 429 under saturation: with a healthy Iggy the poll loop drains the bridge continuously, so provoking a full bridge from outside races the drain. Two unit tests cover it deterministically instead.

Verification

cargo fmt, cargo sort --check --no-format --workspace, cargo clippy --all-features --all-targets -- -D warnings (connector and integration), cargo test, taplo fmt --check, hawkeye check, markdownlint, and the trailing-whitespace/newline scripts all pass locally.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Thanks for the PR. It is labeled S-waiting-on-review and queued for review.

Slash commands (own line, regular comment) move it around the queue:

  • /ready - back to S-waiting-on-review after addressing feedback
  • /author - flip to S-waiting-on-author while you finish changes
  • /request-review @user-or-team - request a reviewer

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Aug 2, 2026
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.26836% with 94 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.64%. Comparing base (91c3ca5) to head (13009f3).

Files with missing lines Patch % Lines
...e/connectors/sources/http_source/src/management.rs 94.92% 45 Missing and 3 partials ⚠️
core/connectors/sources/http_source/src/state.rs 97.50% 15 Missing and 2 partials ⚠️
core/connectors/sources/http_source/src/routes.rs 94.78% 9 Missing and 7 partials ⚠️
core/connectors/sources/http_source/src/metrics.rs 95.00% 10 Missing and 2 partials ⚠️
core/connectors/sources/http_source/src/auth.rs 99.56% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3798      +/-   ##
============================================
+ Coverage     86.35%   86.64%   +0.28%     
  Complexity     1455     1455              
============================================
  Files          1259     1267       +8     
  Lines        205992   211595    +5603     
  Branches     171193   176811    +5618     
============================================
+ Hits         177882   183334    +5452     
- Misses        23668    23751      +83     
- Partials       4442     4510      +68     
Components Coverage Δ
Rust Core 87.60% <96.26%> (+0.31%) ⬆️
Java SDK 67.58% <ø> (ø)
C# SDK 77.04% <ø> (+0.07%) ⬆️
Python SDK 91.34% <ø> (ø)
PHP SDK 85.65% <ø> (ø)
Node SDK 96.26% <ø> (+0.08%) ⬆️
Go SDK 69.43% <ø> (+0.03%) ⬆️
Files with missing lines Coverage Δ
core/connectors/sources/http_source/src/lib.rs 97.39% <ø> (ø)
core/connectors/sources/http_source/src/server.rs 94.88% <ø> (ø)
core/connectors/sources/http_source/src/types.rs 100.00% <100.00%> (ø)
core/connectors/sources/http_source/src/auth.rs 99.56% <99.56%> (ø)
core/connectors/sources/http_source/src/metrics.rs 95.00% <95.00%> (ø)
core/connectors/sources/http_source/src/routes.rs 94.78% <94.78%> (ø)
core/connectors/sources/http_source/src/state.rs 97.50% <97.50%> (ø)
...e/connectors/sources/http_source/src/management.rs 94.92% <94.92%> (ø)

... and 42 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mlevkov

mlevkov commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

/request-review @hubcio

@github-actions
github-actions Bot requested a review from hubcio August 2, 2026 20:53
@mlevkov

mlevkov commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit closing the coverage gaps worth closing. Kept it as a
separate commit rather than amending, so the delta is reviewable on its own.

Patch coverage was 94.52% (2847 hits / 131 misses / 34 partials). Reading the
per-line report rather than the percentage turned up one gap that mattered:

Both existing management auth tests reached only GET /admin/endpoints.
Coverage regions are per call site, so denied() looked fully covered while the
guard inside register_endpoint, rotate_secret, revoke_endpoint, and
get_endpoint had never taken its rejection branch. Deleting the check from
revoke_endpoint left the suite green — it now fails with 204 instead of 401.

Also newly pinned: republish_or_close answering 500 rather than reporting a
revoke that never reached the route table; the endpoint-id route conflict keeping
the id to an 8-char prefix in its message; the hmac_algorithm() mapping; a
revoked dynamic endpoint's tombstone surviving restore with no static
counterpart; and the two message_headers drop branches.

Every new test was mutation-checked — each was confirmed to fail against a
deliberate break of the behaviour it claims to pin, rather than assumed to work
because it passed.

One production line changed: ServerState::new is now pub(crate) so the
management tests can build a listener-less state and provoke the republish
failure. The other 328 added lines are tests.

Deliberately left uncovered, and why:

  • SharedServer::shutdown's abort branch — needs a connection wedged past the
    5s timeout, so pinning it costs a 5s+ unit test.
  • The bind-failure and draining-listener-join races.
  • Metrics::encode's encoder-failure branch.
  • ~30 of the remaining missed lines are tracing-macro arguments. tracing
    skips formatting when no subscriber enables the level, so those regions never
    execute under cargo test even where the surrounding branch is fully
    exercised. Installing a subscriber would raise the number without testing
    anything, so I left it alone — worth knowing when reading the residual figure.

Metrics::default and EndpointRegistry::is_empty were uncovered and have no
callers, but they can't be deleted: clippy::new_without_default and
clippy::len_without_is_empty require them. Covered with one assertion each.

Full local gate green (fmt, sort, workspace clippy -D warnings, build, 124 unit
tests, 5 http_source integration tests, taplo, hawkeye, typos, trailing
whitespace/newline).

@github-actions

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed in 7 days if no further activity occurs.

If you need a review, please ensure CI is green and the PR is rebased on the latest master. Don't hesitate to ping the maintainers - either @core on Discord or by mentioning them directly here on the PR.

Thank you for your contribution!

@github-actions github-actions Bot added the S-stale Inactive issue or pull request label Aug 17, 2026
@mlevkov
mlevkov force-pushed the http-source-connector branch from 6d3bcac to a358ddf Compare August 17, 2026 02:10
@github-actions github-actions Bot removed the S-stale Inactive issue or pull request label Aug 17, 2026
@mlevkov
mlevkov force-pushed the http-source-connector branch from a358ddf to 50f8258 Compare August 21, 2026 02:40
@mlevkov

mlevkov commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master and followed up on af9ce9548 (#3855, source batch acknowledgments), which landed after this PR was opened and changes what this connector owes the runtime.

on_batch_result has a default no-op in the Source trait, so this compiled and stayed quiet, but the default is documented as suitable only for sources with no staged or destructive work. poll() here drains the crossfire bridge destructively, so a NACK had nothing to act on: the events existed nowhere else and were simply gone. The connector now holds each batch until the runtime acks it and replays it on the next poll() otherwise, which is what lets it recover a failed send instead of absorbing it.

Nothing is ever abandoned. Answering 200 already told the sender this gateway owns the event, and the only honest way to shed load is the 429 the handlers return once the bridge fills, which senders retry. Dropping a staged batch would trade that bounded, visible backpressure for silent loss that grows with the length of the outage. The usual case for a give-up bound does not apply here either: oversized bodies are rejected with 413 before a handler runs, headers are clamped on accept, and Schema::Raw cannot fail to decode, so a permanently undeliverable batch is not reachable from the accept path.

Two existing poll tests were asserting the pre-ack behaviour by polling twice without acknowledging, and now ack between the two. Four new tests cover replay on NACK, release on ACK, an empty state-only batch not staging a replay, and the never-abandon property itself so it cannot be quietly reversed.

One thing that needs a decision outside this PR: #3941. The SDK stops a source after five consecutive NACKs, which is about 1.5s of backoff plus five send rounds, so roughly two seconds of broker unavailability ends the poll task. That is correct for a source that can re-read its cursor and wrong for this one, whose bridge is in memory: the listener keeps accepting, the bridge fills to buffer_capacity, and a restart loses up to 10,000 events that already received a 200. The stop is also unobservable, since the runtime's forwarding loop stays parked and iggy_connectors_sources_running keeps counting the source.

There is a loophole that would let a plugin survive this (an empty batch always acks, which resets the breaker's counter) and I have deliberately not used it, because it defeats an SDK safety mechanism from inside a plugin. The README states the residual window and points at #3941 rather than working around it here.

README.md's producer-failure loss window was also stale: it said there is no feedback channel back to the handler, which #3855 made false.

@mlevkov

mlevkov commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

/request-review @hubcio

@hubcio hubcio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two things block, both silent security-control failures with small fixes:

  • a state-only batch that gets NACKed loses the revocation permanently, while the admin api reports submitted: true (lib.rs on_nack)
  • the registry fails closed per field and open in aggregate, so any decode failure resurrects every revoked endpoint (state.rs restore)

separately, README lines 14, 230 and 232 promise guarantees the code does not provide. those want correcting in this PR even if the code fixes land later.

the rest of the inline comments are smaller but mostly cheap. one is a real footgun beyond this connector: an out-of-range buffer_capacity aborts the whole connectors runtime through an extern "C" unwind, and a plugin's own validate() cannot stop it.

smaller things, no need for separate threads:

  • server.rs body.to_vec() on the accept path deserves a why-comment. it is correct as written - Vec::from(body) would hand back hyper's read buffer instead, several times the body at typical webhook sizes, in a bridge bounded by message count. easy for the next reader to "optimise" away
  • metrics.rs maps 3xx into the 2xx bucket
  • types.rs expiry uses unwrap_or_default() on the clock, so a bad clock fails open
  • the two 429 tests run at buffer_capacity = 1, which selects crossfire's single-slot channel rather than the array one every real deployment uses. set 2
  • config.toml ships plausible-looking secrets on 0.0.0.0 where the example_config/ twins use placeholders with a warning. same snippet is duplicated in the README
  • auth.rs rebuilds a constant hmac key on every compare - hoist it. the double-hmac itself is right, verify_slices_are_equal is deprecated
  • server.rs re-parses three constant HeaderKeys per request, while the crate precomputes forward_headers for exactly that reason
  • types.rs byte-slices at index 8 without a boundary check. unreachable today, but chars().take(8) costs nothing
  • lib.rs has a duplicate polled-count log block, and a doc line claiming one atomic load per request on a method with no request-path caller
  • dead code worth dropping: QueuedMessage.received_at is never read, EndpointRegistry::len/is_empty and HttpSource::shared() have no non-test callers. the pub mod surface is also much wider than the siblings' for a cdylib nothing links as a lib
  • the three hand-written EndpointOrigin/EndpointState string maps want one const fn as_str(&self) -> &'static str. not Display - the metrics encoder wants a &str and Display would allocate on the scrape path
  • nothing posts to /topics/{topic_path} in the integration tests

one refactor worth taking even though nothing is reachable today: make staged a std::sync::Mutex. it is never held across an await at any of its five uses, so it removes five awaits, makes three functions sync, and closes the window where a shutdown during stage() would lose a batch with no metric. do not extend that to receiver, which is held across recv().await and has to stay tokio.

two follow-ups that are not yours:

  • runtime/src/manager/source.rs does not close the plugin when setup_source_producer fails after init_source, while the boot path does. inert for every existing source, but this is the first one that binds a public port in open(), so a single transient failure leaves a bound listener and a joined instance behind and wedges the connector until the process restarts. either that gets fixed, or a same-name rejoin should evict the stale instance
  • source_connector! emits a bare use dashmap::DashMap; where the sink macro already goes through connector_macro_support. worth a ticket. to be clear, the dashmap dep and machete ignore in this PR are correct and needed

Comment thread core/connectors/sources/http_source/src/lib.rs
Comment thread core/connectors/sources/http_source/src/state.rs Outdated
Comment thread core/connectors/sources/http_source/README.md Outdated
Comment thread core/connectors/sources/http_source/src/lib.rs Outdated
Comment thread core/connectors/sources/http_source/src/lib.rs
Comment thread core/connectors/sources/http_source/src/routes.rs
Comment thread core/connectors/sources/http_source/src/lib.rs Outdated
Comment thread core/connectors/sources/http_source/src/server.rs Outdated
Comment thread core/connectors/sources/http_source/src/management.rs Outdated
Comment thread core/integration/tests/connectors/http/http_source.rs
@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Aug 24, 2026
@mlevkov
mlevkov force-pushed the http-source-connector branch from 886a096 to c8ba475 Compare August 26, 2026 22:57
@mlevkov

mlevkov commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

All of the review is addressed. Two of your points I have to push back on, both with evidence, and one gap in my own earlier response that you were right about twice over.

The crossfire capacity claim is not correct, though the ask was. You wrote that buffer_capacity = 1 "selects crossfire's single-slot channel rather than the array one every real deployment uses". crossfire::mpsc::bounded_async<T>(size) is declared as returning (MAsyncTx<Array<T>>, AsyncRx<Array<T>>), so the flavour is fixed by the signature and cannot vary with size; the one-slot flavour is mpsc::One<T>, a distinct type reached through a different constructor. The only documented special case is size 0, treated as 1.

Both tests still moved to 2, because the reason holds even though the mechanism does not: at capacity 1 the ring is degenerate, so the test cannot distinguish a real capacity bound from an off-by-one. I had copied your wording into two comments before checking it, which is on me, and they now state the corrected reason.

The leave() guard does not have the consequence you described. You said an unguarded teardown "wipes the metrics of the sibling that legitimately owns that name". forget_instance removes only gauges, and Metrics::encode re-derives every gauge from the live instances at the start of each scrape, so a still-joined sibling is never observably affected. I confirmed it by mutation: with the guard deleted the whole suite stays green, and the test I first wrote to catch it passed either way, so I deleted that test rather than keep one that cannot fail. The guard stays, because not rebuilding the route table for nothing and not logging a deregistration that never happened are worth the four lines, and the comment now says that instead.

You were right twice about hmac_header and I only fixed it once. Your comment said POST /admin/endpoints has the same gap, and I added the check to the static config only. The dynamic path is the worse half: HeaderMap::get answers None for a name it cannot parse rather than failing, so a malformed one mints an endpoint that 401s every signed request forever, gives no clue why, and survives a restart because the field carries no validation on the restore path either. Fixed with a 400 and a test.

Also fixed since the review: a state-only NACK no longer leaves a revocation unretried; restore fails open() instead of serving TOML without its tombstones; the management guard is a router layer so an unauthenticated request never reaches the body parser; expired endpoints answer 404; revocation clears the secret before it is persisted; received_at_micros no longer stamps the epoch on a broken clock; mutate_registry no longer arms a state write per no-op 404, which was an unbounded remote write on the #3940 backend from an authenticated caller; and instance_name can no longer claim the reserved unrouted metric label. Seven comment claims that were factually wrong are corrected, including three sites asserting "an empty send cannot fail", which #3940 made false by short-circuiting the send stage before the producer.

Three things I have verified and deliberately not changed, since they are yours to call:

  • The public named path buffers the request body before the bearer check, exactly the ordering you flagged in management.rs. Fixing it properly means ServerState carrying the body limit and restructuring the handler to authorize before reading, so I did not want to reshape the request path without your say.
  • submitted still over-reports between a failed state save and the retry that succeeds. Clearing it means rewriting the registry from a sync path that cannot take the async writer lock, and risks discarding a revocation that landed in between. Documented rather than fixed.
  • The setup_source_producer cleanup asymmetry you flagged as not mine is real and worse than it looks: manager/source.rs returns early without iggy_source_close, and details.info.id is only assigned after that fallible step, so a later stop closes a stale id and the orphaned instance keeps answering 200 into a bridge nothing drains. Happy to send that runtime fix if you want it.

@mlevkov

mlevkov commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Ready for another look. Everything in the review is addressed, and I put the branch through two independent review rounds afterwards, which found ten more defects. Two of those changed behaviour you already reviewed, so they are first.

/health now requires every instance on the listener to be polling, not any. The gate exists because the SDK stops a poll task after five consecutive NACKs without calling close(), leaving the instance joined and accepting into a bridge nobody drains. any only delivered that for a single instance, and the shared listener is the topology the whole SERVERS and RouteTable design is for: with two instances on one address, a stopped poll task on one kept the address in rotation while its webhooks were accepted and lost. One load balancer fronts both, so it can only take them in or out together; shedding the healthy sibling costs availability a sender recovers by retrying, where the alternative loses requests already answered 200.

submitted is now derived rather than read off the endpoint. take_dirty_state marks the whole registry submitted before the state leaves, and a NACK re-arms the flush without clearing it, so the flag claimed a revocation was durable when nothing had been written. It is now paired with whether a flush is still owed: one relaxed load, no registry write, no lock. I had previously written in the README that this could not be fixed without rewriting the registry from a sync path; that was wrong, because the flag never needed to be cleared in place.

A real race also turned up in code added for your review: PollGuard::drop cleared the in-flight flag before writing the timestamp, and poll_is_live reads the two as an unsynchronised pair, so a reader landing between them saw "not polling" beside the constructor's zero and /health answered 503 for a healthy source. Writing the timestamp first closes it.

The rest of what the rounds found, all fixed: POST /admin/endpoints accepted an unparsable hmac_header (you called this out and I had only fixed the static half; the dynamic path is the worse one, since it mints an endpoint that 401s forever and survives a restart, and restore now warns when older state carries one); mutate_registry armed a state write per no-op, so repeatedly revoking a tombstone drove one serialization and one remote write per 404 from an authenticated caller; received_at_micros stamped the epoch on a clock behind 1970; instance_name could claim the reserved unrouted metric label; strip_bearer rejected the 1*SP RFC 9110 permits; poll() reserved max_batch_size on every idle call; and StatusClass's redirect arm had no assertion behind its catch-all, so every 3xx could have silently metered as 5xx.

Ten comment and doc claims were factually wrong and are corrected. The one worth naming is "an empty send cannot fail", which appeared in three places including on mark_submitted: #3940 gave the runtime a state-storage short-circuit that NACKs before the producer is called, so on_nack's re-arm is load-bearing rather than defensive, and anyone trusting the old comment would have deleted it.

Four things I deliberately have not changed, because they are yours to call:

  • The public named path buffers the request body before the bearer check, the same ordering you flagged in management.rs. Fixing it properly means ServerState carrying the body limit and restructuring the handler to authorize first, so I did not want to reshape the request path unasked.
  • close() has no flush hook, so a state handover lost to a shutdown race is unrecoverable. That looks inherent to the Source contract rather than something this connector can fix.
  • http_source_request_duration_seconds starts its clock after axum has run the extractors, so it cannot see body read time. Measuring accept-to-200 needs a tower layer; for now the docs say what the number actually is instead of what it was meant to be.
  • The setup_source_producer cleanup asymmetry you flagged as not mine, which is worse than it looked: manager/source.rs returns early without iggy_source_close, and details.info.id is assigned only after that fallible step, so a later stop closes a stale id and the orphaned instance keeps answering 200 into a bridge nothing drains. Say the word and I will send that runtime fix separately.

144 unit tests and the six connectors::http::http_source integration tests pass; every new guard was mutation-checked, and two tests that could not fail were deleted rather than kept.

@mlevkov

mlevkov commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

/ready

@mlevkov

mlevkov commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

/request-review @hubcio

@github-actions github-actions Bot removed the S-waiting-on-author PR is waiting on author response label Aug 27, 2026
The named path answered 404 twice for two different things. An unconfigured path
is permanently absent and 404 is right. A configured path whose instance stopped
serving it while the body was still arriving is transient, and that caller had
already passed bearer auth, so 404 told it the resource was gone. Conventional
clients stop retrying on 404, which turns a republish window into silently
dropped traffic that would have succeeded a moment later. `management.rs`
already answers 503 with "instance is closing" for this same condition.

The secret path keeps 404 for both cases and the comment now says why: its
caller is unauthenticated, so separating "wrong id" from "busy" would confirm
which endpoint ids exist.

This nearly made the identity guard untestable. `enqueue` on a stranger already
answers 503 for a disconnected bridge, and the swap test asserted 404 precisely
to tell those apart, so moving the guard to 503 would have made it pass whether
or not the guard was there. The rival's bridge is now kept live through a new
`test_support::live_instance`, so an unguarded request answers 200 with the
message in the stranger's bridge. Removing the `Arc::ptr_eq` now yields
`HTTP/1.1 200 OK` and the test fails on it, which is a stronger assertion than
the old one: it proves delivery happened rather than that a status differed.
`revoke()` clears `auth_secret` for a stated reason: the handler answers 404 on a
revoked entry before `authorize()` runs, nothing compacts the registry, so
keeping the secret would write a leaked credential back to the state file
indefinitely. An expired endpoint is refused the same way and just as early, and
kept its secret anyway.

`restore` now drops it, and says how many it dropped. That was also the only
restore-time fault with no startup signal; the two sibling cases already warn.

The endpoint keeps its slot and stays `Active`. Reclaiming it would make
endpoints disappear on a clock condition rather than an operator action, which is
a larger promise than this needs to make, so `max_endpoints` accounting is
unchanged and revoking is still how a slot is freed.

Restore only, and the README says so. Clearing it the moment an expiry passes
needs a clock-driven sweep mutating the registry from `poll()`, which is a
mechanism rather than a fix. An endpoint that expires while the instance runs
keeps its secret on disk until the next restart; revoking clears it at once.

One interaction this created, caught by writing the round-trip test: with the
secret gone, the next restore sees Bearer with no secret, which is exactly what
`MissingSecret` reports. That would blame the credential for a refusal expiry
already explains, so the admission check is skipped for an endpoint whose expiry
has passed. Three tests, and the mutant that keeps the secret fails two of them
while the unexpired case stays green.
`Published` exists so no reader can see the instance set and the routes
disagree, and its doc comment says so. `handle_admin_health` loaded it twice
anyway, once for the per-instance array and again for the status, so the two
halves of one response could describe different publishes. It also read the
clock once per instance and once more for the status. `handle_health` took one
guard and its comment explained why, which made this the third fix-the-sibling
case on the branch.

The readiness predicate was written out verbatim in both handlers. It is now
`Published::is_ready(now)`, which puts the rule on the value that already
promises a consistent view and means the two endpoints cannot answer differently
about the same listener.

`/admin/health` now takes one snapshot and one clock read for the whole answer.
`Inadmissible::message()` returns `&'static str` so a 400 body costs no
allocation, which means the three ceilings appear in it as literals with nothing
tying them to `MAX_AUTH_SECRET_LEN`, `MAX_HMAC_HEADER_LEN` and
`MAX_HMAC_PREFIX_LEN`. Raise a const and the operator is told the old number.

The existing ceiling tests cannot catch that, and it is worth being precise about
why: they build their oversized inputs from the consts, so raising one moves the
test and the code together and leaves only the message lying. Confirmed by
raising `MAX_HMAC_HEADER_LEN` to 512: the two existing hmac_header tests stayed
green and only the new one failed.

`management.rs` also hand-wrote "auth_secret must not be empty" three lines from
the `is_usable` it shares with `admit_endpoint`. That check is `MissingSecret`
under another name, so it now uses that variant's message and there is one
wording instead of two.
The re-resolve narrowed the window where a handler enqueues into an instance that
has left, but could not close it. The gate passes, and `enqueue` then checks
`is_full`, builds the header map and copies the body before `try_send`, all of
which `leave()` can overtake. A message landing after `leave()` counted the
bridge is never drained, and the sender was told 200, which it has no way to
detect.

`leave()` now sets `departed` before it counts, and `enqueue` reads it after
`try_send`. That is the only order that can observe the case: the flag has to be
set before the count for a late message to be visible as late, and the check has
to follow the send, because the send is what makes it late.

The message stays in the channel. Depending on which side won, `leave()` may
already have counted it in `dropped_on_close`, so that diagnostic can over-report
by one. Better than the alternative, which is a sender that believes a lost
request succeeded and never retries it.

Disabling the check makes the test answer 200, which is the false success this
closes.
The arm answers for two conditions, and the message I gave it was true of only
one. The second lookup returns nothing when the path was withdrawn, and returns a
different instance when one on the same listener has taken the path over. Calling
both "instance is closing" describes a closure that has not happened in the
handover case.

`route unavailable` is true of both, and the caller's action is the same either
way. It also says no more about the listener's topology than a retry needs, which
is why the two are not split into separate messages.

The admin API's "instance is closing" is unchanged, and so is the one `enqueue`
answers once `leave()` has flagged the instance. Both of those really are
closures.
…tate

I misread this finding the first time and fixed the wrong thing. hubcio's point
was about the canonical third state test telling authors to assert default state,
and I changed the naming rule instead, then reported it done. The naming change
was worth making on its own, but it was not this.

The canonical `given_invalid_state_should_start_fresh` is right when the state is
a cursor: discarding an undecodable one costs duplicates, which the at-least-once
contract already permits. It is wrong when the absence of the state is a
downgrade rather than a repeat. `http_source` keeps revocation tombstones there,
so starting fresh would re-serve every revoked endpoint with its secret, and its
`restore` returns `Err` instead.

Both shapes are now named, with the reason for choosing between them and a worked
refusal test. The rule that is not optional is the last line: never start fresh
silently when the state that was discarded was load-bearing.
@mlevkov
mlevkov force-pushed the http-source-connector branch from 3a560e9 to 55a039d Compare September 10, 2026 19:27
The expiry clear runs during restore and touches the in-memory registry only.
It arms no flush, so the state file keeps its copy of the secret until an
unrelated registration or revocation writes the registry out, and an instance
that sees neither keeps it on disk for good. The README promised the drop
happened on the next restart, which reads as though expiry alone is enough and
sends an operator away from the revoke that actually clears it.

Arming the flag at the clear does not fix it either. Without a permit a quiet
gateway still never flushes, and arming plus notifying would break the contract
that a static-only instance writes no state file. So the documentation is the
whole change here, both the Options row and the paragraph below it, plus the
comment over the clear itself for the next person reading the code.
The earlier round asked for this check ahead of the header map and the body
copy, and putting it inside `enqueue` bought both of those but not the read.
By the time `enqueue` runs, `to_bytes` has already buffered up to
`max_body_size_bytes` off the wire and the secret path has hashed all of it.
With `Retry-After: 1` that has every sender re-upload and re-verify every body
the gateway sheds, which is the opposite of what shedding is for.

Both handlers now check once more before they read, at the first point where
the instance is known. On the secret path that is necessarily before
`authorize`, since the signature covers the body, so a caller holding a live
endpoint id can learn the bridge is full without proving it holds the secret.
An unknown id is already refused above and a wrong signature earns a 401, so
this adds nothing to what such a caller can already tell apart.

Everything else stays where it was. The gates that decide the outcome are the
second resolve, `authorize`, `try_send` and the post-send departure check, and
`enqueue` keeps its own check for a bridge that filled during the read. The
metric, the log line and the 429 itself now come from one place so those four
sites cannot drift.
Four copies at the moment of a send, not three. `postcard::to_allocvec` in the
SDK shadows the polled `ProducedMessages` rather than consuming it, so that
value lives to the end of the poll arm, past the callback and past the result
await. It sits alongside the staged replay copy, the serialized bytes and the
runtime's decoded copy. The earlier correction to this paragraph missed the
same binding, so this is the second pass over the same count.

The paragraph also carried no term for concurrent request bodies. Every
in-flight POST buffers up to `max_body_size_bytes` before its message exists,
and nothing here caps connections or concurrent reads, so at a large body cap
that term is bigger than the bridge and the batches together. The bridge check
that now runs before the read only sheds once the bridge is already full, so it
does not bound this one. The reverse proxy is what does.
A shortened state blob decoded `Ok` with zero endpoints. `#[serde(default)]`
on the registry supplied the missing map and `rmp_serde::from_slice` never
checks that the decode consumed its input, so a blob that had lost every
revocation tombstone was indistinguishable from a registry that never had one.
The instance then served its TOML endpoints with their secrets, including the
ones an operator had revoked for being compromised.

The state now travels in a frame of its own, outside the registry type: a
version, a declared endpoint count, and the endpoints, none of them defaulted,
decoded through a `Cursor` so the consumed length can be compared against the
blob. All three are load-bearing and none of them covers the others. Arity
rejects the old one-element shape and anything shortened, the consumed length
rejects a valid frame with more bytes behind it, and the declared count is the
only thing that rejects a frame which is the right shape, fully consumed, and
carrying a map somebody made smaller. The decode cannot be fixed in the SDK
helper, because `position()` exists only on the `Cursor` flavour of the
deserializer.

Doing it now rather than later is deliberate. The crate is `publish = false`
and no state file exists yet, so this costs nothing today. After a release the
same change is a fail-closed boot for every instance unless a legacy-shape
fallback goes in, and that fallback is the hole reopened.

A zero-length file reaches the same fail-open with nothing corrupted: the
runtime reports an empty state file as no state at all, so the guard above
never runs. The plugin cannot tell that from a first boot, so it warns when it
starts with static endpoints and no registry, and the README now says plainly
that truncating the file is the same act as deleting it.

The decode failure is also logged where it happens. `InitError` is substituted
by the runtime before it reaches `last_error`, so the reason a registry was
refused otherwise never leaves the process.
@mlevkov

mlevkov commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Round 6 from @numinnex: 12 findings, all 12 answered on their own threads. Detail is there; this is the short version.

Six are fixed and pushed:

  • docs(connectors): correct when an expired endpoint's secret leaves disk — the expiry clear is in-memory and arms no flush, so the README's "dropped on the next restart" was false. Reworded, flag not armed, for the reason numinnex gave.
  • fix(connectors): check the bridge before the body is read, not after — the earlier fix landed inside enqueue, which is after the body read. Both handlers now check before to_bytes.
  • docs(connectors): correct the batch copy count, and name the body term — four concurrent copies, not three, and the paragraph now carries a term for in-flight request bodies.
  • fix(connectors): frame the state file so a short read cannot fail open — a shortened state blob decoded Ok with zero tombstones, which put revoked static endpoints back on the wire with their secrets. Framed with a version, a declared count and a consumed-length check.
  • The same commit covers the zero-length-file route into that same fail-open: a warn when an instance starts with static endpoints and no registry, and the README now says truncating the state file is the same act as deleting it.

202 unit tests, and the restart integration tests pass on the new state format.

Seven are accepted and queued, in this order: lib.rs:887, then lib.rs:987 with lib.rs:954 as one change, then server.rs:580, state.rs:284, lib.rs:529, and server.rs:278 last because it moves where routes get published.

A sixth fix came out of the fifth. I swept the state decode rather than trusting the three repros — every single-byte mutation of a real blob plus every prefix — and found that 480 of those mutations edit a byte inside an endpoint id, yielding a well-formed frame still carrying its tombstone under a different id, so the original id serves again from TOML.

I first wrote that off as a file integrity property needing its own mechanism and offered to file it separately. That was wrong. An id is written twice, as the map key and inside the record, and every writer keys an endpoint by its own id, so agreement is an invariant. restore was resolving a disagreement by preferring the key, silently repairing exactly the corruption worth refusing. fix(connectors): refuse a state file that misfiles an endpoint under a new id rejects it instead, catching all 480 with no new format field and no checksum.

To be explicit about the limit: this is corruption detection, not authentication. Anyone who can write the state file can write a consistent one, and it is a cleartext credential store, so they can already read every secret in it.

Not requesting a re-review, the PR is already S-waiting-on-review.

…a new id

Framing the state file stopped a shortened blob decoding as an empty registry,
but not a blob whose endpoint id was edited. That produces a frame which is the
right shape, fully consumed and honest about its count, still carrying its
tombstone, filed under an id one character away from the real one. The revoked
endpoint then serves again from TOML with its secret, which is the same
fail-open by a different route.

The fix was already sitting in the file. An id is written twice, once as the
map key and once inside the record, and every writer keys an endpoint by its
own id, so the two agreeing is an invariant rather than a coincidence. `restore`
used to resolve a disagreement by preferring the key, which silently repaired
exactly the corruption worth refusing. The decode now rejects it instead, and a
single edit cannot keep the two copies consistent.

Swept the same way as the rest of the frame: every byte of a real blob against
every value, plus every prefix. 480 of those mutations retarget a tombstone and
the check catches all 480, so the sweep now asserts the tombstone survives
rather than only that the endpoint count does.

This is corruption detection and not authentication. Anyone who can write the
file can write a consistent one, and can already read every secret in it, so
there is nothing here that a checksum or a MAC would add.
…else

Every refusal this connector can give at startup names its cause, and none of
it reached an operator. The SDK's open shim returns 0 or 1 and drops the `Err`
payload, and the runtime substitutes "Plugin initialization failed" for
`last_error`, so all of `validate`'s messages, the field a joining instance
disagrees on, a route conflict and the refusal to serve an undecodable registry
were text nothing read.

`open` now logs whatever it is about to return. One place rather than a line at
each failure point, so the three cannot drift apart, and the log callback is
installed before the plugin is built, so the line does reach the runtime's log
even though the return value does not. Its body moved to `open_inner` for that.

Two things follow from the message becoming visible. It used to carry
`InitError`'s prefix twice, because `new` stored the restore failure as text
and `open` wrapped the text in a second `InitError`; the field holds the error
itself now. And the documentation said in five places that `last_error` carries
the reason, which was the claim this whole path could not honour. Those now say
the log carries it, and the README says so once as an operational note.

Untouched on purpose: the save failure path really does set `last_error` to a
real message, so what the README says about a failed flush stays true.
…ppened

Two faults in the same state machine, both from reading the outcome of a poll
instead of the event.

An Ack cleared the flush backoff whoever earned it. Traffic knows nothing about
the state store, so a batch of requests was zeroing a counter that only counts
the store's refusals, and the delay for attempt zero is none, which is why the
doubling to an eight second ceiling never engaged on a gateway that was serving
anything. A store that keeps refusing then costs one failed write per poll. It
is not an HTTP backend problem: the default file backend reports itself never
latched, so a read-only mount or a full disk lands exactly here. `on_nack`
already applied this rule and only the Ack side was missing it.

The poll order flipped on whether a flush produced state rather than on whether
the flush arm won. A flush that wins and then declines has still spent the
poll: its permit is gone and `recv()` never ran. Not flipping meant the next
poll was flush-first again, took the re-posted permit straight back, and
declined again, which is an FFI round trip per iteration carrying nothing while
queued traffic goes undrained. `take_dirty_state` declines whenever it loses
its `try_lock` to an ordinary control-plane write, so a caller holding the
registry writer across a clone and a validate is enough to sustain it.

The backoff's own documentation claimed a store that stays latched ends in the
SDK stopping the source. That is true only on an idle gateway. Every traffic
Ack resets the SDK's consecutive counter, so under traffic the refusals are
never consecutive and the source runs on indefinitely. Fixing the counter does
not change that, so the comment now says which case ends and which does not.

Both fixes have a test that fails without them. The poll-order one is driven by
a surplus permit rather than a contended lock, because that reaches the same
line without a guard held across an await.
…al reach

Two ceilings that were not bounding what they claimed to.

The admin listener was measured against `max_body_size_bytes`, which bounds an
inbound webhook and has a floor of 1. So an operator who bounded webhooks
tightly could not register or rotate at all, and the refusal named neither the
field nor the cause. `ensure_compatible` refuses a join that disagrees on that
knob, so it could not be raised for one instance to escape either, and what it
blocked was replacing a secret that had leaked. The admin limit is its own
const now, derived from the field caps rather than picked, so raising a cap
cannot quietly reintroduce it. The original ask was only that the listener stop
using axum's 2 MiB default silently, which sharing the webhook cap overshot.

`MAX_ENDPOINTS` bounded dynamic registration and nothing else, so a TOML file
or a state file could start an instance above a ceiling the README states as a
per-instance property. Past the cap the arithmetic compounds: `try_insert`
reclaims `len - MAX_ENDPOINTS + 1` tombstones to fit one endpoint, so an
over-full registry discards revocation records in bulk. Nothing is resurrected,
since static tombstones are never reclaimable, but the record of who was
revoked and why goes.

The two sources of an over-full registry get different answers on purpose. A
TOML list over the cap fails `open()` naming the count, because that file is
something an operator can edit before starting. A state file over the cap warns
and serves, because clearing it costs every tombstone in it and failing would
take the instance down over a condition with no supported way out. The 507's
log line reports the length the registry actually holds rather than the
ceiling, which was telling operators it was a size it was not.
A misspelled `auth_bearer_token` deserializes to `None` and the named-path
handler skips its whole auth block, so one typo serves `POST /topics/{path}` to
anyone. Nothing documented that, and `deny_unknown_fields` is not the answer:
the runtime delivers env overrides as flat top-level keys, so refusing unlisted
keys would break a documented path. The silence is by design and the design
needed writing down.

The Options row for the token now says a misspelled key reads as unset, and the
configuration section carries the general rule plus what to do about it, which
is to grep the open log for "with NO authentication" after any configuration
change.

`management_token` gets the same typo for a different outcome, and the note
says so rather than implying symmetry. It fails closed, and unlike the bearer
token nothing logs its posture, so what an operator sees is `/admin/endpoints`
answering 404 on the next registration.
A joining instance took every healthy sibling on its listener out of rotation.
`join` published it while its poll task had not started, and the readiness gate
asks whether every instance is polling, so `/health` answered 503 and
`/admin/health` "degraded" for the whole listener until that instance's first
poll. The window is the whole of `setup_source_producer`: an Iggy login, then a
stream and topic ensure with retries. Restarting one instance therefore drained
its siblings, and on boot the window stretched across other connectors' setup
too.

The gate could not tell the two cases apart because `poll_is_live` answers
false both for a task that has stopped and for one that has not started, and
those want opposite answers. An instance now records that it has ever polled,
and readiness counts only those. A task that stops still takes the listener
down, which is the case the gate exists for.

Ignoring it would be a lie if it were serving meanwhile, so routes follow the
same rule: they are projected only from instances that have polled, and an
instance publishes its own on its first poll rather than in `join`. Before that
its paths answer 404, which is the honest answer while nothing can drain them.
A boot grace period would not do, since it answers 200 with no reader on the
bridge.

`publish` validates across every joined instance and serves the subset, so a
route conflict is still refused at join rather than lying dormant until the
offending instance first polls.

The test fixtures now stand in for that first poll, because the runtime always
starts a poll task after `open()` and nearly every request test had been
relying on routes existing without one.
@mlevkov

mlevkov commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

All twelve of round 6 are now fixed, plus one that came out of the sweep for A1. Every one has a reply on its own thread with the detail; this is the index.

Finding Commit subject
C3 state.rs:216 docs(connectors): correct when an expired endpoint's secret leaves disk
E1 server.rs:760 fix(connectors): check the bridge before the body is read, not after
F1 README.md:299 docs(connectors): correct the batch copy count, and name the body term
A1 + A2 state.rs:69,97 fix(connectors): frame the state file so a short read cannot fail open
(found by the A1 sweep) fix(connectors): refuse a state file that misfiles an endpoint under a new id
B1 lib.rs:887 fix(connectors): log why open failed, since the message goes nowhere else
C1 + C2 lib.rs:987,954 fix(connectors): let the flush backoff and the poll order see what happened
E2 + E3 server.rs:580, state.rs:284 fix(connectors): give the admin listener its own limit and the cap real reach
B2 lib.rs:529 docs(connectors): say that an unknown plugin_config key is ignored
D1 server.rs:278 fix(connectors): publish an instance's routes when it can drain them

209 unit tests and all 13 connectors::http integration tests pass against a real server.

Every guard was checked by deleting it and watching a test go red, and each mutant was confirmed to compile first, because one that does not looks exactly like a test that passed. Three places where a test does not prove what it might appear to are called out on their threads rather than left to look like evidence: the E3 restore test pins a decision rather than verifying a fix, the 507 log line has no test because this crate has no log capture, and D1's unit test stands in for the first poll so the integration suite is what shows the production path firing.

Four corrections to my own earlier claims are on the threads: the id retargeting I first called a separate issue, the management_token posture I nearly documented as logged when nothing logs it, numinnex's own "before any bridge check" wording in F1 that E1 had already made false, and the backoff comment claiming a latched store always ends in the SDK stopping the source, which holds only when idle.

The route publication added with the readiness fix returned quietly when the
listener was not in the registry. That path is not reachable through the
ordinary lifecycle, since the instance joined during `open`, an entry only
leaves the map when its last instance does, and the SDK stops the poll task
before `close`. The state it would describe is worth a line regardless: a poll
task running against a listener that is no longer bound, serving nothing for
as long as it lives, with no trace of why.

`leave` documents its own unreachable early return for the same reason. This
one now logs rather than only explaining itself, because unlike that one it
leaves an instance in a state nothing else reports.
…or it

Review of the readiness change found the flag being claimed before the await
that publishes, and the gap between them reachable. The only suspension point
is the `SERVERS` lock, and a poll dropped while waiting for it left the flag
set with nothing published. The sibling whose publish the poll was queued
behind then read that flag and served the cancelled instance's routes on its
behalf, so `enqueue` answered 200 into a bridge with no reader while `/health`
stayed green. Not a regression, since before the readiness change that state
lasted the whole of producer setup, but the readiness comment claimed an
absolute it did not have.

Claiming after a successful publish cannot work, because the publish filter
reads the flag. Claiming inside the guard does: the flip and the publish are
now one critical section, and a poll dropped before the lock leaves the flag
false so the next one retries.

That also removes the test helper, which had been a second copy of the pairing
`poll()` does. Every request test in the crate went through the copy, so
deleting the publish from `poll()` left all of them green. They call the
production function now, and one new test drives `poll()` itself and fails if
that call goes away.

`warn_if_poll_stopped` and `/admin/health` were still reading `poll_is_live`
raw, so a management call against an instance that had not polled yet logged
that its poll task looked stopped, and one health body could report `ok`
beside `poll_is_live: false` with nothing to explain it. The warning now asks
the question it means, and the health body carries `has_polled` alongside, so
the two ways of being not-live are told apart.
The test I wrote for the framing truncated a blob by one byte, and truncation
was never the hole. Verified against the pinned rmp-serde outside the tree:
every one of the 92 prefixes of a valid old blob already failed on EOF, so
that test passes at the parent commit and proves nothing about the frame. The
docstring made the same mistake, calling a shortened blob one that decodes
`Ok`.

The shape that did fail open is `[0x90]`, an empty array, which the old
single-field registry read through `#[serde(default)]` as a registry with no
endpoints: a clean decode carrying zero tombstones. That is what the test
restores from now, against a TOML file still declaring the endpoint whose
tombstone went missing.

The consumed-length check earns its own test too, for the case that is
genuinely its alone. Rewriting a byte that opens the `state` map turns a
tombstone back into an active endpoint and leaves the bytes describing the
revocation unread behind the frame; arity, version and the declared count all
pass it. The test sweeps every single-byte rewrite rather than pinning the one
offset, so it keeps meaning something if the fixture changes.

Also corrects the check count in three comments, which said three before the
id agreement check made it four.
…t it caps

Five documentation defects, each verified against the code rather than reasoned
about.

The README told operators a `management_token` typo is logged nowhere. It is
logged on every listener bind, naming the key. I had checked with a grep that
required the word and the log macro on one line, and the macro opens a line
above the string, so the check could not have found it however wrong the claim
was. It now says both typos announce themselves and what to grep for.

The startup window is not an Iggy login. The login happens once before any
source is initialised; what sits between `open` and a first poll is every
source's producer setup and every sink's init, run in series, so on boot the
window covers connectors this one has nothing to do with. The README and the
comment both said the smaller, wrong thing.

An instance name becomes a header value, not a key. Same 255 either way, since
Iggy gives both the same ceiling, but the config check now validates it as what
it becomes and the operator-facing error names the right one. The reason the
cap is load-bearing is that the header is dropped rather than clamped, so a
name past it takes the identity header off every message.

`publish` carried two summary sentences with the stale one first, and listed
three callers after the first poll became a fourth. `Published` still called
its routes derived from its instance set, which stopped being true when they
narrowed to the instances that can drain.

The shutdown-loss note had its direction backwards: the count can only
under-report, never double count, because the SDK joins the poll task before
close runs. That comment predates this round and is corrected here rather than
by rewriting a commit numinnex has already read.

The admin body limit now covers the revoke reason too. Registration is still
the largest legal body by an order of magnitude, but the derivation's whole
claim is that no field cap can be raised out from under it, and one was not in
the sum. The duplicated 255 is gone in favour of the documented constant that
already existed.

The early bridge check keeps its behaviour and gains the cost: answering
without draining means hyper closes the read half, so above roughly 16 KiB a
shed request burns its connection. The response still arrives. `Connection:
close` was measured rather than assumed and makes it worse.
The decoder returned a formatted string, the only `Result<_, String>` in the
crate and against the rule that says errors are enums. That mattered more than
convention here: all five framing tests asserted a bare `is_err()`, so a blob
refused by the wrong guard passed them, which is the failure this round was
looking for everywhere else.

A variant per check, and the tests name the one they mean. The ids in the
misfiled variant are carried as `log_prefix` rather than whole, since a
secret-path id is the credential and this text reaches a log line.

Also fixes the ceiling test, which passed `None` for state and returned at the
early exit before the branch its own comment described. It carries state now
and reaches it. What it pins is still a decision rather than a fix, since
nothing fails if the warning goes away, and the comment says so.
… than nothing

Gating the management warning on `has_polled` removed a false alarm and the
only signal for a real problem with it. The runtime brings every source up,
then every sink, then the poll tasks, all in series, so one slow sink holds
every instance of this connector before its first poll for an unbounded time.
In that window a registration returns 201 with nothing logged, and on a
listener where a sibling has polled the readiness gate filters the waiting
instance out and answers green. The test added alongside that gate asserts the
green, so the suite agreed with it.

The message was the defect, not its existence. Calling an instance that has not
started one whose poll task "looks stopped" was wrong; staying quiet was worse.
Both conditions are reported now, in the words that fit them, and the one that
means producer setup has not finished says so.

The critical section move also had no test: reconstructing the old arrangement
left the whole suite green, including the test that shipped with it. A poll
cancelled while parked on the registry lock now asserts the instance was not
claimed, and that fails against the old arrangement.
… a test that cannot fail

Four claims, all added while fixing other wrong claims, which is its own
lesson: prose has no compiler, so a number put in under time pressure survives
a green suite.

The admin limit is not larger than the next largest body by an order of
magnitude. A rotation carrying a maximal secret is within a fifth of a
registration, and rotation is what the same comment discusses three lines
above. The unquantified version it replaced was true.

The first-poll claim said a cancelled poll leaves the flag false so the next
poll retries. There is no next poll: the only thing that drops that future is
shutdown, and the SDK breaks its loop on the same branch. What the move
actually buys is two things worth naming, one of which was missed entirely:
a sibling can no longer serve a cancelled instance's routes, and the instance
can no longer read as one whose poll task stopped, which used to take healthy
siblings to 503. The readiness comment also credited the critical section for
a guarantee that rests on `poll_active` instead.

The shutdown-loss note was corrected in the wrong direction last round, on a
finding I took without checking. `leave` sets the departed flag before reading
the bridge length, so a message landing between them is counted while its
sender is told 503: an over-report against that counter's description, which
is what the comment said before I changed it. The reasoning is written out this
time so the next reader can check it rather than flip it again.

The declared-count comment kept the half that was wrong after its number was
corrected. Version and the id agreement also reject valid, fully consumed
blobs; what is unique to the count is noticing a map somebody made smaller.

The tombstone-resurrection test is removed. It rewrote bytes to `0x00` against
a fixture the full sweep already covers with every value, so it could not fail
on its own, and it was the last framing test asserting less than the guard it
named. The sweep says what it catches instead.

Registering against an instance that has not polled now has a test and a line
in the README. The endpoint is created and its path waits for the first poll,
which no test covered because every fixture published routes immediately.
The comment said the response always arrives because the write half stays
open. That was a raw socket's result read as a general one. A client that
writes the whole body before reading can take the reset instead and never see
the 429, and at this connector's default megabyte cap that is a real fraction
of requests rather than an edge. `Retry-After` is guidance for the senders that
receive it.

The threshold was wrong too. It is not a body size: hyper's one drain attempt
covers head and body together, lands near 32 KiB on a fresh connection, and
slides lower on a reused one as its read strategy adapts, so a small body sent
slowly enough loses the connection as well.

Two more quantifiers that did not survive being checked. The startup window is
not every source's producer setup, since the runtime does each source's open
and its producer in one pass and only the ones it has not reached yet are
still ahead; they come from an unordered map, so which ones is not fixed. And
the new health field's pairs were written in an order that made the reachable
combination read as the alarming one, so each names its field now.

None of this changes behaviour. It is the fourth round of correcting things
said about code that was already right, which is its own finding.
@mlevkov

mlevkov commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

@numinnex I put the round 6 work through a two round adversarial review before asking you to look at it again. It found that the fixes themselves held up, and that several things I told you about them did not. Five corrections, all pushed.

1. My test for the state framing was vacuous. I told you the fix was covered by a test that truncated a blob. Truncation was never the hole: I rebuilt the pre-frame shape against the pinned rmp-serde and every one of the 92 prefixes already failed on EOF, so that test passes at the parent commit and proved nothing. The shape that actually failed open is [0x90], which decoded as a registry with no endpoints. That is what the test restores from now, against a TOML file still declaring the endpoint whose tombstone went missing. test(connectors): test the state shapes that actually failed open.

The same review found what the trailing length check is really for, which I had not tested or described: rewriting the byte that opens the state map turns a tombstone back into an active endpoint and leaves the revocation bytes unread behind the frame. Arity, version and the count all pass it. That is a resurrected endpoint serving with its secret, and it now has a test.

2. I told you a management_token typo is logged nowhere. It is logged. management.rs names that exact condition on every listener bind. I had checked with a grep that required the word and the log macro on one line, and the macro opens a line above the string, so my check could not have found it however wrong I was. The README says the opposite thing now.

3. My description of the readiness window was wrong twice. There is no Iggy login in it; that happens once before any source is initialised. And the window is not one connector's producer setup: the runtime finishes the sources it has not reached yet, then every sink, in series, and only then starts any poll task. Which sources those are comes from an unordered map. You had told me the window spans other connectors and I dropped it.

4. The admin body limit did not cover every field cap, which was the whole claim. The revoke reason was not in the sum. Registration is still the largest legal body, but by about 15%, not the margin I implied. Both fixed.

5. Answering 429 before reading the body costs the connection, and sometimes the answer. Measured, not reasoned: hyper makes one drain attempt then closes the read half, at about 32 KiB of head plus body on a fresh connection and lower on a reused one. A sender that reads while writing usually still gets the 429. One that writes the whole body first can take a reset instead and never see it, which at the default 1 MiB cap is a real fraction of requests. The gate still does what you asked for, the upload really is cut off, and Connection: close was measured and does not help. This is now written next to the check rather than left for someone to discover.

One more thing worth saying plainly. The second review round found that my fix for server.rs:278 had introduced a regression: gating the management warning on "has this instance polled" removed a false alarm and the only signal for an instance that is wedged before its first poll, and the test I shipped alongside it asserted the resulting green health. Fixed in fix(connectors): say when an instance has not started polling, rather than nothing.

212 unit tests and all 13 connectors::http integration tests pass. Nothing in the code changed direction as a result of the review; what changed is that the claims now match it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review PR is waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants