第三方API对接的通用封装模式

简介: 本文分享一套生产级Python第三方API封装实践:通过配置层、基类客户端、业务子类三层解耦,统一处理超时、重试、签名、异常分类(网络/鉴权/业务)与日志脱敏,让新渠道接入缩短至半小时,彻底告别裸奔式`requests.post`。

为什么写这篇

团队里对接的第三方服务越来越多:短信、推送、支付、地图、OCR……每接一个,代码里就多一坨 requests.post(...)。超时没设、异常裸奔、签名逻辑复制粘贴,出了问题全靠 grep 日志。上个月排查一个"短信偶发发不出去"的问题,翻了半天才发现是某个角落里的调用没设超时,worker 被拖死后消息静默丢了。

实在受不了了,把这几年对接第三方 API 的封装套路整理了一遍,沉淀成一个基类。现在新接一个渠道,写个子类描述清楚接口参数,半小时就能上线。这篇把整套模式分享出来,代码都是生产环境在跑的版本(脱敏过)。

裸奔的代码长什么样

先看不封装时的典型写法,应该很眼熟:

import requests

def send_sms(phone, content):
    resp = requests.post(
        "https://api.example.com/v1/sms/send",
        json={
   "phone": phone, "content": content, "app_key": "ak_123"},
    )
    data = resp.json()
    if data["code"] == 0:
        return data["data"]["msg_id"]
    return None

问题一抓一大把:

  • 没设超时。对方服务挂了你这边的 worker 就跟着吊死,还是挂一片的那种
  • 网络抖动直接抛 ConnectionError,调用方根本分不清"要不要重试"
  • 业务错误码被静默吞掉,返回 None,上游不知道是没发送还是发送失败
  • 密钥硬编码,换个环境改代码,迟早把生产密钥提交进仓库
  • 如果对方要签名,签名逻辑散在每个函数里,改签名算法要改七八处

同一个套路复制五六份之后,任何一次统一改造(比如加灰度 header、换网关)都是体力活。

封装思路:三层切分

不绕弯子,直接说结构:

  1. 配置层:base_url、密钥、超时、重试次数,全部收进一个配置对象,从环境变量读
  2. 客户端层(基类):管 Session、连接池、签名、重试、异常转换、日志,不碰具体业务
  3. 业务层(子类):每个第三方平台一个子类,只描述"这个平台有哪些接口、参数长什么样"

调用方最后只面对三件事:调用方法、处理三种异常。下面逐块给代码,最后有完整版。

第一步:配置和异常体系

配置用 dataclass 就够了,别一上来就引入配置框架:

from dataclasses import dataclass

@dataclass
class APIConfig:
    base_url: str                    # 接口地址,如 https://api.example.com
    app_key: str = ""
    app_secret: str = ""
    connect_timeout: float = 3.0     # 连接超时:对方网络有问题时快速失败
    read_timeout: float = 10.0       # 读取超时:对方处理慢时别让 worker 干等
    max_retries: int = 2             # 网络层失败的最大重试次数(不含首次)
    retry_backoff: float = 0.5       # 退避基数(秒),第 n 次重试前等 backoff * 2^n
    verify_ssl: bool = True

异常体系是整个封装的灵魂。核心诉求是:让调用方能根据异常类型做决定,而不是去解析字符串。三种就够:

class APIError(Exception):
    """所有第三方调用异常的基类,上层只 catch 这一个也够用"""


class APINetworkError(APIError):
    """网络层失败:连不上、超时、DNS 解析失败等。已经重试过且耗尽了,可以走兜底"""


class APIResponseError(APIError):
    """对方给了明确答复,但结果不符合预期(HTTP 非 200 / 业务码非成功)"""

    def __init__(self, message, *, status_code=0, biz_code=None, payload=None):
        super().__init__(message)
        self.status_code = status_code   # HTTP 状态码
        self.biz_code = biz_code         # 对方业务错误码
        self.payload = payload           # 原始响应,排查问题时救命用


class APIAuthError(APIResponseError):
    """鉴权失败。一般是密钥配错了,重试一百次也没用,单独拎出来"""

payload 字段是我踩坑后加的。以前只记 message,对方返回格式一变就两眼一抹黑;现在把原始响应留着,排查效率完全不是一个量级。

第二步:基类,把脏活全收进来

核心是一个 BaseAPIClient。几个设计决定先说清楚:

  • 重试自己写循环,不用 urllib3 的 Retry。原因:Retry 管不到业务错误码,也没法区分"这个 POST 不是幂等的,不能重试"。自己写几十行,控制力完全在自己手里
  • 默认只对幂等方法重试(GET/PUT/DELETE),POST 创建类操作默认不重试,除非调用方显式声明幂等
  • 业务码错误不重试。对方已经明确告诉你"余额不足"了,重试只会再打一遍同样的脸
import hashlib
import hmac
import json
import logging
import time
import uuid
from typing import Any, Optional

import requests
from requests import Response, Session
from requests.adapters import HTTPAdapter


class BaseAPIClient:
    # 各家平台"业务成功"的标志不一样,按需覆盖
    SUCCESS_CODES = {
   0, "0", "success", "SUCCESS", "ok", "OK"}
    # 业务码里的鉴权失败,按对接文档维护
    AUTH_BIZ_CODES = {
   "SIGN_ERROR", "INVALID_TOKEN", "10001", 10001}

    def __init__(self, config: APIConfig, logger: Optional[logging.Logger] = None):
        self.config = config
        self.log = logger or logging.getLogger(self.__class__.__name__)
        self.session = self._build_session()

    def _build_session(self) -> Session:
        s = requests.Session()
        # 默认连接池只有 10,并发高的场景务必调大
        adapter = HTTPAdapter(pool_connections=10, pool_maxsize=20)
        s.mount("http://", adapter)
        s.mount("https://", adapter)
        return s

签名这块,各家规则千差万别(排序方式、拼接符、是否包含 body、HMAC 还是 RSA),但骨架都差不多。给一个最常见的 HMAC-SHA256 版本,子类按需覆盖:

    def _sign(self, params: dict, body: Optional[dict],
              timestamp: str, nonce: str) -> str:
        raw = {
   
            "app_key": self.config.app_key,
            "timestamp": timestamp,
            "nonce": nonce,
            **(params or {
   }),
            **(body or {
   }),
        }
        # 按 key 排序拼成 k1=v1&k2=v2,再用 secret 做 HMAC
        canonical = "&".join(f"{k}={raw[k]}" for k in sorted(raw))
        return hmac.new(
            self.config.app_secret.encode(),
            canonical.encode(),
            hashlib.sha256,
        ).hexdigest()

    def _auth_headers(self, params: dict, body: Optional[dict]) -> dict:
        ts = str(int(time.time()))
        nonce = uuid.uuid4().hex[:16]
        return {
   
            "X-App-Key": self.config.app_key,
            "X-Timestamp": ts,
            "X-Nonce": nonce,
            "X-Sign": self._sign(params, body, ts, nonce),
        }

然后是整个基类的心脏,统一的请求入口:

    def _request(self, method: str, path: str, *,
                 params: dict = None, body: dict = None,
                 need_auth: bool = True,
                 idempotent: bool = None) -> Any:
        url = self.config.base_url.rstrip("/") + "/" + path.lstrip("/")
        headers = self._auth_headers(params, body) if need_auth else {
   }

        # 默认幂等方法才可重试;调用方可显式覆盖
        if idempotent is None:
            idempotent = method.upper() in ("GET", "HEAD", "PUT", "DELETE")
        attempts = self.config.max_retries + 1 if idempotent else 1

        last_err: Optional[Exception] = None
        for i in range(attempts):
            try:
                resp = self.session.request(
                    method, url,
                    params=params,
                    json=body,
                    headers=headers,
                    timeout=(self.config.connect_timeout,
                             self.config.read_timeout),
                    verify=self.config.verify_ssl,
                )
                return self._parse(resp)
            except APIResponseError:
                # 对方已给出明确答复,重试结果一样,直接抛
                raise
            except requests.RequestException as e:
                # 网络层错误:按指数退避重试
                last_err = e
                if i < attempts - 1:
                    wait = self.config.retry_backoff * (2 ** i)
                    self.log.warning(
                        "request failed, retry %d/%d after %.1fs: %s %s, err=%s",
                        i + 1, attempts - 1, wait, method, url, e)
                    time.sleep(wait)

        raise APINetworkError(
            f"request to {url} failed after {attempts} attempts") from last_err

统一的响应解析。绝大多数平台都是 {"code":..., "message":..., "data":...} 结构,基类按这个写;遇到返回 XML 或者结构奇葩的,子类覆盖 _parse 一个方法就行:

    def _parse(self, resp: Response) -> Any:
        if resp.status_code in (401, 403):
            raise APIAuthError("auth failed",
                               status_code=resp.status_code,
                               payload=resp.text[:500])
        if resp.status_code != 200:
            raise APIResponseError(
                f"unexpected http status: {resp.status_code}",
                status_code=resp.status_code, payload=resp.text[:500])

        try:
            data = resp.json()
        except json.JSONDecodeError:
            raise APIResponseError("response is not valid json",
                                   status_code=resp.status_code,
                                   payload=resp.text[:500])

        code = data.get("code")
        if code in self.SUCCESS_CODES:
            return data.get("data")
        if code in self.AUTH_BIZ_CODES:
            raise APIAuthError(str(data.get("message")),
                               biz_code=code, payload=data)
        raise APIResponseError(str(data.get("message")),
                               status_code=200, biz_code=code, payload=data)

最后补上便捷方法和资源释放:

    def get(self, path, params=None, **kw):
        return self._request("GET", path, params=params, **kw)

    def post(self, path, body=None, idempotent=False, **kw):
        return self._request("POST", path, body=body,
                             idempotent=idempotent, **kw)

    def close(self):
        self.session.close()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

第三步:业务子类,只剩"描述接口"

基类就位之后,接一个新平台就只剩一件事:把对方的接口文档翻译成一个类。

import os

class SmsClient(BaseAPIClient):
    """某短信平台(示例)"""

    def send(self, phone: str, template_id: str, params: dict) -> str:
        # 发送不是幂等操作,默认不重试;对方支持 msg_no 去重的话可以传 idempotent=True
        data = self.post("/v1/messages", body={
   
            "phone": phone,
            "template_id": template_id,
            "params": params,
        })
        return data["msg_id"]

    def query_status(self, msg_id: str) -> str:
        data = self.get(f"/v1/messages/{msg_id}")
        return data["status"]

调用方长这样:

config = APIConfig(
    base_url="https://api.example-sms.com",
    app_key=os.environ["SMS_APP_KEY"],
    app_secret=os.environ["SMS_APP_SECRET"],
    read_timeout=5,
)

with SmsClient(config) as sms:
    try:
        msg_id = sms.send("138****1234", "LOGIN_CODE", {
   "code": "123456"})
    except APIAuthError:
        ...  # 密钥/签名问题,别重试,直接告警查配置
    except APINetworkError:
        ...  # 重试耗尽,走兜底:切备用渠道或入队稍后补发
    except APIResponseError as e:
        ...  # 对方明确拒绝,按 e.biz_code 对照错误码表做业务处理

对比一下开头那段裸奔代码:调用方只面对三种异常,每种的处理策略都是明确的。这就是这套封装值回票价的地方。

测试怎么写

不引入额外的 mock 库,unittest.mock 直接糊 session.request 就行:

import json
import requests
from unittest import TestCase, mock


class SmsClientTest(TestCase):

    def setUp(self):
        self.client = SmsClient(APIConfig(
            base_url="https://api.test", app_key="k", app_secret="s"))

    def _resp(self, payload, status=200):
        r = mock.Mock()
        r.status_code = status
        r.text = json.dumps(payload)
        r.json.return_value = payload
        return r

    def test_send_success(self):
        ok = self._resp({
   "code": 0, "message": "ok", "data": {
   "msg_id": "m1"}})
        with mock.patch.object(self.client.session, "request", return_value=ok):
            self.assertEqual(
                self.client.send("13800001234", "T1", {
   }), "m1")

    def test_biz_error_no_retry(self):
        # 业务错误:不重试,直接抛
        err = self._resp({
   "code": 20001, "message": "balance not enough"})
        with mock.patch.object(self.client.session, "request",
                               return_value=err) as m:
            with self.assertRaises(APIResponseError) as ctx:
                self.client.query_status("m1")
            self.assertEqual(ctx.exception.biz_code, 20001)
            self.assertEqual(m.call_count, 1)

    def test_retry_on_timeout_then_success(self):
        # 前两次超时,第三次成功:GET 幂等,应当重试
        ok = self._resp({
   "code": 0, "message": "ok", "data": {
   "status": "done"}})
        side = [requests.ConnectTimeout(), requests.ConnectTimeout(), ok]
        with mock.patch.object(self.client.session, "request",
                               side_effect=side), \
             mock.patch("time.sleep"):  # 测试里别真睡
            self.assertEqual(self.client.query_status("m1"), "done")

    def test_post_not_retried(self):
        # POST 默认不幂等:超时应直接失败,不重复发
        with mock.patch.object(self.client.session, "request",
                               side_effect=requests.ConnectTimeout()) as m:
            with self.assertRaises(APINetworkError):
                self.client.send("13800001234", "T1", {
   })
            self.assertEqual(m.call_count, 1)

重点测四个行为:成功路径、业务错误不重试、幂等接口网络错误会重试、非幂等接口不重试。这四个守住了,基类的行为就稳了。

一些零碎但重要的经验

最后几条都是拿钱买来的教训:

1. 日志必须脱敏。 手机号中间四位打码,app_secret 和签名原文永远不进日志。出过一次事故,日志平台里能搜到完整手机号,整改了一个星期。

2. 连接超时和读超时分开设,别一刀切。 连接超时 3 秒足够,连不上就是连不上;读超时按接口的 SLA 来,查询类给短点,批量类给长点。

3. POST 重试的前提是对方支持幂等。 要么对方接口本身幂等(按业务单号去重),要么你自己生成幂等键传过去。两条都不满足,宁可失败报警人工介入,也不要赌"对方应该没收到"。重复扣款、重复发短信,哪个都够喝一壶。

4. 维护一张错误码映射表。 对接文档里的错误码,挑出现频率高的(余额不足、频率超限、参数错误)单独映射成自定义异常或枚举,剩下的走通用 APIResponseError。别试图全覆盖,几百个错误码你维护不动。

5. 别开 verify=False 内网自签名证书就把 verify 指向你们的 CA 文件,关掉校验等于把 HTTPS 的裤子脱了。requests 对关闭校验的每次调用都会打 warning,看到那行字就该心虚。

6. 高并发场景记得调连接池。 Session 默认 pool_maxsize=10,worker 并发一高,连接建了又拆,TIME_WAIT 堆成山。按实际并发量调 HTTPAdapter

结尾

这套模式没有引入任何框架,核心就是一个基类加三种异常,总共两百来行。它的价值不在代码本身,而在于把"第三方调用"这件事的不确定性收敛到了一个地方:网络会抖、对方会挂、错误码千奇百怪,但你的业务代码面对的世界是干净的。

新接一个渠道,写个子类;对方改签名算法,改一个方法;要加统一的灰度 header,改一个地方。这大概就是"封装"两个字最朴素的意义。

目录
相关文章
|
7天前
|
人工智能 JSON 安全
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
阿里云AI安全产品联动防御Fastjson攻击
2043 11
Fastjson远程代码执行漏洞,阿里云AI安全为您保驾护航
|
7天前
|
云安全 人工智能 安全
|
7天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max-Preview深度全解析:2.4万亿参数旗舰MoE模型+Token Plan限时优惠完整落地指南
2026年7月,全新旗舰级混合专家大模型Qwen3.8-Max-Preview正式开放抢先体验,作为通义千问Qwen3系列规格最高、综合推理能力顶尖的新一代模型,该模型总参数量达到2.4万亿(2.4T),是当前线上可调用的原生多模态旗舰模型,综合推理水准对标海外顶级Fable 5模型,在复杂工程开发、长文档深度分析、多步骤智能体自治、跨境多语言创作、海量数据挖掘五大高难度业务场景实现跨越式性能提升。
903 1
|
7天前
|
人工智能 自然语言处理 数据挖掘
最新版通义千问(Qwen3.8-Max-Preview)功能介绍
2026年,通义千问正式推出全新旗舰级大模型 **Qwen3.8-Max-Preview 预览版**,作为首款突破万亿参数规格的新一代基座模型,该模型总参数量达到**2.4万亿**,采用全新迭代的MoE混合专家架构,综合推理性能、长文本处理、多模态理解、复杂任务规划能力全面超越前代Qwen3.7-Max版本,整体实力跻身全球第一梯队,可对标海外顶级旗舰模型,是当前面向复杂工程开发、多智能体协同、超长文档解析、专业办公自动化场景的最优国产基座模型。
911 0
|
9天前
|
人工智能
Qwen3.8抢先体验!正式版即将发布并开源!
千问Qwen3.8即将开源,参数达2.4T,进化速度以“天”计,实力媲美Fable 5。预览版Qwen3.8-Max已上线阿里Token Plan等平台,限时优惠:日间Credits低至1折,夜间更优,个人/团队版月付仅35元起!
912 39
|
5天前
|
自然语言处理 测试技术 API
通义千问Qwen3.8-Max-Preview全功能解析:2.4万亿参数旗舰模型深度使用指南
在大模型技术持续迭代的当下,通义千问推出的Qwen3.8-Max-Preview作为新一代旗舰预览版模型,凭借2.4万亿参数的超大规模、多模态融合能力与全场景适配特性,成为开发者与企业用户探索AI应用的核心工具。该模型采用稀疏混合专家(MoE)架构,是通义千问首个突破万亿参数的多模态模型,可同时处理文本、图像、视频与文档等多种数据形态,在全栈代码开发、复杂逻辑推理、长文档分析与多智能体协作等场景实现跨越式升级。本文将全面拆解Qwen3.8-Max-Preview的核心功能,详解API调用流程与配置方法,覆盖多场景实战技巧,帮助用户快速掌握这款旗舰模型的使用方法,充分释放其性能潜力。
444 1
|
8天前
|
人工智能 自然语言处理 数据挖掘
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南
Qwen3.8-Max-Preview是通义千问Qwen3系列旗舰MoE大模型,参数达2.4万亿,综合推理能力居行业第一梯队。支持思考/快速双模式,擅长大模型五大高难场景。现于阿里云百炼Token Plan、Qoder及QoderWork上线体验,个人版低至39元/月。在阿里云百炼官网:https://t.aliyun.com/U/fPVHqY 免费领取千万Tokens
669 1
Qwen3.8-Max 预览版全解析:2.4 万亿参数旗舰模型,Token Plan 限时优惠指南