Skip to content

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

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

feat(connectors): add the HTTP source webhook gateway connector#3798
mlevkov wants to merge 2 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.87403% with 101 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.10%. Comparing base (1453114) to head (4c60da8).
⚠️ Report is 19 commits behind head on master.

Files with missing lines Patch % Lines
core/connectors/sources/http_source/src/server.rs 96.01% 37 Missing and 8 partials ⚠️
...e/connectors/sources/http_source/src/management.rs 94.06% 32 Missing and 3 partials ⚠️
core/connectors/sources/http_source/src/lib.rs 97.87% 5 Missing and 7 partials ⚠️
core/connectors/sources/http_source/src/routes.rs 96.82% 0 Missing and 6 partials ⚠️
core/connectors/sources/http_source/src/metrics.rs 99.08% 1 Missing and 1 partial ⚠️
core/connectors/sources/http_source/src/auth.rs 99.29% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3798      +/-   ##
============================================
+ Coverage     75.72%   76.10%   +0.37%     
  Complexity      969      969              
============================================
  Files          1322     1330       +8     
  Lines        159363   162594    +3231     
  Branches     132746   136054    +3308     
============================================
+ Hits         120684   123741    +3057     
- Misses        35041    35107      +66     
- Partials       3638     3746     +108     
Components Coverage Δ
Rust Core 76.19% <96.87%> (+0.50%) ⬆️
Java SDK 62.71% <ø> (ø)
C# SDK 71.16% <ø> (-1.14%) ⬇️
Python SDK 93.10% <ø> (ø)
PHP SDK 84.52% <ø> (ø)
Node SDK 95.30% <ø> (+0.07%) ⬆️
Go SDK 43.08% <ø> (ø)
Files with missing lines Coverage Δ
core/connectors/sources/http_source/src/state.rs 100.00% <100.00%> (ø)
core/connectors/sources/http_source/src/types.rs 100.00% <100.00%> (ø)
core/connectors/sources/http_source/src/auth.rs 99.29% <99.29%> (ø)
core/connectors/sources/http_source/src/metrics.rs 99.08% <99.08%> (ø)
core/connectors/sources/http_source/src/routes.rs 96.82% <96.82%> (ø)
core/connectors/sources/http_source/src/lib.rs 97.87% <97.87%> (ø)
...e/connectors/sources/http_source/src/management.rs 94.06% <94.06%> (ø)
core/connectors/sources/http_source/src/server.rs 96.01% <96.01%> (ø)

... and 54 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.

Iggy has no way to receive a webhook. Every provider that pushes events
over HTTP needs something in front of it, and today that means running a
separate service whose only job is to accept a POST and republish it.
This connector removes that hop: it runs an embedded HTTP server, accepts
authenticated POST bodies, and produces them to the instance's stream and
topic as raw bytes.

One plugin .so is loaded once no matter how many source entries reference
it, so the listener cannot live on any single instance. It lives in a
process-global registry keyed by listen address: 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, and the last close releases both ports. Mismatches fail
that instance's open rather than silently handing it a listener its
configuration does not describe. A single port can therefore serve many
providers, each routed to its own topic.

Requests resolve against an ArcSwap route table that is rebuilt whole on
every control-plane change, so one atomic load yields both the endpoint's
auth rules and the destination bridge. Secret paths carry 128 bits in the
URL itself, on the model of a Slack webhook, with optional bearer or HMAC
on top; 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, because revoking a compromised
endpoint is time-critical and provisioning one per tenant is inherently
programmatic. Those endpoints ride the SDK's ConnectorState, and 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
mutation cannot be lost to an unrelated send failure. Revocation writes a
tombstone that outranks TOML on restore, so a stale config file cannot
resurrect an endpoint an operator revoked.

Delivery is best-effort in both directions and the README says so first,
before anything else: HTTP 200 means accepted into an in-memory buffer,
and both the loss and duplicate windows are enumerated with what mitigates
each. A full bridge answers 429 with Retry-After rather than blocking,
since holding the connection open would turn a slow Iggy into a retry
storm. Gateway metrics on the admin listener cover accept-to-200 latency,
which the runtime's own stage histograms begin too late to see.

Part of the webhook gateway design accepted in apache#3039. The backpressure
chain is only complete once the bounded runtime forwarding channel from
apache#3795 lands; until then a full bridge signals an arrival burst rather
than a slow Iggy, which the README documents.

Co-authored-by: Claude <[email protected]>
@mlevkov
mlevkov force-pushed the http-source-connector branch from 504c603 to ebca5c2 Compare August 2, 2026 20:14
@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
The 94.5% patch coverage on this branch hid the gap that mattered: both
management auth tests reached only GET /admin/endpoints, so the guard on
each of the four mutating routes had never taken its rejection branch.
Removing `denied()` from `revoke_endpoint` left the suite green, which a
per-route table-driven test now catches at 204-instead-of-401.

The rest closes the branches a reviewer would want pinned rather than
every uncovered line. Chief among them, `republish_or_close` had no test
proving it answers 500 instead of reporting a revoke that never reached
the route table, and the endpoint-id route conflict rendered its message
without any test asserting the id stays an 8-char prefix.

`ServerState::new` widens to `pub(crate)` so the management tests can
build a listener-less state and provoke the republish failure. Every new
test was mutation-checked: each one was confirmed to fail against a
deliberate break of the behaviour it claims to pin.

Left uncovered on purpose: the shutdown abort branch needs a connection
wedged past the 5s timeout, and roughly 30 of the remaining missed lines
are tracing-macro arguments that only execute with a subscriber
installed.
@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).

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.

1 participant