feat(spec,middleware): batched webhook delivery - #111
Open
ucekmez wants to merge 1 commit into
Open
Conversation
§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>
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
2xxacknowledges every event in the batch; anything else fails the whole batch, retried whole. A subscriber that cannot process one event MUST NOT return2xxand drop it. That is precisely why batching is opt-in — a subscriber must be able to process a batch atomically, or ask formax_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 === 1after 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
Checklist
max_batch_sizeabsent or1is byte-identical to today.Verification
@eep-dev/middlewaretests/tests/cross-impl/openapi-route-paritycodegen-schema-types --checkNotes for reviewers
max_batch_wait_msbounds 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.
dispatchBatchis the delivery half only. Accumulation policy — when to flush, how to hold events formax_batch_wait_ms— belongs to a deployment's queue, not to an in-process dispatcher whosedispatchcall 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.