微信小程序 / H5 调用第三方 API 的正确姿势:把直连改成三段式调用链
正确做法不是在页面里直接请求第三方接口,而是拆成三段:不可信的前端、持有密钥的服务端代理、以及上游 API。小程序受平台合法域名约束,H5 受同源策略约束,两者都因为代理而简化;真正决定可用性的,是显式超时、只对幂等请求重试、把错误分类后再决定用户可见状态,并且不把上游原始报错与密钥一起送到客户端。
- 前端直连会暴露密钥;可撤销的密钥只应存在于服务端环境变量中。
- 小程序合法域名与 H5 CORS 是两条不同的限制,用自有域名做代理可以同时绕开它们的细节。
- 重试前先确认请求幂等;对非幂等请求重试是重复扣费与重复写入的常见来源。
- 错误要分类后再返回前端,上游原始报错与 request id 的公开范围需要单独决定。
把问题问得更具体一点:微信小程序和 H5 调用第三方 API 时,代码应该写在哪一侧、密钥放在哪、失败时返回什么?简短回答是:不要在页面里直接请求上游接口。比较稳妥的结构是三段式——前端只调用自有域名下的一个接口,由服务端持有密钥并访问上游,前端只消费归一化后的结果。下面按这三个位置展开,涉及平台规则的部分以官方文档为准(本次未核验具体条款)。
一、先决定谁持有密钥#
密钥暴露与否,取决于请求从哪台机器发出,而不是取决于代码写得多隐蔽。小程序包可以被解包,H5 的 JS 可以被直接阅读,写在里面的密钥等同于公开。
| 调用位置 | 密钥是否到达用户设备 | 说明 |
|---|---|---|
| 小程序 / H5 代码直连上游 | 会 | 只适合完全公开、无配额的端点;有配额的接口不建议 |
| 自有服务端代理 | 否 | 密钥放环境变量,客户端只认自己的域名 |
| 云函数 / 边缘函数 | 否,前提是密钥由平台环境变量注入 | 要额外检查日志、冷启动与出网限制 |
使用 API.PLUS 这类第三方 API 服务时,这一点同样成立:密钥依然只能放在服务端,具体接入方式、可用端点与计费口径以官方文档为准(待核实)。
二、域名与跨域是两条不同的限制#
- 小程序:网络请求受平台侧合法域名配置约束,通常要求 HTTPS 且不能是裸 IP 或 localhost,具体字段与数量限制请对照微信官方文档。
- H5:受浏览器同源策略约束。跨域请求会触发预检(OPTIONS),服务端需要返回允许的来源、方法与头部,否则浏览器在拿到响应前就拦掉。
两者的共同解法是同一个:把上游域名藏到服务端,前端只面向自己的域名。这样小程序端只需要配置一个域名,H5 端也就不存在跨域问题。
三、超时、重试与幂等#
客户端库的默认超时往往偏长或不存在,必须显式设置。重试则要分情况:
- GET 等幂等请求可以有限重试,建议指数退避加随机抖动,避免同一时刻集体重试。
- POST 只有在请求自带幂等键、且上游确实按幂等键去重时才可重试;否则重试会变成重复下单、重复扣费。
- 4xx 通常不该重试;429 与 5xx 可以,但要设上限与总时长。
重试放在服务端比放在前端更可控:前端一个页面可能在用户切后台后被挂起,重试节奏就断了。
四、失败路径:先分类,再决定给用户看什么#
把错误至少分成四类:网络不可达、上游超时、上游返回错误、请求本身不合法。这四类对用户的含义不同,可恢复性也不同。工程上要遵守两点:
- 不要把上游原始报错原样透传给客户端,其中可能含内部端点、账号信息或密钥片段。
- 记录一个 request id 并回传,方便用户报障时定位;但公开范围要单独评估。
日志同样需要脱敏:请求头里的 authorization、查询串里的 key 参数都不应落盘。
五、一个最小代理示例#
下面代码未执行,仅为结构演示,域名与密钥都是占位(example.com、YOUR_KEY)。环境假设为 Node.js 18+ 与 Express 4.x。
// 未执行的示例:服务端代理,密钥从环境变量注入
import express from "express";
const app = express();
app.use(express.json()); // 否则 req.body 为 undefined
const UPSTREAM = "https://api.example.com/v1/endpoint"; // 占位域名
const KEY = process.env.UPSTREAM_KEY; // 不要把 YOUR_KEY 写进代码
app.post("/api/proxy", async (req, res) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000); // 显式超时
try {
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${KEY}`,
},
body: JSON.stringify(req.body),
signal: controller.signal,
});
const requestId = upstream.headers.get("x-request-id") ?? "unknown";
res.setHeader("x-request-id", requestId);
if (!upstream.ok) {
// 不透传上游原始报错
return res.status(upstream.status).json({ error: "upstream_failed" });
}
res.json(await upstream.json());
} catch (err) {
const timedOut = err.name === "AbortError";
res.status(timedOut ? 504 : 502).json({
error: timedOut ? "upstream_timeout" : "upstream_unreachable",
});
} finally {
clearTimeout(timer);
}
});
app.listen(3000);
注意这段示例没有做鉴权、限流与请求体大小限制。对外可用之前,这三项都需要补上,否则代理会变成任何人都能使用的免费额度出口。
六、边界与仍需核对的事项#
- 小程序的合法域名规则、并发与超时上限,以微信官方文档为准,本文未逐条核验。
- 上游 API 的速率限制、计费单位与是否支持幂等键,需查对应服务的文档;不同服务差异很大,不能按通则推断。
- 若涉及流式响应,代理层需要正确转发分块数据并处理中途断开,本文示例不涵盖这种情况。
总结成一句可执行的判断:密钥在哪台机器上、超时是否显式设置、非幂等请求会不会被重试、上游报错会不会漏到用户端——这四项定下来,调用姿势基本就正确了。