Skip to content

feat(spec,middleware): batched webhook delivery - #111

Open
ucekmez wants to merge 1 commit into
feat/cloudevents-binary-content-modefrom
feat/webhook-batching
Open

feat(spec,middleware): batched webhook delivery#111
ucekmez wants to merge 1 commit into
feat/cloudevents-binary-content-modefrom
feat/webhook-batching

Conversation

@ucekmez

@ucekmez ucekmez commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 19 of a stacked series. Base is #110. Not for merge without review.

This is the other half of audit finding O4; the filtering half shipped separately because it changes nothing about the delivery envelope.

§5.2 mandates one event per POST. A high-frequency entity therefore pays a TLS handshake, a signature computation and a 10-second acknowledgement round-trip per event — the dominant per-delivery cost, and the one that does not shrink when you shrink the payload.

The wire format is the CloudEvents batched JSON encoding (application/cloudevents-batch+json), so this is a media type change rather than an EEP invention.

Four rules that are easy to get wrong

1. Acknowledgement is all-or-nothing. A 2xx acknowledges every event in the batch; anything else fails the whole batch, retried whole. A subscriber that cannot process one event MUST NOT return 2xx and drop it. That is precisely why batching is opt-in — a subscriber must be able to process a batch atomically, or ask for max_batch_size: 1.

2. A failed batch is ONE failed delivery against the §10 counter, not one per event. Otherwise a single failure of a 100-event batch would pause a subscription instantly. There's a test pinning failure_count === 1 after a failed 5-event batch.

3. Deduplication stays per event. Each envelope keeps its own id, so on a retry a subscriber discards what it already processed and handles the rest — delivery_guarantees.md §6 applies unchanged.

4. A batching subscription always receives the batch media type, even when only one event is ready. I implemented the opposite first (send a 1-element chunk unbatched) and a test caught it: switching format based on how many events happened to be available gives the subscriber two parsing paths and no way to predict which it gets.

Scope

  • Spec / schema only
  • TypeScript package(s)
  • Python package(s)
  • Tests / CI
  • Docs / examples

Checklist

  • I read CONTRIBUTING.md and CODE_OF_CONDUCT.md.
  • Tests added or updated where appropriate.
  • Breaking change? No. Opt-in per subscription; max_batch_size absent or 1 is byte-identical to today.
  • Documentation updated for user-visible behavior.

Verification

Suite Result
@eep-dev/middleware 259 passed (was 250)
tests/ 199 passed
tests/cross-impl/ 56 passed, 3 skipped
openapi-route-parity
codegen-schema-types --check no drift

Notes for reviewers

max_batch_wait_ms bounds the latency batching adds. Without it a low-traffic subscription could hold an event indefinitely waiting for a batch that never fills — the classic batching failure mode. The publisher MUST deliver once it elapses even if the batch is short.

Batching is structured-mode only. Binary content mode (#110) maps attributes to headers, and a batch carries many events with different attribute values — there is nowhere for them to go. Stated in the spec rather than left to be discovered.

dispatchBatch is the delivery half only. Accumulation policy — when to flush, how to hold events for max_batch_wait_ms — belongs to a deployment's queue, not to an in-process dispatcher whose dispatch call already blocks for the full retry schedule. I said so in the method's docstring rather than shipping a timer that would be wrong for any real deployment.

No Python middleware parity — flagging rather than skipping silently.

§5.2 mandated one event per POST. A high-frequency entity therefore paid
a TLS handshake, a signature computation and a 10-second acknowledgement
round-trip per event — the dominant per-delivery cost, and one that does
not shrink with a smaller payload. This is the other half of audit
finding O4; the filtering half shipped separately.

The wire format is the CloudEvents batched JSON encoding
(`application/cloudevents-batch+json`), so this is a media type change
rather than an EEP invention.

Four rules that are easy to get wrong and are stated explicitly:

- **Acknowledgement is all-or-nothing.** A 2xx acknowledges every event
  in the batch; anything else fails the whole batch, retried whole. A
  subscriber that cannot process one event MUST NOT return 2xx and drop
  it. That is precisely why batching is opt-in.
- **A failed batch is ONE failed delivery** against the §10 counter, not
  one per event — otherwise a single failure of a 100-event batch would
  pause a subscription instantly.
- **Deduplication stays per event.** Each envelope keeps its own `id`,
  so on a retry a subscriber discards what it already processed and
  handles the rest.
- **A batching subscription always receives the batch media type**, even
  when one event is ready. Switching format based on how many events
  happened to be available would give the subscriber two parsing paths
  and no way to predict which it gets.

`max_batch_wait_ms` bounds the latency batching adds: without it a
low-traffic subscription could hold an event indefinitely waiting for a
batch that never fills.

Batching is structured-mode only. Binary content mode maps attributes to
headers, and a batch carries many events with different attribute
values, so there is nowhere for them to go.

`dispatchBatch` is the delivery half; accumulation policy belongs to a
deployment's queue rather than an in-process dispatcher, which is noted
rather than pretended otherwise.

Refs: EEP audit 2026-08 finding O4 (batching)
Signed-off-by: Ugur Cekmez <ucekmez@gmail.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 19:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

2 participants