首页 / 博客 / 框架·排错·其它

手把手封装一个 Python 微信 API SDK(带重试与异常处理)

分类:框架·排错·其它 · 标签:Python、微信API、SDK封装

前言

在用 HTTP 接口对接微信能力时,散落在各处的 requests.post 调用很快会让项目陷入混乱:超时重试写了三遍、错误码判断复制来复制去、每个地方都要手动拼 headers……这些重复劳动不仅浪费时间,一旦接口地址或鉴权方式发生变化,更是牵一发而动全身。

本文从零开始,用 Python 封装一个轻量级 SDK 层,把重试逻辑、统一异常、接口方法都收拢进来。读完你将得到一个可直接套用到项目里的模块,只需改动配置就能调用发消息、管理联系人、操作群组等常见接口。整个实现不依赖任何重量级框架,只用标准库加 requests,既方便调试,也便于集成进已有项目。


一、SDK 整体设计思路

封装一个 SDK,本质上是在原始 HTTP 客户端上加一层"适配器",解决以下几个问题:

问题没有 SDK 时的写法SDK 封装后
鉴权 header每次 requests.post 都手写初始化时注入,自动携带
超时与重试if/else 散落各处统一在基类处理
错误码解析resp["ret"] == 200 写 N 遍基类自动判断,抛异常
接口方法每处都拼 URL + body封装成方法,调用方只关心业务参数

设计原则:单一职责、最小依赖、可测试。整个 SDK 只依赖标准库 + requests,不引入重量级框架。

为什么要用 Mixin 而不是继承链?

很多教程喜欢把所有方法塞进一个 Client 类里,短期方便,长期维护噩梦。Mixin 模式把消息、联系人、群组各自隔离,测试时只需对目标 Mixin 单独 mock,不用搭起整个 Client。多个 Mixin 通过 Python 的 MRO(方法解析顺序)安全组合,不会出现方法冲突。

关于版本兼容与扩展性:SDK 初始化时把所有可调参数(超时、重试次数、退避间隔)都暴露出来,而不是写死在代码里。这样当不同项目对响应速度和稳定性的要求不同时,只需在实例化时传入不同参数,无需修改 SDK 本身。后续如果需要支持异步调用,只需在 WechatClient 基础上再封装一个 AsyncWechatClient,业务 Mixin 层完全不需要动。


二、目录结构

wechat_sdk/
├── __init__.py
├── client.py        # 核心 HTTP 客户端(重试、鉴权、异常)
├── exceptions.py    # 自定义异常体系
├── message.py       # 消息相关接口
├── contact.py       # 联系人接口
└── group.py         # 群组接口

保持扁平,按业务域分文件,后续增加朋友圈、登录等模块同样照此模式扩展。每个文件职责单一:client.py 只管网络和重试,exceptions.py 只定义异常类型,业务模块只包含对应领域的接口方法。这种结构在团队协作时也很友好,不同成员负责不同模块,PR 冲突率极低。


三、自定义异常体系

异常是 SDK 的"错误语言",调用方通过 except 精确捕获,比裸 return False 友好得多。

python# wechat_sdk/exceptions.py

class WechatSDKError(Exception):
    """SDK 基础异常,所有异常的父类"""
    pass


class AuthError(WechatSDKError):
    """鉴权失败,Token 无效或过期"""
    pass


class ApiError(WechatSDKError):
    """接口返回业务错误(ret != 200)"""
    def __init__(self, ret: int, msg: str):
        self.ret = ret
        self.msg = msg
        super().__init__(f"[ret={ret}] {msg}")


class NetworkError(WechatSDKError):
    """网络层异常,请求未到达服务端或超时"""
    pass


class RetryExhaustedError(WechatSDKError):
    """重试次数耗尽后仍失败"""
    pass

层次清晰:WechatSDKError 是根,ApiError 携带 retmsg 方便日志记录,RetryExhaustedError 供调用方决定是否发告警。

注意事项ApiError 故意把 retmsg 设为公开属性,而不是只放进 args[0] 字符串。这样调用方可以 if e.ret == 429 分支处理频率限制,而不需要解析字符串——接口变动时维护成本更低。

异常体系的另一个设计要点是不要过度细化。有些团队倾向于为每种错误码单独定义一个异常类,结果维护了几十个异常类型,调用方反而不知道该 except 哪个。本文的做法是在 ApiError 里保留 ret 属性,细化判断交给调用方,异常类只区分"鉴权失败""业务错误""网络问题""重试耗尽"四种场景,足以覆盖绝大多数处理需求。


四、核心客户端:重试与鉴权

这是整个 SDK 的基础,所有业务模块都继承或组合它。

python# wechat_sdk/client.py

import time
import logging
import requests
from typing import Any, Dict, Optional

from .exceptions import ApiError, AuthError, NetworkError, RetryExhaustedError

logger = logging.getLogger(__name__)

# ──────────────────────────────────────────────
# 占位符配置——注册后在官方文档获取真实值
BASE_URL = "https://你的接口域名"   # 注册后在官方文档获取
TOKEN    = "你的Token"               # 以官方文档为准
APPID    = "你的appId"               # 扫码登录后获取
# ──────────────────────────────────────────────

DEFAULT_TIMEOUT = 15        # 单次请求超时(秒)
DEFAULT_RETRIES = 3         # 最大重试次数
RETRY_BACKOFF   = (1, 2, 4) # 退避间隔(秒)


class WechatClient:
    """
    底层 HTTP 客户端。
    - 统一注入 token header
    - 自动重试(指数退避)
    - 解析 ret 码,非 200 抛 ApiError
    """

    def __init__(
        self,
        base_url: str = BASE_URL,
        token: str = TOKEN,
        app_id: str = APPID,
        timeout: int = DEFAULT_TIMEOUT,
        max_retries: int = DEFAULT_RETRIES,
    ):
        self.base_url   = base_url.rstrip("/")
        self.app_id     = app_id
        self.timeout    = timeout
        self.max_retries = max_retries
        self._session   = requests.Session()
        # 鉴权字段名以官方文档为准
        self._session.headers.update({"token": token, "Content-Type": "application/json"})

    def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
        """
        发起 POST 请求,带重试逻辑。
        成功返回 data 字段;失败抛对应异常。
        """
        url = f"{self.base_url}{path}"
        last_exc: Optional[Exception] = None

        for attempt in range(self.max_retries):
            try:
                resp = self._session.post(url, json=body, timeout=self.timeout)
                resp.raise_for_status()
                payload = resp.json()
            except requests.exceptions.Timeout as e:
                last_exc = NetworkError(f"请求超时(第 {attempt+1} 次): {e}")
                logger.warning(str(last_exc))
            except requests.exceptions.ConnectionError as e:
                last_exc = NetworkError(f"连接错误(第 {attempt+1} 次): {e}")
                logger.warning(str(last_exc))
            except requests.exceptions.HTTPError as e:
                # HTTP 4xx/5xx
                status = e.response.status_code if e.response else "?"
                if status == 401:
                    raise AuthError("Token 鉴权失败,请检查 token 配置") from e
                last_exc = NetworkError(f"HTTP {status}(第 {attempt+1} 次): {e}")
                logger.warning(str(last_exc))
            except ValueError as e:
                last_exc = NetworkError(f"响应不是合法 JSON: {e}")
                logger.warning(str(last_exc))
            else:
                # 网络层成功,解析业务码
                ret = payload.get("ret", -1)
                msg = payload.get("msg", "")
                if ret == 200:
                    return payload.get("data", {})
                raise ApiError(ret=ret, msg=msg)

            # 退避等待后重试
            wait = RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)]
            logger.info("等待 %ss 后重试...", wait)
            time.sleep(wait)

        raise RetryExhaustedError(
            f"请求 {path} 重试 {self.max_retries} 次后仍失败"
        ) from last_exc

几个设计细节值得注意:

  1. 退避间隔RETRY_BACKOFF = (1, 2, 4) 实现简单的指数退避,避免瞬间打爆接口。
  2. raise_for_status 在循环内:HTTP 401 直接抛 AuthError 不再重试,因为重试也没用。
  3. 业务码 ret 解析在网络成功后才做:业务错误(如频率限制)也不应触发网络重试。
  4. requests.Session 复用:连接池复用减少 TCP 握手开销,高并发场景效果明显。

实操细节:生产环境建议把 BASE_URLTOKENAPPID 通过环境变量或配置文件注入,不要硬编码在源码中。可以在 __init__ 里加 os.environ.get("WECHAT_TOKEN", "") 兜底读取,并在值为空时立即抛出 ValueError,让问题在启动时暴露而不是在运行时静默失败。

关于重试策略的边界:退避间隔 (1, 2, 4) 适合多数场景,但如果你的业务对延迟敏感(如实时消息通知),可以缩短为 (0.5, 1, 2);如果接口本身不稳定、偶发性超时较多,则可以延长到 (2, 5, 10) 并适当增加 max_retries。需要特别注意的是,发送消息类接口不建议重试:消息已经被服务端处理但响应未返回时,重试会导致重复发送。建议对写操作(发消息、建群、加好友)单独设置 max_retries=1,只对查询类接口开启重试。


五、消息模块封装

消息模块是最常用的,把所有 message/* 接口收拢进来。

python# wechat_sdk/message.py

from typing import List, Optional
from .client import WechatClient


class MessageMixin:
    """
    消息接口 Mixin。
    混入 WechatClient 或独立组合均可。
    具体接口/字段以官方文档为准,以下为示例。
    """

    _client: WechatClient  # 类型提示,组合模式下由外部注入

    def send_text(self, to_wxid: str, content: str, ats: Optional[List[str]] = None) -> dict:
        """
        发送文本消息。
        :param to_wxid:  接收方 wxid(个人或群 ID)
        :param content:  消息内容
        :param ats:      群内 @ 的 wxid 列表,可选
        """
        body = {
            "appId":   self._client.app_id,
            "toWxid":  to_wxid,
            "content": content,
        }
        if ats:
            body["ats"] = ",".join(ats)
        return self._client._post("/message/postText", body)

    def send_image(self, to_wxid: str, image_url: str) -> dict:
        """发送图片消息,image_url 为图片链接"""
        body = {
            "appId":  self._client.app_id,
            "toWxid": to_wxid,
            "imgUrl": image_url,
        }
        return self._client._post("/message/postImage", body)

    def forward_image(self, to_wxid: str, msg_id: str) -> dict:
        """转发图片(用 msgId,避免重复上传)"""
        body = {
            "appId":  self._client.app_id,
            "toWxid": to_wxid,
            "msgId":  msg_id,
        }
        return self._client._post("/message/forwardImage", body)

    def send_file(self, to_wxid: str, file_url: str, file_name: str) -> dict:
        """发送文件消息"""
        body = {
            "appId":    self._client.app_id,
            "toWxid":   to_wxid,
            "fileUrl":  file_url,
            "fileName": file_name,
        }
        return self._client._post("/message/postFile", body)

注意事项:发送图片时优先用 forward_image 转发已有的 msgId,而不是每次都传 URL 重新上传。同一张图片多次上传不仅浪费流量,还可能触发接口的防刷机制。如果图片来自本地,建议先上传一次拿到 msgId,后续全部走转发接口。

消息内容的安全处理:文本消息在传入前建议做长度截断,部分接口对单条消息内容有字符数上限,超出会被截断甚至返回错误。可以在 send_text 里加一行 content = content[:4096] 作为保险,实际上限以官方文档为准。此外,如果消息内容来自用户输入,注意过滤掉换行符和特殊控制字符,避免在日志系统里产生解析异常。


六、联系人与群组模块

python# wechat_sdk/contact.py

from .client import WechatClient


class ContactMixin:
    """
    联系人接口 Mixin。
    字段名以官方文档为准,以下为示例。
    """

    _client: WechatClient

    def search_contact(self, keyword: str) -> dict:
        """搜索联系人(微信号/手机号)"""
        body = {"appId": self._client.app_id, "contactsInfo": keyword}
        return self._client._post("/contacts/search", body)

    def add_contact(self, wxid: str, remark: str = "") -> dict:
        """
        添加好友。
        注意:建议每 2h 内添加不超过 5 人,单日累计 5–15 个,
        新账号建议在线 3 天后再操作,随机间隔避免行为异常。
        """
        body = {
            "appId": self._client.app_id,
            "wxId":  wxid,
            "remark": remark,
        }
        return self._client._post("/contacts/addContacts", body)

    def get_contacts_list(self) -> dict:
        """获取全部联系人列表"""
        body = {"appId": self._client.app_id}
        return self._client._post("/contacts/fetchContactsList", body)

    def get_contact_detail(self, wxid: str) -> dict:
        """获取单个联系人详情"""
        body = {"appId": self._client.app_id, "targetWxid": wxid}
        return self._client._post("/contacts/getDetailInfo", body)
python# wechat_sdk/group.py

from typing import List
from .client import WechatClient


class GroupMixin:
    """
    群组接口 Mixin。
    字段名以官方文档为准,以下为示例。
    """

    _client: WechatClient

    def create_group(self, member_wxids: List[str]) -> dict:
        """
        创建群聊。
        建议每天建群不超过 10 个,间隔 10 分钟以上。
        """
        body = {
            "appId":         self._client.app_id,
            "memberWxIdList": member_wxids,
        }
        return self._client._post("/group/createChatroom", body)

    def invite_members(self, chatroom_id: str, wxids: List[str]) -> dict:
        """邀请成员加入群"""
        body = {
            "appId":         self._client.app_id,
            "chatroomId":    chatroom_id,
            "memberWxIdList": wxids,
        }
        return self._client._post("/group/inviteMember", body)

    def remove_member(self, chatroom_id: str, wxid: str) -> dict:
        """移除群成员"""
        body = {
            "appId":      self._client.app_id,
            "chatroomId": chatroom_id,
            "memberWxId": wxid,
        }
        return self._client._post("/group/removeMember", body)

    def set_announcement(self, chatroom_id: str, content: str) -> dict:
        """设置群公告"""
        body = {
            "appId":      self._client.app_id,
            "chatroomId": chatroom_id,
            "content":    content,
        }
        return self._client._post("/group/setChatroomAnnouncement", body)

    def get_member_list(self, chatroom_id: str) -> dict:
        """获取群成员列表"""
        body = {"appId": self._client.app_id, "chatroomId": chatroom_id}
        return self._client._post("/group/getChatroomMemberList", body)

实操细节get_contacts_list 返回的联系人列表在好友数量较多时数据量较大,建议在本地缓存并设置过期时间(如 30 分钟),避免频繁调用。群操作(邀请、移除)对账号行为检测较为敏感,批量操作时一定要加随机间隔,间隔建议在 5–15 秒之间随机浮动,而不是固定值。


七、组合成统一入口

用多重继承把 Mixin 组合进一个 WechatBot 类,对调用方暴露单一入口。

python# wechat_sdk/__init__.py

from .client   import WechatClient
from .message  import MessageMixin
from .contact  import ContactMixin
from .group    import GroupMixin
from .exceptions import (
    WechatSDKError,
    ApiError,
    AuthError,
    NetworkError,
    RetryExhaustedError,
)


class WechatBot(MessageMixin, ContactMixin, GroupMixin):
    """
    统一入口。示例:
        bot = WechatBot(base_url=..., token=..., app_id=...)
        bot.send_text("wxid_xxx", "Hello")
    """

    def __init__(self, base_url: str, token: str, app_id: str, **kwargs):
        self._client = WechatClient(
            base_url=base_url,
            token=token,
            app_id=app_id,
            **kwargs,
        )


__all__ = [
    "WechatBot",
    "WechatClient",
    "WechatSDKError",
    "ApiError",
    "AuthError",
    "NetworkError",
    "RetryExhaustedError",
]

八、调用示例与异常处理最佳实践

python# demo.py — 具体接口/字段以官方文档为准

from wechat_sdk import WechatBot, ApiError, RetryExhaustedError, AuthError

bot = WechatBot(
    base_url = "https://你的接口域名",   # 注册后在官方文档获取
    token    = "你的Token",
    app_id   = "你的appId",
    max_retries = 3,
    timeout     = 10,
)

TARGET = "wxid_目标好友"

# ── 发文本消息 ──────────────────────────────────
try:
    data = bot.send_text(TARGET, "你好,这是 SDK 测试消息")
    print("发送成功:", data)

except AuthError:
    print("Token 无效,请检查配置")

except ApiError as e:
    # e.ret 和 e.msg 方便记录到监控系统
    print(f"接口返回错误 ret={e.ret}: {e.msg}")
    if e.ret == 429:
        print("触发频率限制,稍后重试")

except RetryExhaustedError as e:
    print(f"网络不稳定,重试耗尽: {e}")
    # 此处可接入告警(钉钉/飞书/邮件)

# ── 批量发消息(带节流) ───────────────────────
import time

wxid_list = ["wxid_a", "wxid_b", "wxid_c"]
for wxid in wxid_list:
    try:
        bot.send_text(wxid, "批量通知内容")
    except ApiError as e:
        print(f"发送 {wxid} 失败: {e}")
    time.sleep(2)   # 每条间隔,避免触发频控

注意事项

关于日志规范:SDK 内部使用 logging.getLogger(__name__) 而非 print,这样调用方可以通过 Python 日志系统统一控制输出级别和格式。生产环境建议把日志接入 ELK 或云日志服务,并为每条重试日志附上唯一的 trace_id,方便排查某次请求在哪一步出了问题。trace_id 可以在 _post 方法入口生成(uuid.uuid4().hex[:8]),随重试轮次一起写入日志。


九、接收回调消息

微信消息推送采用 Webhook 回调模式:平台将用户消息 POST 到你通过 setCallback 注册的公网地址,字段以官方文档为准,典型格式示例如下:

json{
  "appId":      "你的appId",
  "fromWxid":   "wxid_发送方",
  "toWxid":     "wxid_接收方",
  "type":       1,
  "content":    "消息内容",
  "msgId":      "唯一消息ID",
  "createTime": 1718000000
}

用 Flask 快速搭建接收端:

python# callback_server.py

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/wechat/callback", methods=["POST"])
def wechat_callback():
    data = request.json or {}
    msg_type = data.get("type")
    content  = data.get("content", "")
    from_id  = data.get("fromWxid", "")

    # 异步处理,当前请求快速返回 200
    print(f"收到消息 type={msg_type} from={from_id}: {content}")

    # TODO: 丢入消息队列(Celery/RQ)异步处理
    # 不要在此同步下载图片/文件,会造成超时

    return jsonify({"status": "ok"}), 200

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

回调收不到消息时,先检查三点:公网 IP/域名是否可达、是否返回了 HTTP 200、微信账号是否保持在线。

实操细节:回调接口必须在 3 秒内返回 200,否则平台会认为投递失败并重试。重试会带来重复消息,所以业务逻辑里要用 msgId 做幂等去重,把已处理的 msgId 存入 Redis 或数据库,收到重复消息直接返回 200 不做处理。同时,Flask 单线程开发服务器不适合生产,建议套 Gunicorn + 多 worker,或者用 FastAPI 异步框架提升吞吐。

回调签名验证:如果接口提供了签名字段(如 signtimestamp + nonce 组合),务必在回调处理函数最开头做签名校验,验证失败立即返回 403 并记录来源 IP。跳过签名验证意味着任何人都可以伪造消息向你的业务系统注入数据,这是一个容易被忽视但影响严重的安全漏洞。签名算法以官方文档为准,通常是 HMAC-SHA256 或 MD5。


十、接口托管方案选型参考

自建接口层需要维护协议适配、账号会话保活等底层工作,成本不低。目前有一些 REST API 服务提供标准 HTTP 接口:WechatApi 提供扫码登录、消息收发、好友与群管理等 REST 接口,HTTP 调用即可,配合本文的 SDK 层直接对接。不管选哪种方案,SDK 封装层的价值不变——换底层接口只需改 BASE_URL,上层业务代码零改动。


总结

从异常体系、核心 HTTP 客户端、业务 Mixin 到统一入口,本文完整走了一遍 Python 微信 API SDK 的封装路径。重试退避、Session 复用、异常分层这三点是让 SDK 在生产环境稳定运行的关键;Mixin 组合模式则保证了后续扩展新接口时不需要动已有代码。

几个容易踩坑的地方再强调一遍:Token 和 appId 不要硬编码、写操作接口慎用重试、批量操作务必加随机间隔、回调接口要做幂等去重和签名校验、生产环境不要用 Flask 开发服务器、日志要带 trace_id 方便定位问题。以上代码为示例结构,具体接口路径、请求字段和返回格式以官方文档为准。

封装好的 SDK 层带来的最大收益不是少写几行代码,而是把所有"接口对接的不确定性"收拢在一处:日后接口升级、鉴权方式变更、域名迁移,改动范围都锁定在 client.py 的几十行代码里,不会扩散到业务逻辑各处。这才是 SDK 封装真正的工程价值。

想动手试试?

WechatApi 提供扫码登录、消息收发、好友与群管理等 REST 接口,注册后几分钟跑通。

立即免费注册查看开发文档

相关产品页

🔗 微信群管理机器人(产品页)🔗 微信Hook(产品页)🔗 微信SDK(产品页)

相关文章

wechaty 维护放缓、itchat 失效后,个人微信机器人怎么做gewechat 微信开发框架快速上手教程微信加好友失败、对方收不到验证?原因与解决清单微信发朋友圈别人看不到?原因排查与解决
© 2025 WechatApi · 企业级微信智能机器人接入平台
官网价格帮助文档博客
苏ICP备2024128799号 · 苏ICP备2023038368号