Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2586,6 +2586,7 @@ def classify_send_error(exc: Optional[BaseException], error_text: str = "") -> s
or "not enough rights" in blob
or "have no rights" in blob
or "not a member" in blob
or "target_not_allowed" in blob

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: cron does not call the wrapper that consumes this classification. cron/scheduler.py:1789-1806 invokes _deliver_to_platform() directly, while mark_dead() is only reached from DeliveryRouter.deliver() at gateway/delivery.py:376-385; this match therefore will not short-circuit a later cron tick.

):
return "forbidden"
if any(s in blob for s in _CHAT_LEVEL_NOT_FOUND_SUBSTRINGS) or any(
Expand Down
31 changes: 31 additions & 0 deletions tests/gateway/test_dead_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,37 @@ async def test_shared_registry_is_used_when_injected(isolate):
assert adapter.calls == []


@pytest.mark.asyncio
async def test_photon_target_not_allowed_marks_target_dead(isolate):
# A shared/free-tier Photon line permanently cannot open a new outbound
# thread to this target (Spectrum "target_not_allowed"). Scheduled/cron
# delivery only sees the raised RuntimeError text (not the adapter's
# structured raw_response), so classify_send_error must recognize this
# shape from text alone or the target retries forever on every tick.
message = (
"Photon sidecar /send returned 403 (target_not_allowed, "
"retryable=False): shared/free-tier Photon lines cannot initiate "
"outbound sends to new targets — upgrade to a dedicated line or "
"use another delivery channel"
)
adapter = RaisingAdapter(message)
router = DeliveryRouter(GatewayConfig(), adapters={Platform("photon"): adapter})
target = DeliveryTarget.parse("photon:900")

res1 = await router.deliver("hi", [target])
assert res1["photon:900"]["success"] is False
assert router.dead_targets.is_dead("photon", "900") is True
assert adapter.calls == ["900"]

# Second delivery: short-circuited, adapter NOT called again — this is
# the actual bug: before the fix, classify_send_error returned "unknown"
# for this text, so mark_dead() was never called and every cron tick
# re-sent to a target the sidecar had already permanently rejected.
res2 = await router.deliver("hi again", [target])
assert res2["photon:900"]["skipped"] == "dead_target"
assert adapter.calls == ["900"]


# --------------------------------------------------------------------------
# not_found blast radius: chat-level kills the chat, thread/message-level must not
# --------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions tests/gateway/test_send_error_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class _FakeBadRequest(Exception):
("Forbidden: bot was blocked by the user", "forbidden"),
("Forbidden: user is deactivated", "forbidden"),
("Bad Request: not enough rights to send text messages", "forbidden"),
(
"Photon sidecar /send returned 403 (target_not_allowed, "
"retryable=False): shared/free-tier Photon lines cannot "
"initiate outbound sends to new targets — upgrade to a "
"dedicated line or use another delivery channel",
"forbidden",
),
("Bad Request: chat not found", "not_found"),
("Bad Request: message to edit not found", "not_found"),
("Too Many Requests: retry after 12", "rate_limited"),
Expand Down
Loading