Skip to content

Socket Mode: retry_attempt and retry_reason are dropped when building AsyncBoltRequest #1484

@loganrosen

Description

@loganrosen

Summary

In Socket Mode, SocketModeRequest.retry_attempt and SocketModeRequest.retry_reason are silently dropped when constructing the AsyncBoltRequest. This makes it impossible for middleware/listeners running in Socket Mode to detect Slack Events API retries — even though the same information is readily available to HTTP-mode handlers via X-Slack-Retry-Num / X-Slack-Retry-Reason headers.

Reproduction

from slack_bolt.request.async_request import AsyncBoltRequest
from slack_sdk.socket_mode.request import SocketModeRequest

envelope = {
    "type": "events_api",
    "envelope_id": "abc-123",
    "payload": {
        "type": "event_callback",
        "event_id": "Ev123",
        "event": {"type": "app_mention", "text": "<@U1> hello"},
    },
    "accepts_response_payload": False,
    "retry_attempt": 2,
    "retry_reason": "http_timeout",
}
sm_req = SocketModeRequest.from_dict(envelope)
print(sm_req.retry_attempt)   # 2
print(sm_req.retry_reason)    # "http_timeout"

# What slack_bolt does in run_async_bolt_app:
bolt_req = AsyncBoltRequest(mode="socket_mode", body=sm_req.payload)
print(bolt_req.headers)                                # {}
print("retry_attempt" in bolt_req.body)                # False
print(list(bolt_req.body.keys()))                      # ['type', 'event_id', 'event']

Root Cause

slack_bolt/adapter/socket_mode/async_internals.py:

async def run_async_bolt_app(app: AsyncApp, req: SocketModeRequest):
    bolt_req: AsyncBoltRequest = AsyncBoltRequest(mode="socket_mode", body=req.payload)
    ...

Only req.payload is propagated. The retry fields on the SocketModeRequest envelope (which sit alongside payload, not inside it) are not carried into the bolt request. The sync version in internals.py has the same issue.

Why this matters

Slack's Events API automatically retries delivery if the receiver doesn't ack within 3s. Apps need to detect retries to:

  • Idempotently handle events (avoid double-processing)
  • Distinguish "silently dropped envelopes" from "retried envelopes" in observability metrics
  • Log retry context (e.g., retry_reason="http_timeout") for debugging

In HTTP mode this works via headers (request.headers.get("x-slack-retry-num") is the documented pattern). In Socket Mode there is currently no documented or convenient way to access this from middleware.

Issue #868 included a maintainer comment suggesting retry_attempt exists "as a top-level property in body" for socket mode, but as shown above, it does not — only req.payload (which excludes retry_attempt) becomes bolt_req.body.

Proposed Fix

Propagate retry info into the bolt request. Two reasonable approaches:

Option 1 — synthesize headers (mirrors HTTP mode; existing retry-detection patterns work unchanged):

async def run_async_bolt_app(app: AsyncApp, req: SocketModeRequest):
    headers = {}
    if req.retry_attempt is not None:
        headers["x-slack-retry-num"] = str(req.retry_attempt)
    if req.retry_reason is not None:
        headers["x-slack-retry-reason"] = req.retry_reason
    bolt_req = AsyncBoltRequest(mode="socket_mode", body=req.payload, headers=headers or None)
    ...

Option 2 — surface via context:

context = {"retry_attempt": req.retry_attempt, "retry_reason": req.retry_reason}
bolt_req = AsyncBoltRequest(mode="socket_mode", body=req.payload, context=context)

Option 1 has the advantage that existing HTTP-mode retry-detection code works unchanged in Socket Mode.

Happy to send a PR if there's interest.

Versions

  • slack_bolt==1.28.0 (latest as of writing — same code on main)
  • slack_sdk==3.40.0
  • Python 3.12

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions