fix(mattermost): classify API errors, escalate fatals, lock single-instance, surface audio/slash attachments - #35645
Conversation
…stance, surface audio/slash attachments
Hardens the Mattermost adapter against eight independently-verified
reliability bugs, each covered by a focused regression test (15 new
tests: 13 red->green plus 2 green->green 4xx guards; full file 58
passing, 43 existing + 15).
- Introduce _MMApiError(retryable=...) and a shared _request_json so
transient failures (network, timeout, 429, 5xx) are distinguished from
permanent 4xx once and reused everywhere. send() and edit_message() now
return SendResult(retryable=...) so _send_with_retry retries transient
outbound failures with backoff and the streaming consumer keeps
can_edit alive across transient edit blips (mirrors Slack/Teams).
- connect() escalates missing config and permanent auth failure via
_set_fatal_error(retryable=False) and a transient connect error via
retryable=True, instead of a bare False the gateway retries forever
(mirrors IRC/Teams).
- _ws_loop escalates a permanent WS auth/permission failure through
_set_fatal_error + _notify_fatal_error instead of returning silently
and leaving a zombie adapter the gateway never reconnects (mirrors IRC).
- disconnect() calls _mark_disconnected() so is_connected and runtime
status reflect shutdown (mirrors IRC/Teams).
- connect() acquires a scoped platform lock (released in disconnect, and
on every post-acquire failure exit so a permanent auth failure cannot
leak it) so two gateways sharing one token cannot double-process every
post (mirrors Discord/Signal/Slack).
- Relax the inbound media-type override guard to {TEXT, COMMAND} so a
slash-captioned attachment is still surfaced as a document/image/audio
file instead of being dropped as a command.
- Classify inbound audio as MessageType.AUDIO, not VOICE, so the agent is
handed the file instead of force-running STT on arbitrary audio
(Mattermost has no voice-note concept; mirrors Discord).
1ab422f to
c94baf5
Compare
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved ✅ — Mattermost platform reliability fixes.
Changes
Classify API errors, escalate fatals, lock single-instance, surface audio/slash attachments.
Reviewed by Hermes Agent
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused Mattermost reliability coverage. Current main still has the retry, lifecycle, and inbound-media defects this targets, so the work is useful to salvage.
Problems
- PR
plugins/platforms/mattermost/adapter.py:142removes the current..API-path validation. Main added that protection ind836b2bacafter event-controlled IDs could steer authenticated bearer-token requests; keep the validation in the shared_request_json()path. - PR
plugins/platforms/mattermost/adapter.py:253assumes a lock conflict is non-retryable, but_acquire_platform_lock()currently recordsretryable=Trueatgateway/platforms/base.py:2769. The new conflict-test expectation therefore does not match the helper it invokes. - The branch's direct
send()request path predates current_post_preserving_thread()delivery behavior atplugins/platforms/mattermost/adapter.py:367; preserve that behavior during reconciliation.
Suggested changes
- Put the path guard in
_request_json()and retain wrapper compatibility. - Align lock-conflict semantics and its regression test with the shared helper, or make any helper-contract change explicit and cross-platform.
- Reapply typed transient/permanent errors onto the current thread-preserving send path.
Automated hermes-sweeper review.
| :meth:`_api_put`, which wrap this. | ||
| """ | ||
| import aiohttp | ||
| url = f"{self._base_url}/api/v4/{path.lstrip('/')}" |
There was a problem hiding this comment.
Please retain the .. path rejection before constructing this URL. Current main added it in d836b2bac because WebSocket-event IDs can be attacker-controlled and otherwise steer authenticated bearer-token requests; the legacy wrappers no longer protect this shared request path.
| # (duplicate agent runs/replies). Key the lock on URL+token like the | ||
| # Discord/Signal/Slack adapters; _acquire_platform_lock records a | ||
| # non-retryable fatal error on conflict. | ||
| if not self._acquire_platform_lock( |
There was a problem hiding this comment.
_acquire_platform_lock() currently records lock conflicts with retryable=True (gateway/platforms/base.py:2769), so this failure will enter the reconnect queue. The new test/comment expecting a non-retryable fatal needs to match that shared contract or deliberately change it.
|
Heads-up on scope overlap: PR #85157 (merged, salvaging #80489) just changed the That supersedes this PR's substring-escalation half (and note the ordering hazard flagged in the #80489 thread: a conflict resolution that keeps this branch's side would reference the now-deleted |
Hardens the Mattermost adapter against eight independently-verified reliability bugs, each with focused regression coverage (15 new tests: 13 red->green for the fixes plus 2 green->green guards asserting genuine 4xx failures stay non-retryable; full file 58 passing, 43 existing + 15). Every fix mirrors an established sibling-adapter convention (IRC/Teams/Discord/Slack) rather than inventing new behavior, and the outbound-error plumbing is unified through one structured error type so transient vs permanent failures are classified once and reused by both
send()andedit_message().1. Outbound sends silently dropped on transient 5xx/429/network failures
Symptom: a momentary network blip, a 5xx, or a 429 rate-limit while posting a response caused the agent's reply to vanish with no retry and no user-facing notice.
Root cause:
_api_postswallowed both HTTP error responses (any status >= 400, including 429 and 5xx) andaiohttp.ClientErrorinto a single empty-dict sentinel.send()then returnedSendResult(success=False, error="Failed to create post")withretryabledefaulting to False.BasePlatformAdapter._send_with_retrydecides whether to retry viaresult.retryable or self._is_retryable_error(error_str); the static string matches none of the retryable patterns andretryablewas False, so the exponential-backoff loop was skipped, only the single plain-text fallback attempt ran, and the retry-exhausted user notice (emitted only inside the network branch) never fired.Fix: a new
_MMApiErrorcarries aretryableflag set True for network errors, timeouts, HTTP 429, and 5xx, and False for genuine 4xx. A shared_request_jsonraises it;send()translates it intoSendResult(success=False, error=str(exc), retryable=exc.retryable), mirroring Slack'ssend()returningstr(e). The legacy_api_get/_api_post/_api_putwrappers preserve the empty-dict sentinel for callers that still rely on it.2. Streamed-response progress editing permanently disabled by one transient edit blip
Symptom: a single transient API failure during streaming downgraded the entire remaining response to spammy append-only sends.
Root cause:
edit_message()returnedSendResult(success=False, error="Failed to edit post")withretryableunset. The stream consumer setscan_edit = Falsefor the rest of the response when an edit fails,getattr(result, 'retryable', False)is False, and the error contains neither 'flood' nor 'retry after' — all true here regardless of whether the failure was transient.Fix:
edit_message()returnsretryable=exc.retryablefrom the same_MMApiError, so the consumer keepscan_editalive across transient edit failures and only gives up on real 4xx. Mirrors Teams' send/edit handlers returningretryable=True.3. connect() retried forever on permanent auth/config failure
Symptom: a revoked/invalid token, missing permissions, or a wrong URL caused the adapter to re-create a session and re-hit
users/meforever (backoff-capped) instead of dropping out of the retry queue.Root cause: both failure exits (missing URL/token; failed
GET users/me) returned False without calling_set_fatal_error. The gateway startup path treats a False return with no fatal error as transient and queues it for indefinite reconnection.Fix: missing config calls
_set_fatal_error("config_missing", ..., retryable=False); a non-retryable_MMApiErrorfromusers/me(4xx) calls_set_fatal_error("auth_failed", ..., retryable=False)so the gateway drops the platform; a transient_MMApiError(network/5xx) setsretryable=Trueso a genuinely flaky connection still retries. Mirrors IRC'sconfig_missingand Teams'MISSING_CREDENTIALSnon-retryable escalation.4. Dead WebSocket listener never escalated -> zombie adapter
Symptom: on a permanent WS auth/permission failure the listener task ended while the adapter still reported itself connected; the gateway was never informed and never reconnected, so inbound messages were silently lost.
Root cause: the permanent-auth branch in
_ws_loopdid a barereturn, ending_ws_taskwhile_runningstayed True (set by_mark_connected). It never called_set_fatal_erroror_notify_fatal_error, and there is no health poll to notice.Fix: a new
_escalate_ws_fatalrecords_set_fatal_error("ws_auth_failed", ..., retryable=False)(which also clears_running) andawaits_notify_fatal_error(), bridging the dead listener back to the gateway's fatal machinery. Both theWSServerHandshakeError401/403 path and the substring-detected permanent-error path now escalate instead of returning silently. Mirrors IRC's receive-loopfinally, which calls_set_fatal_error+_notify_fatal_erroron connection loss.5. disconnect() left is_connected and runtime status stuck at 'connected'
Symptom: after a clean shutdown,
is_connectedkept returning True and the runtime status file still reported 'connected'.Root cause:
disconnect()closed the task/socket/session but never called_mark_disconnected(), so_runningstayed True.Fix:
disconnect()now calls_mark_disconnected()after closing the session (and releasing the lock), matching IRC and Teams.6. Single-instance lock missing -> two gateways double-process every post
Symptom: two gateway processes sharing one token each open their own
/api/v4/websocketlistener and both dispatchhandle_messagefor the same post, producing duplicate agent runs/replies.Root cause:
connect()never acquired a scoped platform lock; the per-process in-memoryMessageDeduplicatorcannot dedup across processes.Fix:
connect()calls_acquire_platform_lock("mattermost-token", f"{base_url}|{token}", "Mattermost bot token")after config validation, before opening the session;disconnect()calls_release_platform_lock(). Every post-acquire failure exit (transientconnect_failed, permanentauth_failed, and thenot me or "id" not in mebranch) also calls_release_platform_lock()before returning False, so a permanent auth failure cannot leak the lock and strand the token. A conflict records a non-retryable fatal error automatically. Mirrors Discord, which releases the lock on its post-acquire failure exits (timeout, generic exception), and Signal/Slack.7. Slash-captioned attachment mis-typed COMMAND -> file dropped
Symptom: uploading a file with a caption that begins with '/' (e.g.
/notes for review, or a pasted path) produced a message tagged COMMAND with the media never surfaced; downstream document surfacing requiresmessage_type == DOCUMENT, so the cached file was silently dropped.Root cause:
msg_typewas set to COMMAND from the leading slash before attachments were inspected, and the media-type override was guarded onmsg_type == MessageType.TEXTonly, so it never ran for the slash case.Fix: the override guard is relaxed to
msg_type in {MessageType.TEXT, MessageType.COMMAND}, so a downloaded attachment's media type (PHOTO/AUDIO/DOCUMENT) wins over a provisional COMMAND tag and the file is surfaced to the agent.8. All inbound audio force-classified VOICE (force-STT), never offered as a file
Symptom: any uploaded audio (music, a podcast, a non-speech clip) was force-routed to speech-to-text and the agent was never handed the actual file.
Root cause: every
audio/attachment was mapped toMessageType.VOICE, which run.py consumes only via transcription; the file path is surfaced to the agent only forMessageType.AUDIO.Fix: inbound audio is classified
MessageType.AUDIO. Mattermost has no distinct voice-note concept, so an audio upload is an ordinary file; run.py now surfaces the cached path to the agent. Mirrors Discord, which reserves VOICE for true voice-message attachments and uses AUDIO for ordinary audio files.Overlap
_api_get/_api_post/_api_puthelpers and theconnect()session setup to use a per-callaiohttp.ClientSession(cross-loop fix). This is a direct conflict with the_request_jsonrefactor here, which also consolidates those helpers and rebuilds the session/auth path inconnect(). Reconciliation: keep fix(mattermost): use per-call aiohttp ClientSession to avoid cross-loop bug #35343's per-call-session lifetime as the base and re-apply this change's transient/permanent error classification on top — route each per-call request through_request_jsonso theretryabledistinction and the_MMApiErrorplumbing survive, rather than reverting to the empty-dict sentinel. The two are complementary (session lifetime vs error typing) but must be merged by hand, not auto-applied.edit_message; it touches a method this change also rewrites (edit_messagenow returnsretryable=...). No logical conflict, but the hunks overlap and will need a manual merge.connect()/disconnect()region this change also modifies (lock release,_mark_disconnected). Different feature, shared methods — expect a textual merge inconnect/disconnect._mark_disconnected, or retryable-SendResult contracts introduced here.