Browse Source

fix(http): 错误响应缺失 data 字段时透传后端 message

- 后端错误响应是 {code,message,requestId,timestamp},无 data 字段;
  旧代码用 isEnvelope(要求 data)判断错误体恒为 false,后端真实 message
  (如 sms rate limited / unsupported sms purpose)被 pickHttpMessage(400)
  覆盖成笼统的「请求参数有误」,用户看不到验证码失败的真实原因
- 新增 extractError 从错误体提取 code/message,用于 !ok 与 401 分支
- 补回归测试:验证码限流(400/3005)应透传 sms rate limited
master
doublex001 4 weeks ago
parent
commit
20a741d54d
  1. 15
      src/__tests__/http.test.ts
  2. 28
      src/api/http.ts

15
src/__tests__/http.test.ts

@ -132,6 +132,21 @@ describe('信封 code 校验(规范 §3)', () => {
}
});
it('HTTP 非 2xx 且错误体没有 data 字段时,仍透传后端 message(回归:验证码限流曾显示「请求参数有误」)', async () => {
// 真实后端错误体是 {code, message, requestId, timestamp},不含 data 字段;
// 旧实现用 isEnvelope(要求 data)判断,恒为 false,把后端 message 覆盖成「请求参数有误」
responder = () => ({ status: 400, body: { code: 3005, message: 'sms rate limited', requestId: 'rid', timestamp: 't' } });
try {
await request('/auth/sms/send', { method: 'POST', auth: false, query: { phone: '19900009021', purpose: 'login' } });
throw new Error('不应成功');
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).httpStatus).toBe(400);
expect((e as ApiError).code).toBe(3005);
expect((e as ApiError).message).toBe('sms rate limited');
}
});
it('HTTP 非 2xx 且信封无 message 时,回退到状态码映射文案(§8)', async () => {
responder = () => ({ status: 404, body: '' });
try {

28
src/api/http.ts

@ -143,6 +143,24 @@ function isEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
);
}
/**
* 从错误响应里提取业务 code 与 message。
*
* 后端**错误**响应是 `{code, message, requestId, timestamp}`,**没有 `data` 字段**;
* 而 `isEnvelope` 要求 `data` 存在(那是给「成功响应解包」用的),
* 若拿它判断错误体,会恒为 false,导致后端真实 message 被 `pickHttpMessage` 覆盖成
* 「请求参数有误」之类的笼统文案。故错误分支单独用本函数取值。
*/
function extractError(parsed: unknown): { code: number | null; message: string | null } {
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>;
const code = typeof obj.code === 'number' ? obj.code : null;
const message = typeof obj.message === 'string' && obj.message ? obj.message : null;
return { code, message };
}
return { code: null, message: null };
}
interface InternalOptions extends RequestOptions {
/** 标记这是刷新请求本身,避免 401 时递归刷新 */
_isRefresh?: boolean;
@ -247,7 +265,7 @@ async function rawRequest<T>(path: string, opts: InternalOptions): Promise<T> {
}
throw new ApiError({
httpStatus: 401,
code: isEnvelope<unknown>(parsed) ? parsed.code : 401,
code: extractError(parsed).code ?? 401,
message: '登录已失效,请重新登录',
requestId: resRequestId,
});
@ -255,13 +273,11 @@ async function rawRequest<T>(path: string, opts: InternalOptions): Promise<T> {
// HTTP 状态码优先(§8)
if (!response.ok) {
const msg = isEnvelope<unknown>(parsed)
? parsed.message
: pickHttpMessage(response.status);
const err = extractError(parsed);
throw new ApiError({
httpStatus: response.status,
code: isEnvelope<unknown>(parsed) ? parsed.code : response.status,
message: msg,
code: err.code ?? response.status,
message: err.message ?? pickHttpMessage(response.status),
requestId: resRequestId,
});
}

Loading…
Cancel
Save