Skip to content

feat(api): production reqwest (rustls) webhook delivery sink + worker - #177

Merged
arkadianet merged 3 commits into
mainfrom
feat/v1-webhook-reqwest-sink
Jul 8, 2026
Merged

feat(api): production reqwest (rustls) webhook delivery sink + worker#177
arkadianet merged 3 commits into
mainfrom
feat/v1-webhook-reqwest-sink

Conversation

@arkadianet

@arkadianet arkadianet commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Stacked on #174 (webhooks). Merge that first.

What this is

The production HTTP delivery sink for the v1 webhooks subsystem — the deferred half of #174. The engine, retry/backoff, HMAC signing, and dedupe already existed behind an injected WebhookSink trait; this supplies the real network sink and spawns the delivery worker, so registered webhooks now actually deliver (they previously enqueued as pending).

Supply-chain discipline (this is a consensus node)

reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }pure rustls, no OpenSSL. Verified: grep -i 'openssl\|native-tls' Cargo.lock → no matches; the only new TLS crates are the expected rustls stack (hyper-rustls, tokio-rustls, rustls, ring, webpki-roots). No json feature (the signed body is already a String, sent via .body()). No system dependency, reproducible builds.

Implementation

  • ReqwestSink (webhooks/worker.rs): one shared reqwest::Client, 10s connect+overall timeout, redirects disabled (a 3xx must not bypass the registration-time SSRF guard). POSTs the signed body with the full X-Ergo-* header set; reports the attempt outcome — the engine still owns retry/backoff/dead-letter, the sink does not reimplement it.
  • Worker spawned at the server.rs seam inside the existing live-runtime guard, with a process-wide AtomicBool once-guard (mirrors the O4 depth sampler) — important here because this worker opens real outbound connections and the full-router tests build repeatedly under a runtime.

Tests (no real external URLs)

  • ReqwestSink::new() builds against the rustls backend.
  • A #[tokio::test] spins a real 127.0.0.1:0 listener, fires one delivery, and asserts the received body + X-Ergo-Signature/X-Ergo-Webhook-Id/X-Ergo-Delivery-Id headers with Success(200). The retry/backoff engine tests (injected fake sink) are untouched.

Remaining deferral (documented): durable-across-restart registration still needs a DB schema (registry is in-memory).

Test plan

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace   # webhooks 38/38

🤖 Generated with Claude Code

https://claude.ai/code/session_01BUh2DBnAPqThdYFZW5D8wx

Summary by CodeRabbit

  • New Features

    • Webhook deliveries now use a live outbound network sender, enabling delivery attempts instead of only storing registrations.
    • Added support for secure HTTPS requests using modern TLS settings.
  • Bug Fixes

    • Webhook delivery workers now start only once per process, preventing duplicate background delivery loops.
    • Added request timeouts and clearer success/failure handling for webhook delivery attempts.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@arkadianet, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fa0bc795-2355-4c64-b6a7-0030db30bcda

📥 Commits

Reviewing files that changed from the base of the PR and between 207d501 and 30def80.

📒 Files selected for processing (2)
  • ergo-api/src/server.rs
  • ergo-api/src/v1/webhooks/worker.rs
📝 Walkthrough

Walkthrough

This PR wires a production HTTP transport (ReqwestSink, rustls-based) into the webhook delivery worker, adds an idempotent spawn-once guard, adds reqwest as a dependency with a license exception, updates public exports and documentation, and wires worker startup into the server.

Changes

Webhook Delivery Transport

Layer / File(s) Summary
TLS dependency and license configuration
ergo-api/Cargo.toml, deny.toml
Adds reqwest with rustls-tls feature and allows CDLA-Permissive-2.0 for webpki-roots.
ReqwestSink transport implementation
ergo-api/src/v1/webhooks/worker.rs
Adds SINK_REQUEST_TIMEOUT and ReqwestSink with a client constructor and post implementation mapping HTTP status to delivery outcomes; updates module docs and adds real-transport tests.
Idempotent worker spawn guard
ergo-api/src/v1/webhooks/worker.rs
Adds a WORKER_STARTED atomic guard and spawn_webhook_worker_once function that spawns the worker at most once per process.
Public export updates
ergo-api/src/v1/webhooks/mod.rs, ergo-api/src/v1/mod.rs
Exposes spawn_webhook_worker_once and ReqwestSink from the webhooks module and reformats the v1 re-export list.
Server startup wiring
ergo-api/src/server.rs
Builds a shared WebhookEngine, constructs a ReqwestSink, spawns the worker once when a Tokio runtime is present, and reuses the shared engine in WebhooksState.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Server as server.rs startup
  participant Spawner as spawn_webhook_worker_once
  participant Worker as delivery worker
  participant Sink as ReqwestSink
  participant Endpoint as Webhook endpoint

  Server->>Server: build v1_webhooks_engine
  Server->>Server: check tokio runtime handle
  Server->>Sink: ReqwestSink::new()
  Server->>Spawner: spawn_webhook_worker_once(bus, engine, sink, tick)
  Spawner->>Spawner: check WORKER_STARTED guard
  Spawner->>Worker: spawn worker task (once)
  Worker->>Sink: post(prepared request)
  Sink->>Endpoint: HTTP POST via reqwest client
  Endpoint-->>Sink: HTTP response
  Sink-->>Worker: DeliveryOutcome
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: production Reqwest-based webhook delivery with a worker, using rustls.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v1-webhook-reqwest-sink

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@arkadianet
arkadianet changed the base branch from main to feat/v1-api-webhooks July 7, 2026 22:23
@arkadianet
arkadianet changed the base branch from feat/v1-api-webhooks to main July 7, 2026 23:27
…ry worker (§4.1)

Registered webhooks previously enqueued deliveries as `pending` forever
with no real transport. Add `ReqwestSink` (rustls-TLS only, no system
OpenSSL) as the production `WebhookSink`, construct it once, and spawn
the delivery worker at the server seam guarded to a live Tokio runtime
and process-once (same idiom as the O4 depth sampler), so registered
webhooks now actually POST to their operator URL under the engine's
existing retry/backoff/HMAC discipline. Redirects are disabled so a
3xx can't silently route around the registration-time SSRF guard.

Persistence remains the one documented deferral (in-memory registry).
@arkadianet
arkadianet force-pushed the feat/v1-webhook-reqwest-sink branch from 264f5f3 to 9accf82 Compare July 8, 2026 10:23
reqwest's rustls-tls backend pulls in webpki-roots, which ships the
Mozilla CA root-store data under CDLA-Permissive-2.0 — a permissive
data-only license. Scoped per-crate exception rather than a global
allow, per the deliberate-per-dep policy in deny.toml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUh2DBnAPqThdYFZW5D8wx
@arkadianet

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ergo-api/src/v1/webhooks/worker.rs (1)

189-208: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Transport failures are silently swallowed.

Err(_) => DeliveryOutcome::TransportError discards the underlying reqwest::Error entirely. For a production external-call boundary that the engine will retry indefinitely on failure, there's no way to tell DNS failure, TLS handshake failure, connect refused, and timeout apart from logs/metrics — all collapse into the same opaque outcome.

♻️ Suggested fix: log the error before discarding
-            Err(_) => DeliveryOutcome::TransportError,
+            Err(err) => {
+                tracing::debug!(
+                    delivery_id = %req.delivery_id,
+                    webhook_id = %req.webhook_id,
+                    error = %err,
+                    "webhook delivery transport error"
+                );
+                DeliveryOutcome::TransportError
+            }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ergo-api/src/v1/webhooks/worker.rs` around lines 189 - 208, The transport
error path in `WebhookWorker::post` is swallowing the underlying
`reqwest::Error`, making all failures look identical. Update the `match
builder.body(req.body.clone()).send().await` error branch to capture the error
value instead of `_`, and log it with enough context before returning
`DeliveryOutcome::TransportError`. Keep the existing `DeliveryOutcome` behavior,
but ensure DNS, TLS, connect, and timeout failures are visible through the
`post` method’s logging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ergo-api/src/server.rs`:
- Around line 1264-1275: The webhook client initialization in the router startup
path is hard-failing via ReqwestSink::new().expect(...), which can bring down
router_with_mempool_and_wallet_and_security on client build errors. Update the
logic around the Handle::try_current() block to handle the ReqwestSink::new()
result gracefully: log the failure, skip spawn_webhook_worker_once, and leave
the webhook subsystem disabled so startup can continue. Use the existing
WebhooksState/webhooks_disabled pattern to keep the failure contained instead of
panicking.

---

Nitpick comments:
In `@ergo-api/src/v1/webhooks/worker.rs`:
- Around line 189-208: The transport error path in `WebhookWorker::post` is
swallowing the underlying `reqwest::Error`, making all failures look identical.
Update the `match builder.body(req.body.clone()).send().await` error branch to
capture the error value instead of `_`, and log it with enough context before
returning `DeliveryOutcome::TransportError`. Keep the existing `DeliveryOutcome`
behavior, but ensure DNS, TLS, connect, and timeout failures are visible through
the `post` method’s logging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 135cd6c3-f1a7-4c42-a03d-28561a4ece0b

📥 Commits

Reviewing files that changed from the base of the PR and between 1cb522f and 207d501.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • deny.toml
  • ergo-api/Cargo.toml
  • ergo-api/src/server.rs
  • ergo-api/src/v1/mod.rs
  • ergo-api/src/v1/webhooks/mod.rs
  • ergo-api/src/v1/webhooks/worker.rs

Comment thread ergo-api/src/server.rs
…o build

ReqwestSink::new().expect() in the router path would take the whole node
down over an auxiliary subsystem's client-build failure. Route through
the existing handle: None → 409 webhooks_disabled degrade path instead
(a handle without a sink would accept registrations that never deliver),
and log the build error. Also log the concrete transport error in
ReqwestSink::post — DNS/TLS/connect/timeout failures all collapsed into
an anonymous DeliveryOutcome::TransportError with no trace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUh2DBnAPqThdYFZW5D8wx
@arkadianet
arkadianet merged commit f9f53f2 into main Jul 8, 2026
9 checks passed
@arkadianet
arkadianet deleted the feat/v1-webhook-reqwest-sink branch July 9, 2026 06:40
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