fix(channels): document GitHub dedupe TTL, tighten redelivery tests (#4274)

* fix(channels): document GitHub dedupe TTL, tighten redelivery tests

Follow-up to PR #4104's review, whose non-blocking notes were left
unaddressed at merge time:

- Document the previously-undocumented inbound-dedupe TTL
  (INBOUND_DEDUPE_TTL_SECONDS, 10 min, in-memory only) directly above
  the constant in ChannelManager: what it is, why the window, and what
  happens at the boundary (a manual "Redeliver" clicked after the TTL
  has elapsed, or any redelivery after a Gateway restart, is not
  deduped, since _recent_inbound_events is never persisted to
  ChannelStore). Cross-reference it from the GitHub dispatcher's
  fan-out comment, which previously implied unconditional redelivery
  coverage.
- test_channels.py::test_github_redelivery_is_deduped_like_other_channels:
  the _gh helper stamped a 2-part delivery:agent id while production
  stamps a 3-part delivery:user:agent id. Align the helper with the
  real shape and add a cross-user assertion the old 2-part shape could
  not even express.
- test_github_dispatcher.py::test_missing_delivery_header_leaves_dedupe_open:
  previously only asserted the dispatcher-layer id was None. Add the
  manager-level _is_duplicate_inbound assertion across two header-less
  deliveries so a future regression that treats a missing id as a real
  key is caught here too, not only at the dispatcher layer.

All four affected test files (test_github_dispatcher.py,
test_channels.py, test_github_channel.py, test_github_registry.py)
pass; ruff check and ruff format --check are clean. Verified the two
tightened tests actually discriminate by temporarily reintroducing
each bug they target (2-part id shape; missing-id treated as a real
key) and confirming the new assertions fail, then reverting.

* fix(channels): correct GitHub redelivery claim in dedupe comments

fancyboi999's review on this PR flagged that the previous commit's new
comments (manager.py, dispatcher.py) justified the 10-minute dedupe TTL
by claiming GitHub automatically retries a failed delivery after its
10-second timeout. GitHub's own documentation says the opposite:
failed deliveries (non-2xx, timeout, connection error) are recorded as
failed and never automatically redelivered, for both repository and
GitHub App webhooks alike. Every redelivery is explicit: the "Redeliver"
button, the REST API, or an operator's own recovery script.

- manager.py: rework the INBOUND_DEDUPE_TTL_SECONDS comment to state
  GitHub's actual behavior (no auto-retry), cite the authoritative doc
  (docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries),
  and justify the 10-minute window as a bound on explicit near-term
  replays rather than an automatic-retry window. No longer asserts
  unverified retry behavior for the other channels either.
- dispatcher.py: same correction for the dedupe_message_id comment,
  which previously implied "retried after a timeout" as a distinct,
  automatic case alongside the manual "Redeliver" button.
- test_channels.py / test_github_dispatcher.py: reworded the two
  docstrings and one section comment that repeated the same
  "retry-on-timeout" framing, so the corrected mental model doesn't
  sit next to contradicting prose nearby. No assertions changed.

Verified directly against GitHub's current documentation (fetched, not
paraphrased from memory): "Handling failed webhook deliveries" states
plainly that GitHub does not auto-redeliver; "Automatically redelivering
failed deliveries for a GitHub App webhook" / "...for a repository
webhook" are both guides for a user-written recovery script (GitHub's
own example runs on a 6-hour cron), not a native GitHub feature. No
difference in this behavior between GitHub Apps and repository webhooks.

All four affected test files pass; the two `test_bot_login_whitespace_only`
/ `test_trigger_mention_login_whitespace_only` failures in
test_github_dispatcher.py are pre-existing on this branch's unmodified
HEAD (confirmed against a clean checkout of the same commit) and are
unrelated to this change. ruff check and ruff format --check are clean
on all four touched files.
This commit is contained in:
Daoyuan Li
2026-07-19 22:08:48 +08:00
committed by GitHub
parent 532111b2e6
commit 9a5d701355
4 changed files with 88 additions and 17 deletions
+30
View File
@@ -72,6 +72,36 @@ MESSAGE_STREAM_EVENTS = ("messages-tuple", "messages")
THREAD_BUSY_MESSAGE = "This conversation is already processing another request. Please wait for it to finish and try again."
BOUND_IDENTITY_REQUIRED_MESSAGE = "Connect this channel from DeerFlow Settings, complete the in-channel connect step, then send your message again."
BOUND_IDENTITY_UNAVAILABLE_MESSAGE = "Channel connection verification is temporarily unavailable. Please try again later or contact the DeerFlow operator."
# Inbound-redelivery dedup window. ``_recent_inbound_events`` (below) is a
# process-local, in-memory-only OrderedDict — it is never persisted to
# ``ChannelStore`` — so a recorded key survives only for this TTL (or until
# evicted by the entry cap below) and is gone entirely across a Gateway
# restart. 10 minutes is a deliberately bounded window: long enough to
# absorb a near-term redelivery of the same event — whether a provider's
# own automatic retry (where the provider implements one) or an operator
# explicitly triggering a resend — without keeping a growing in-memory
# ledger.
#
# For GitHub specifically: GitHub does NOT automatically retry or redeliver
# a failed delivery (non-2xx response, timeout, or connection error) — it
# is simply recorded as failed. See GitHub's own documentation:
# https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries.
# Every redelivery of the same ``X-GitHub-Delivery`` GUID is therefore an
# explicit action — the repo/App "Redeliver" button, the REST API, or an
# operator's own scheduled recovery script polling the failed-deliveries
# endpoint (the pattern GitHub's own docs recommend) — never an automatic
# GitHub-side retry. This TTL exists to absorb exactly those explicit
# near-term replays.
#
# At the boundary: a manual redelivery (e.g. GitHub's "Redeliver" button)
# clicked *after* the TTL has elapsed, or any redelivery following a Gateway
# restart, is no longer recognized as a duplicate — the key has already been
# evicted, or never existed in the new process — so the agent runs again
# and may repeat a real side effect (e.g. a duplicate PR comment on
# GitHub). This is parity with every other IM channel's dedupe (same
# mechanism, same TTL), not a channel-specific gap. True idempotency against
# a late/manual redelivery would require persisting the dedupe key in
# ``ChannelStore`` instead, which is not implemented here.
INBOUND_DEDUPE_TTL_SECONDS = 10 * 60
INBOUND_DEDUPE_MAX_ENTRIES = 4096
# Only server-stable provider message ids: client-generated ids (client_msg_id,
+12 -5
View File
@@ -402,11 +402,18 @@ async def fanout_event(
# mirroring the stable per-message id the other channels stamp (Slack
# `ts`, Telegram `message_id`, WeChat/WeCom `message_id`, …) that the
# dedupe added in PR #3584 keys on. ``X-GitHub-Delivery`` is GitHub's
# globally-unique per-delivery GUID; it is reused verbatim when a
# delivery is retried after a timeout or replayed via the repo/App
# "Redeliver" button, so keying on it lets the manager absorb those
# replays instead of re-running the agent (and its real side effects,
# e.g. a duplicate PR comment). One delivery fans out to N agents
# globally-unique per-delivery GUID; it is reused verbatim on any
# redelivery of the same event. GitHub does not automatically retry
# a failed delivery — every redelivery is explicit: the repo/App
# "Redeliver" button, the REST API, or an operator's own recovery
# script (see
# https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries).
# Keying on the GUID lets the manager absorb those replays (within
# the in-memory dedupe TTL — see ``INBOUND_DEDUPE_TTL_SECONDS`` in
# ``app.channels.manager``; a late manual "Redeliver" or one after a
# Gateway restart is not deduped) instead of re-running the agent
# (and its real side effects, e.g. a duplicate PR comment). One
# delivery fans out to N agents
# across potentially N different owning users, so the per-message id
# is scoped to (delivery, user, agent): an identical redelivery
# reproduces the same triples (deduped) while two agents matching the
+20 -7
View File
@@ -1418,9 +1418,11 @@ class TestChannelManager:
PR #3584 added inbound dedupe for the IM channels; the GitHub channel
added in PR #3754 never stamped the ``message_id`` / workspace the
dedupe keys on, so GitHub's native "Redeliver" button or a
retry-on-timeout re-ran the agent with real side effects (e.g. a
duplicate PR comment). The dispatcher now stamps the X-GitHub-Delivery
dedupe keys on, so a redelivered GitHub webhook (the native
"Redeliver" button, the REST API, or an operator's own recovery
script GitHub does not auto-retry a failed delivery) re-ran the
agent with real side effects (e.g. a duplicate PR comment). The
dispatcher now stamps the X-GitHub-Delivery
GUID (scoped per agent) plus the repo, so the same manager dedupe
absorbs the replay while a second agent bound to the same delivery,
and a genuinely new delivery, still fire.
@@ -1429,20 +1431,25 @@ class TestChannelManager:
manager = ChannelManager(bus=MessageBus(), store=ChannelStore(path=tmp_path / "store.json"))
def _gh(delivery: str, agent: str = "reviewer") -> InboundMessage:
# Shaped exactly as app.gateway.github.dispatcher.fanout_event emits.
def _gh(delivery: str, agent: str = "reviewer", owner_user_id: str = "alice") -> InboundMessage:
# Shaped exactly as app.gateway.github.dispatcher.fanout_event
# emits: a 3-part (delivery, owner_user_id, agent) message_id —
# ``dedupe_message_id = f"{delivery_id}:{match.user_id}:{agent.name}"``
# — plus the matching ``owner_user_id`` field fanout_event sets
# from ``match.user_id``.
return InboundMessage(
channel_name="github",
chat_id="zhfeng/llm-gateway",
user_id="alice",
owner_user_id=owner_user_id,
text="@bot please review",
topic_id=f"7:{agent}",
workspace_id="zhfeng/llm-gateway",
metadata={"message_id": f"{delivery}:{agent}", "agent_name": agent},
metadata={"message_id": f"{delivery}:{owner_user_id}:{agent}", "agent_name": agent},
)
# The dedupe key matches the other channels' 4-tuple shape.
assert ChannelManager._inbound_dedupe_key(_gh("d1")) == ("github", "zhfeng/llm-gateway", "zhfeng/llm-gateway", "d1:reviewer")
assert ChannelManager._inbound_dedupe_key(_gh("d1")) == ("github", "zhfeng/llm-gateway", "zhfeng/llm-gateway", "d1:alice:reviewer")
# First delivery fires; an identical redelivery of the same GUID is dropped.
assert manager._is_duplicate_inbound(_gh("d1")) is False
@@ -1451,6 +1458,12 @@ class TestChannelManager:
assert manager._is_duplicate_inbound(_gh("d2")) is False
# A second agent fanned out from the SAME delivery is not cross-deduped.
assert manager._is_duplicate_inbound(_gh("d1", agent="coder")) is False
# A second user's SAME-named agent on the SAME delivery is not
# cross-deduped either. A helper still stamping the old 2-part
# (delivery, agent) id could not even express this case — it would
# collide with the very first assertion's "d1"+"reviewer" key and
# silently drop this user's run (willem-bd, PR #4104 review).
assert manager._is_duplicate_inbound(_gh("d1", owner_user_id="bob")) is False
def test_dispatch_loop_releases_dedupe_key_when_handling_fails(self, tmp_path):
"""A transient handling failure must not black-hole a provider redelivery (ShenAC #1)."""
+26 -5
View File
@@ -1037,7 +1037,7 @@ async def test_coder_and_reviewer_on_same_pr_get_distinct_threads(base_dir: Path
# ---------------------------------------------------------------------------
# Inbound dedupe identity — redelivery / retry-on-timeout protection
# Inbound dedupe identity — redelivery / replay protection
# ---------------------------------------------------------------------------
@@ -1048,8 +1048,9 @@ async def test_delivery_id_populates_inbound_dedupe_identity(base_dir: Path) ->
The inbound dedupe added for the IM channels in PR #3584 keys on a
top-level ``metadata["message_id"]`` plus a workspace id. The GitHub
channel added later (PR #3754) never populated either, so a redelivered
webhook (native "Redeliver" button / retry-on-timeout) re-ran the agent.
Fan-out now stamps the ``X-GitHub-Delivery`` GUID (scoped per owning
webhook (native "Redeliver" button, REST API, or an operator's own
recovery script — GitHub does not auto-retry a failed delivery) re-ran
the agent. Fan-out now stamps the ``X-GitHub-Delivery`` GUID (scoped per owning
user + agent) as the message id and the repo as the workspace id,
exactly where ``ChannelManager._inbound_dedupe_key`` looks.
"""
@@ -1181,7 +1182,19 @@ async def test_missing_delivery_header_leaves_dedupe_open(base_dir: Path) -> Non
``delivery_id`` originates from an optional header and can be empty. An
empty value must not become a constant key that would silently drop
distinct deliveries — it yields no dedupe id, i.e. the pre-fix behavior.
This asserts the actual manager-level consequence, not just the raw
dispatcher-layer id: today ``_inbound_dedupe_key`` returns ``None`` for a
falsy ``message_id``, so ``_is_duplicate_inbound`` returns ``False`` and
never records a key. A future change that let a missing id fall through
as a real (constant) key would silently collapse every header-less
delivery into "the same" message; asserting on two separate header-less
deliveries pins that neither is ever treated as a duplicate of the other
(willem-bd, PR #4104 review).
"""
from app.channels.manager import ChannelManager
from app.channels.store import ChannelStore
bus = MessageBus()
_write_agent(base_dir, "default", "reviewer", {"name": "reviewer", "github": {"bindings": [{"repo": "a/b", "triggers": {"pull_request": {"actions": ["opened"]}}}]}})
payload = {
@@ -1190,9 +1203,17 @@ async def test_missing_delivery_header_leaves_dedupe_open(base_dir: Path) -> Non
"repository": {"full_name": "a/b"},
"sender": {"login": "u"},
}
manager = ChannelManager(bus=MessageBus(), store=ChannelStore(path=base_dir / "dedupe-store.json"))
await fanout_event(bus, "pull_request", "", payload)
(msg,) = await _drain(bus)
assert msg.metadata["message_id"] is None
(first,) = await _drain(bus)
assert first.metadata["message_id"] is None
assert manager._is_duplicate_inbound(first) is False
await fanout_event(bus, "pull_request", "", payload)
(second,) = await _drain(bus)
assert second.metadata["message_id"] is None
assert manager._is_duplicate_inbound(second) is False
# ---------------------------------------------------------------------------