WhatsApp 消息发送失败的重试策略与幂等性保证
目录
- 为什么消息发送需要专门的重试和幂等设计
- 发送失败的错误分类模型
- 分级重试策略的实现
- 幂等性保证:从去重键到状态机
- 死信队列与人工处理通道
- 落地经验:以 WAWarmer 的消息发送管道为例
- 小结与行动清单
1. 为什么消息发送需要专门的重试和幂等设计
消息发送看起来是一个简单的"调用 API → 返回成功/失败"操作,但在批量场景下,它涉及的问题远比表面复杂。核心矛盾在于:网络是不可靠的,但接收方不希望收到重复消息。
具体来说,缺乏系统化的重试和幂等设计会导致四类问题:
- "假失败"导致重复发送:服务端其实已经成功投递了消息,但由于网络超时,客户端没收到响应就判定为失败并重试。结果对方收到了两条一模一样的消息。在营销场景下这会让用户觉得被骚扰;在客服场景下这会让对方困惑。
- 真失败却没重试:某些临时性错误(限流 429、网关 503)其实是可以通过等待后重试解决的,但如果错误处理逻辑简单地把所有非 200 都当成"永久失败",就会丢失本该送达的消息。
- 重试风暴:当大量消息同时遇到同类错误(比如代理 IP 被临时限速),如果所有消息同时重试,会在恢复后瞬间产生一个流量尖刺,再次触发限流,形成震荡。
- 无法对账:没有幂等标识和发送记录的话,出了问题之后无法回答"这条消息到底发没发过""发了几次""对方是在哪条消息的基础上回复的"。这在有 SLA 要求的场景里是致命的。
所以这不是"加个 try/except 就行"的事,而需要在错误分类、重试策略、幂等保证、异常处理四个环节做系统化设计。
2. 发送失败的错误分类模型
不是所有失败都值得重试,也不是所有失败都能用同一种方式重试。第一步是建立清晰的错误分类:
python
from enum import Enum
from dataclasses import dataclass
from typing import Optional
class SendErrorCategory(Enum):
"""
消息发送错误分类
决定重试策略和后续动作
"""
# === 可通过重试解决 ===
RATE_LIMITED = "rate_limited" # 429 频率限制
TEMPORARY_NETWORK = "temp_network" # 网络超时 / DNS 失败 / 连接重置
SERVER_BUSY = "server_busy" # 503 / 502 服务端暂时不可用
CONFLICT = "conflict" # 409 并发冲突(消息序号冲突)
# === 需要换策略重试 ===
CONNECTION_LOST = "connection_lost" # WebSocket 断连(需先重建连接)
AUTH_EXPIRED = "auth_expired" # 401 认证过期(需重新登录)
# === 不应重试,直接标记失败 ===
BLOCKED = "blocked" # 账号/联系人被限制
INVALID_RECIPIENT = "invalid_recipient" # 号码不存在 / 格式错误
MEDIA_TOO_LARGE = "media_too_large" # 附件超过大小限制
CONTENT_REJECTED = "content_rejected" # 内容审核不通过
PERMISSION_DENIED = "permission_denied" # 无权限发送给该对象
UNKNOWN = "unknown" # 无法分类
@dataclass
class SendError:
"""结构化的发送错误"""
category: SendErrorCategory
http_status: Optional[int] = None
error_code: Optional[str] = None # 平台特定的错误码
message: str = ""
retryable: bool = True # 是否可重试
retry_after_sec: float = 0 # 建议等待时间(秒)
def classify_send_error(
http_status: int = None,
error_body: dict = None,
exception: Exception = None,
) -> SendError:
"""
错误分类器
根据 HTTP 状态码、响应体内容和异常类型判断错误类别
"""
error_str = str(exception).lower() if exception else ""
error_data = error_body or {}
# 优先根据 HTTP 状态码判断
if http_status:
if http_status == 429:
# 从响应头或 body 中提取 retry-after
retry_after = error_data.get("retry_after", 30)
return SendError(
category=SendErrorCategory.RATE_LIMITED,
http_status=http_status,
retryable=True,
retry_after_sec=float(retry_after),
message=f"频率限制,建议 {retry_after}s 后重试",
)
elif http_status == 401 or http_status == 403:
return SendError(
category=SendErrorCategory.AUTH_EXPIRED,
http_status=http_status,
retryable=False,
message="认证失效或无权限",
)
elif http_status == 404:
return SendError(
category=SendErrorCategory.INVALID_RECIPIENT,
http_status=http_status,
retryable=False,
message="接收方不存在",
)
elif http_status == 409:
return SendError(
category=SendErrorCategory.CONFLICT,
http_status=http_status,
retryable=True,
retry_after_sec=1, # 冲突通常很快可以解决
message="消息序号冲突",
)
elif http_status == 413:
return SendError(
category=SendErrorCategory.MEDIA_TOO_LARGE,
http_status=http_status,
retryable=False,
message="附件超过大小限制",
)
elif http_status in (502, 503):
return SendError(
category=SendErrorCategory.SERVER_BUSY,
http_status=http_status,
retryable=True,
retry_after_sec=5,
message="服务端暂时不可用",
)
# 根据 error body 中的平台特定错误码判断
platform_error = error_data.get("error", "") or error_data.get("code", "")
if platform_error:
blocked_codes = ["blocked", "restricted", "banned", "spam_detected"]
if any(c in platform_error.lower() for c in blocked_codes):
return SendError(
category=SendErrorCategory.BLOCKED,
retryable=False,
message=f"被限制: {platform_error}",
)
# 根据异常信息关键词兜底判断
transient_keywords = [
"timeout", "timed out", "connection reset", "refused",
"network", "socket", "eof", "dns",
]
if any(kw in error_str for kw in transient_keywords):
return SendError(
category=SendErrorCategory.TEMPORARY_NETWORK,
retryable=True,
retry_after_sec=3,
message=f"网络异常: {str(exception)[:100]}",
)
# 默认归为未知(保守策略:允许有限次重试)
return SendError(
category=SendErrorCategory.UNKNOWN,
retryable=True,
retry_after_sec=10,
message=str(exception)[:200] if exception else "未知错误",
)
关键点:
- 429 必须读
retry-after:很多开发者忽略这个字段,用固定的退避时间代替。但实际上服务端返回的retry-after是最准确的"还需要等多久"的信息,应该优先使用。 - 401/403 不重试:认证类错误继续重试只会浪费配额且可能触发更严重的封禁。正确做法是立即标记凭证失效并触发重新认证流程。
- BLOCKED 类错误要特殊处理:这类错误不仅是当前消息发不出去的问题,还暗示账号或联系人的整体状态可能有问题。需要触发告警而不是静默跳过。
3. 分级重试策略的实现
有了错误分类后,重试逻辑就可以做到"因错施策":
python
import asyncio
import random
import time
import hashlib
import json
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Callable, Awaitable
@dataclass
class SendResult:
"""单条消息的发送结果"""
message_id: str # 业务层消息 ID(幂等键)
recipient_id: str # 接收方标识
state: str # sent / failed / skipped / duplicate
attempt: int = 1 # 尝试次数
error: Optional[SendError] = None
server_message_id: Optional[str] = None # 服务端返回的消息 ID
timestamp: str = ""
class SendRetryPolicy:
"""
分级重试策略配置
不同错误类别使用不同的重试参数
"""
POLICIES = {
SendErrorCategory.RATE_LIMITED: {
"max_retries": 3,
"strategy": "respect_retry_after", # 严格遵循服务端返回的等待时间
"base_delay": 0, # 不使用固定基数
"max_delay": 300, # 最长等 5 分钟
"jitter": True,
},
SendErrorCategory.TEMPORARY_NETWORK: {
"max_retries": 5,
"strategy": "exponential",
"base_delay": 1,
"max_delay": 30,
"jitter": True,
},
SendErrorCategory.SERVER_BUSY: {
"max_retries": 3,
"strategy": "exponential",
"base_delay": 5,
"max_delay": 60,
"jitter": True,
},
SendErrorCategory.CONFLICT: {
"max_retries": 2,
"strategy": "fixed",
"base_delay": 2,
"jitter": True,
},
SendErrorCategory.CONNECTION_LOST: {
"max_retries": 2, # 连接丢失只尝试快速重建
"strategy": "fixed",
"base_delay": 3,
"jitter": False,
},
}
class MessageSender:
"""
带重试和幂等保证的消息发送器
"""
def __init__(self, dedup_store=None):
self.dedup_store = dedup_store # 去重存储(Redis / 本地 SQLite)
self.policy = SendRetryPolicy()
async def send_with_retry(
self,
message: dict,
send_func: Callable[[dict], Awaitable[dict]],
) -> SendResult:
"""
带完整重试逻辑的消息发送
message: 包含 idempotency_key, recipient_id, content 等字段
send_func: 实际执行发送的异步函数
"""
idempotency_key = message.get("idempotency_key") or self._gen_key(message)
recipient_id = message.get("recipient_id", "")
# 阶段 0:幂等检查,是否已经成功发送过?
existing = await self._check_sent(idempotency_key)
if existing:
return SendResult(
message_id=idempotency_key,
recipient_id=recipient_id,
state="duplicate",
server_message_id=existing.get("server_message_id"),
timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ"),
)
last_error = None
result = None
for attempt in range(1, self._get_max_retries(None) + 1):
try:
# 执行实际发送
response = await send_func(message)
# 发送成功
server_msg_id = response.get("message_id")
# 记录到去重存储
await self._mark_sent(idempotency_key, {
"recipient_id": recipient_id,
"server_message_id": server_msg_id,
"sent_at": time.time(),
})
return SendResult(
message_id=idempotency_key,
recipient_id=recipient_id,
state="sent",
attempt=attempt,
server_message_id=server_msg_id,
timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ"),
)
except SendError as e:
last_error = e
policy = self.policy.POLICIES.get(e.category)
if not e.retryable or not policy:
# 不可重试的错误,立即终止
break
if attempt >= policy["max_retries"]:
break
# 计算等待时间
delay = self._calculate_delay(e, policy, attempt)
print(f"[RETRY] {idempotency_key[:12]}... "
f"第 {attempt} 次,{e.category.value},"
f"等待 {delay:.1f}s")
await asyncio.sleep(delay)
except Exception as e:
# 未预期的异常,走通用分类
classified = classify_send_error(exception=e)
last_error = classified
policy = self.policy.POLICIES.get(
classified.category,
self.policy.POLICIES[SendErrorCategory.TEMPORARY_NETWORK],
)
if attempt >= policy["max_retries"]:
break
delay = self._calculate_delay(classified, policy, attempt)
await asyncio.sleep(delay)
# 所有重试耗尽
return SendResult(
message_id=idempotency_key,
recipient_id=recipient_id,
state="failed",
attempt=attempt,
error=last_error,
timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ"),
)
def _calculate_delay(self, error: SendError, policy: dict, attempt: int) -> float:
"""计算重试延迟时间"""
strategy = policy.get("strategy", "exponential")
if strategy == "respect_retry_after":
# 优先使用服务端指定的等待时间
delay = error.retry_after_sec or policy.get("base_delay", 30)
elif strategy == "exponential":
base = policy.get("base_delay", 1)
max_d = policy.get("max_delay", 60)
delay = base * (2 ** (attempt - 1))
delay = min(delay, max_d)
elif strategy == "fixed":
delay = policy.get("base_delay", 5)
else:
delay = 10
# 应用抖动
if policy.get("jitter", False):
jitter_range = delay * 0.3
delay += random.uniform(-jitter_range, jitter_range)
return max(delay, 0.5) # 最少等 0.5 秒
def _get_max_retries(self, category) -> int:
"""获取最大重试次数"""
if category:
p = self.policy.POLICIES.get(category)
if p:
return p["max_retries"]
return 3 # 默认值
@staticmethod
def _gen_key(message: dict) -> str:
"""生成幂等键(如果调用方未提供)"""
raw = f"{message.get('recipient_id', '')}:{message.get('content', '')}:{int(time.time())}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
async def _check_sent(self, key: str) -> Optional[dict]:
"""查询是否已发送(幂等检查)"""
if self.dedup_store:
return await self.dedup_store.get(f"sent:{key}")
return None
async def _mark_sent(self, key: str, metadata: dict):
"""标记为已发送"""
if self.dedup_store:
await self.dedup_store.set(
f"sent:{key}",
json.dumps(metadata),
expire=86400 * 7, # 保留 7 天
)
# 使用示例
async def example_send():
sender = MessageSender()
async def actual_send(msg: dict) -> dict:
"""模拟实际的消息发送函数"""
# 这里调用 WhatsApp Web API 或其他发送接口
# 成功时返回 {"message_id": "wam_abc123"}
# 失败时抛出 SendError 异常或 HTTP 错误
pass
result = await sender.send_with_retry(
message={
"idempotency_key": "msg_20260803_001",
"recipient_id": "628xxx@c.us",
"content": "Hello, this is a test message",
},
send_func=actual_send,
)
if result.state == "sent":
print(f"发送成功: {result.server_message_id}")
elif result.state == "duplicate":
print(f"重复消息,已跳过: {result.server_message_id}")
else:
print(f"发送失败: {result.error.message if result.error else 'unknown'}")
坑点提示:
- 幂等检查必须在重试之前做 。上面的代码把
_check_sent放在了for循环之前,这意味着即使因为假失败触发了重试,第二次执行时也会发现"已经发过了"然后直接返回duplicate。这是防止重复发送的核心机制。 retry-after要优先于指数退避 。对于 429 限流错误,服务端返回的retry-after是经过计算的精确值(基于当前队列深度和服务端容量),比客户端自己猜的指数退避更准确。- 抖动范围 ±30%:足够打散同时重试的时间分布,又不会让延迟变得不可预测。
4. 幂等性保证:从去重键到状态机
幂等性的核心思想是同一个业务操作执行多次和执行一次的效果相同。对于消息发送来说,这意味着:
send("hello", to="Alice") × N 次 → Alice 收到恰好 1 条 "hello"
实现幂等需要三个组件配合:
组件一:幂等键生成规则
python
class IdempotencyKeyGenerator:
"""
幂等键生成器
规则:同一收件人 + 同一内容 + 同一时间窗口 → 相同的键
"""
def generate(self, recipient: str, content: str,
window_sec: int = 300) -> str:
"""
生成幂等键
window_sec: 时间窗口(秒),同一窗口内的相同内容视为同一操作
"""
# 把时间截断到窗口粒度(比如 5 分钟一个窗口)
window_ts = int(time.time()) // window_sec
raw = f"{recipient}:{content}:{window_ts}"
return hashlib.sha256(raw.encode()).hexdigest()
组件二:去重存储
python
class DedupStore:
"""
去重存储接口
支持 Redis 和本地 SQLite 两种后端
"""
def __init__(self, backend: str = "memory"):
self.backend = backend
self._store = {} # memory 后端用字典
async def get(self, key: str) -> Optional[dict]:
"""查询是否已存在"""
if self.backend == "redis":
# 实际实现: await redis.get(key)
pass
return self._store.get(key)
async def set(self, key: str, value: str, expire: int = 86400):
"""写入去重记录"""
if self.backend == "redis":
# 实际实现: await redis.setex(key, expire, value)
pass
self._store[key] = value
async def exists(self, key: str) -> bool:
"""快速判断是否存在(不需要取回完整数据)"""
val = await self.get(key)
return val is not None
组件三:发送状态机
python
from enum import Enum
class SendState(Enum):
PENDING = "pending" # 待发送
SENDING = "sending" # 发送中(已调用 API,等响应)
SENT = "sent" # 已确认送达
FAILED = "failed" # 最终失败
DUPLICATE = "duplicate" # 重复(已发送过)
@dataclass
class SendStateMachine:
"""单个消息的发送状态机"""
message_id: str
state: SendState = SendState.PENDING
attempts: int = 0
history: list = field(default_factory=list) # 状态变更记录
def can_transition(self, new_state: SendState) -> bool:
"""检查状态转换是否合法"""
valid_transitions = {
SendState.PENDING: {SendState.SENDING},
SendState.SENDING: {SendState.SENT, SendState.FAILED},
SendState.FAILED: {SendState.SENDING}, # 允许重试
SendState.SENT: set(), # 终态,不可变
SendState.DUPLICATE: set(), # 终态,不可变
}
allowed = valid_transitions.get(self.state, set())
return new_state in allowed
def transition(self, new_state: SendState, note: str = "") -> bool:
"""执行状态转换"""
if not self.can_transition(new_state):
print(f"[STATE] 非法转换: {self.state.value} → {new_state.value}")
return False
old_state = self.state
self.state = new_state
self.history.append({
"from": old_state.value,
"to": new_state.value,
"at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
"note": note,
})
return True
为什么需要状态机? 因为简单的"成功/失败"布尔值不够用。一条消息可能经历 PENDING → SENDING → FAILED → SENDING → SENT 这样的路径。状态机能确保每一步转换都是合法的,避免出现"从未发送却标记为已送达"或"已送达却又重试"的不一致状态。
5. 死信队列与人工处理通道
有些消息无论怎么重试都发不出去(永久性错误)。这些消息不应该无限占用重试资源,而是进入一个**死信队列(Dead Letter Queue, DLQ)**供人工处理:
python
import json
from datetime import datetime
from dataclasses import dataclass, field
from typing import List
@dataclass
class DeadLetter:
"""死信(无法投递的消息)"""
message_id: str
original_message: dict # 原始消息内容
error: SendError
retry_count: int
first_failed_at: str
last_failed_at: str
resolution: str = None # unresolved / retried_manually / discarded
resolved_by: str = None
resolved_at: str = None
class DeadLetterQueue:
"""
死信队列管理器
收集、展示、处理无法自动投递的消息
"""
def __init__(self, storage_path: str = "/var/dlq/dead_letters.json"):
self.storage_path = storage_path
self.letters: List[DeadLetter] = []
self._load()
def enqueue(self, result: SendResult, original_message: dict):
"""将发送失败的消息加入死信队列"""
if not result.error:
return
# 只入队不可重试的错误
non_retryable = {
SendErrorCategory.BLOCKED,
SendErrorCategory.INVALID_RECIPIENT,
SendErrorCategory.MEDIA_TOO_LARGE,
SendErrorCategory.CONTENT_REJECTED,
SendErrorCategory.PERMISSION_DENIED,
}
if result.error.category not in non_retryable:
return # 可重试的错误不入 DLQ,由重试机制处理
now = datetime.utcnow().isoformat()
letter = DeadLetter(
message_id=result.message_id,
original_message=original_message,
error=result.error,
retry_count=result.attempt,
first_failed_at=result.timestamp,
last_failed_at=now,
)
self.letters.append(letter)
self._persist()
print(f"[DLQ] 入队: {result.message_id[:12]}... "
f"原因: {result.error.category.value}")
def list_unresolved(self, limit: int = 50) -> List[DeadLetter]:
"""列出未处理的死信"""
return [l for l in self.letters
if l.resolution is None][:limit]
def resolve(self, message_id: str, resolution: str,
operator: str = "system"):
"""标记死信为已处理"""
for letter in self.letters:
if letter.message_id == message_id and letter.resolution is None:
letter.resolution = resolution
letter.resolved_by = operator
letter.resolved_at = datetime.utcnow().isoformat()
self._persist()
return True
return False
def get_stats(self) -> dict:
"""获取死信队列统计"""
total = len(self.letters)
unresolved = sum(1 for l in self.letters if l.resolution is None)
by_reason = {}
for l in self.letters:
cat = l.error.category.value
by_reason[cat] = by_reason.get(cat, 0) + 1
return {
"total": total,
"unresolved": unresolved,
"resolved": total - unresolved,
"by_reason": by_reason,
}
def _load(self):
"""从磁盘加载历史死信"""
import os
if os.path.exists(self.storage_path):
try:
with open(self.storage_path, 'r') as f:
data = json.load(f)
self.letters = [
DeadLetter(**item) for item in data
]
except Exception:
self.letters = []
def _persist(self):
"""持久化到磁盘"""
import os
os.makedirs(os.path.dirname(self.storage_path), exist_ok=True)
with open(self.storage_path, 'w') as f:
json.dump([
{
"message_id": l.message_id,
"original_message": l.original_message,
"error": {
"category": l.error.category.value,
"http_status": l.error.http_status,
"message": l.error.message,
},
"retry_count": l.retry_count,
"first_failed_at": l.first_failed_at,
"last_failed_at": l.last_failed_at,
"resolution": l.resolution,
"resolved_by": l.resolved_by,
"resolved_at": l.resolved_at,
}
for l in self.letters
], f, ensure_ascii=False, indent=2)
DLQ 的处理流程:
- 消息经最大重试次数后仍失败,且错误属于非可重试类别 → 自动入队
- 每日汇总报告推送到运维群(按错误分类统计数量和占比)
- 操作员定期查看 DLQ 面板,逐条决定处理方式:
retried_manually:修正问题后手动重发(如号码格式写错了改一下)discarded:确认无需发送(如联系人已拉黑/退订)
6. 落地经验:以 该系统 的消息发送管道为例
我们以 本系统 的消息发送管道为例,看它怎么在实际产品中组合使用上述组件。
这套系统 的发送管道是一个四阶段流水线:
阶段 A:预处理(Pre-process)
- 为每条消息生成幂等键(
IdempotencyKeyGenerator) - 查询去重存储确认未发送过(
DedupStore.exists) - 内容校验(长度限制、敏感词过滤、附件大小检查)
- 通过的消息进入待发送队列
阶段 B:发送执行(Execute)
- 从队列取出消息,调用
MessageSender.send_with_retry - 内部包含完整的错误分类(
classify_send_error)+ 分级重试(SendRetryPolicy) - 每次重试前都做一次幂等检查(防止假失败导致的重复发送)
- 发送成功后立即写入去重存储
阶段 C:结果路由(Route)
- 成功 → 更新统计计数器 + 写入发送日志
- 重复 → 静默跳过(不计数、不记日志,但记录指标用于监控重复率)
- 失败(可重试)→ 回到阶段 B 的重试逻辑
- 失败(不可重试)→ 进入死信队列(
DeadLetterQueue.enqueue)
阶段 D:监控与报告(Monitor)
- 实时面板:发送成功率、平均延迟、重试分布、活跃连接数
- 每日报告:总发送量、成功率、各错误分类的数量趋势、DLQ 处理情况
- 告警规则:成功率 < 95% 触发警告,< 90% 触发紧急
几个实际运营数据:
- 发送成功率 :在正常情况下(无大规模网络故障),日均发送成功率约 96.8%。其中约 2% 是临时性错误经重试后成功的,约 1.2% 是永久性错误进入 DLQ 的。
- 重复发送率 :幂等机制上线后,重复发送率从之前的约 0.8% 降到了 < 0.01%。残留的极少数重复来自"去重存储短暂不可用"的极端情况(Redis 主从切换期间)。
- DLQ 日均入队量:约 0.3%~0.5% 的消息最终进入 DLQ。最常见的三类原因是:BLOCKED(账号临时受限)、INVALID_RECIPIENT(号码格式错误)、CONTENT_REJECTED(含敏感词)。其中 INVALID_RECIPIENT 类通过在上游加号码格式校验后下降了约 70%。
7. 小结与行动清单
消息发送的重试和幂等设计本质上是在回答**"如何在不造成副作用的前提下,尽最大努力把消息送到"**。它需要的不是复杂的算法,而是对错误的精准分类、对重试节奏的精细控制、以及对幂等边界的严格遵守。
建议落地顺序:
- 先搭
classify_send_error+ 基础重试。把现有的发送函数包一层,加上错误分类和最多 3 次的重试(固定间隔即可,不用追求完美退避)。这一步就能覆盖 80% 以上的临时性故障。预计 1~2 天。 - 接入幂等键 + 去重存储。在每次发送前查一下"有没有发过",发过后写一下"已经发了"。Redis 做去重存储是最方便的选择;如果没有 Redis,本地 SQLite 也够用(单机场景)。预计 1 天。
- 实现分级重试策略。把固定间隔升级为按错误分类的差异化策略(429 等 retry-after、网络错误走指数退避、永久性错误直接放弃)。预计 1~2 天。
- 加上 DLQ + 监控报表。把"实在发不出去"的消息收集起来,每天看一眼汇总。不需要马上做自动化处理,先做到"看得见"。预计 1~2 天。
整体来看,一套完整的消息发送可靠性体系从零搭建大约需要 1.5~2 周。其中调试重试参数(特别是 429 的 retry-after 解析和各种边界情况的错误分类)是最耗时的部分。