fix(gateway): don't retry ambiguous HTTP 5xx sends (Signal group-reply duplicates) - #130
Conversation
…y duplicates)
A Signal group send that fails with an HTTP 500 is surfaced as
`org.signal.network.exceptions.NonSuccessfulResponseCodeException: [500] ...`.
`_is_retryable_error` does a substring match against a pattern list that
includes "network", and the Java exception's *package name* (`org.signal.
network`) matches it — so the 500 was misclassified as a transient network
error and `_send_with_retry` re-sent the identical message up to 2 more times.
A 5xx is ambiguous: Signal frequently accepts and delivers the message and
*then* answers 500, so each retry lands as a duplicate reply in the group.
This is the same hazard the existing timeout guard already protects against
("message may have been delivered").
Fix: add `_is_ambiguous_delivery_error` (HTTP 5xx detection) and
- short-circuit it to non-retryable in `_is_retryable_error` (runs before the
substring match, so genuine connection-level `PushNetworkException`s in the
same package are still retried — the request never reached the server);
- extend the `_send_with_retry` guard so a 5xx skips both the retry loop and
the plain-text fallback, returning the failure as-is.
`SendResult.retryable=True` still wins for platforms that know their 5xx is safe.
4xx is deliberately excluded (outright rejection, nothing delivered) and remains
eligible for the plain-text fallback.
Adds regression tests using the real observed Signal 500 error string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @exiao's task in 3m 1s —— View job PR Review —
|
There was a problem hiding this comment.
LGTM — the 5xx ambiguous-delivery guard correctly stops the retry-and-fallback duplication for Signal group 500s, keeps genuine PushNetworkException retryable, and preserves the retryable=True opt-in. Tests use the real error string and assert single-send behavior. One minor non-blocking note left inline: the mid-retry break path (L4401) still falls through to the duplicating plain-text fallback if a 500 appears after an initial transient failure.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 015b038c56
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Addressed the mid-retry ambiguity: after a transient failure, an HTTP 5xx or delivery-ambiguous timeout now returns the failed result instead of falling through to the plain-text fallback. Added a regression covering transient → Signal 500 (two sends only). Verified with |
There was a problem hiding this comment.
Reviewed the ambiguous-5xx delivery guard. Core Signal fix is correct and complete; Codex #2 (mid-retry 5xx) is already resolved in the second commit. One valid minor finding: _is_ambiguous_delivery_error misses the WhatsApp Cloud 5xx format (HTTP 500: / graph error 500 (HTTP 500)), so a Meta Graph 500 still drops to the duplicating plain-text fallback. Suggest broadening the regex to also match http\s*5\d\d. Details in the PR comment. 0 blocking, 1 minor.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c61f0bacd4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Reviewed the ambiguous-5xx delivery guard. Core Signal fix is correct and complete; both Codex points (WhatsApp Graph 5xx format, mid-retry 5xx) are resolved. One minor non-blocking finding: _is_ambiguous_delivery_error misses the httpx raise_for_status() format (Server error '500 ...' for url ...) that httpx-based adapters like bluebubbles surface (base.py:601/618), so those 5xx sends still hit the duplicating plain-text fallback — suggest adding an error '5\d\d alternative to the regex. Details in the PR comment. 0 blocking, 1 minor.
There was a problem hiding this comment.
LGTM — the ambiguous-5xx guard correctly stops the retry-and-fallback duplication for Signal group 500s: the classifier runs before the substring match so network-package 500s are no longer misretried, genuine PushNetworkException stays retryable, the mid-retry break path returns instead of falling through, and SignalAdapter.send() now preserves the real error text so the guard actually sees it. One minor non-blocking note posted inline: _is_ambiguous_delivery_error doesn't match the httpx raise_for_status format (Server error '500 ...') that BlueBubbles/weixin/yuanbao surface, so those 5xx sends still drop to the duplicating plain-text fallback — broadening the regex closes the whole class. 0 blocking, 1 minor.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a31285196
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
LGTM — the ambiguous-5xx guard correctly stops the retry-and-fallback duplication for Signal group 500s: the classifier runs before the substring match so network-package 500s are no longer misretried, PushNetworkException stays retryable, the mid-retry break path returns instead of falling through, and SignalAdapter.send() now preserves the real error text so the guard sees it. 1 minor non-blocking note in the PR comment: _is_ambiguous_delivery_error still misses the httpx raise_for_status format (Server error '500 …') that BlueBubbles/weixin/yuanbao and Signal's own transport surface, so those 5xx sends still drop to the duplicating plain-text fallback — broadening the regex with an error '5\d\d alternative closes the whole class. 0 blocking, 1 minor.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f485e36264
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
One real regression on the Signal 4xx path (details in the Claude review comment): the newly-preserved org.signal.network.exceptions text now matches the "network" retryable substring for 4xx errors, so _is_retryable_error returns True, the rejected payload is retried 2x, and the plain-text fallback the docstring promises for 4xx never runs. Fix: short-circuit any non-5xx HTTP response-code error to non-retryable (but fallback-eligible) in _is_retryable_error, plus a Signal-400 regression. Core Signal-500 duplicate fix is correct and well-tested — 1 minor, non-blocking.
There was a problem hiding this comment.
LGTM — reviewed the ambiguous-5xx guard end-to-end. The classifier runs before the substring match so Signal's network-package 500s are no longer misretried, genuine PushNetworkException stays retryable, 4xx short-circuits to fallback, both the initial and mid-retry break paths return the failure instead of falling through to the duplicating plain-text fallback, and SignalAdapter.send() now preserves the real error text (JSON-RPC dict + httpx transport) so the guard actually sees it. Regex covers Signal / Graph / httpx 5xx formats without false-positiving on URLs. Tests are real-error-string behavior contracts asserting exact send counts. 0 blocking, 0 minor.
Problem
Users get duplicate replies in Signal group chats. Root cause is a misclassified retry.
When a Signal group send hits an intermittent server-side HTTP 500, signal-cli surfaces it as:
_is_retryable_errorsubstring-matches an error against a pattern list that includes"network". The Java exception's package name —org.signal.network.exceptions— matches it, so the 500 is treated as a transient network error and_send_with_retryre-sends the identical message up to 2 more times (≈2s, ≈4s backoff).A 5xx is ambiguous: Signal frequently accepts and delivers the message and then returns 500. So every retry lands as a duplicate in the group. This is the exact hazard the existing timeout guard already guards against ("message may have been delivered") — but a 500 isn't a timeout, so nothing protected it.
Evidence (from a live gateway)
is_retryable=Trueon the real error string (matched pattern:network); the timeout guard returnsFalse.Fix
Add
_is_ambiguous_delivery_error(HTTP 5xx detection) and use it in two places:_is_retryable_error— returnFalsefor a 5xx before the substring match. This runs first so genuine connection-level failures in the same package (e.g.PushNetworkException, request never reached the server) are still retried._send_with_retry— extend the existing ambiguous-failure guard so a 5xx skips both the retry loop and the plain-text fallback, returning the failure as-is.Notes:
SendResult.retryable=Truestill wins — a platform that knows its 5xx is safe to retry can opt in.Tests
Regression tests use the real observed Signal 500 error string:
_is_retryable_error: 500 not retryable despitenetworkin the package; genuinePushNetworkExceptionstill retryable._is_ambiguous_delivery_errorunit tests (5xx yes, 4xx no, no-status no)._send_with_retry: a 500 produces exactly one send (no retry, no fallback → no duplicate); still retries when the platform setsretryable=True.tests/gateway/test_send_retry.py(50) andtests/gateway/test_send_error_classification.py(22) pass.Scope
🤖 Generated with Claude Code