@autional/shared 0.1.0-rc.36 → 0.1.0-rc.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -661
- package/package.json +1 -1
- package/src/auth/auth-queries.ts +3 -6
- package/src/components/TenantIndexGuard.tsx +4 -4
- package/src/components/__tests__/TenantIndexGuard.test.tsx +4 -3
- package/src/generated/error-semantics.ts +852 -0
- package/src/index.ts +17 -0
- package/src/utils/__tests__/error-semantics.test.ts +56 -0
- package/src/utils/error-semantics.ts +91 -0
package/src/index.ts
CHANGED
|
@@ -245,3 +245,20 @@ export type { TransmissionResult } from './utils/password-transmission';
|
|
|
245
245
|
export type { ExtractedApiError } from './utils/error';
|
|
246
246
|
export type { PaginationInfo, ListResult } from './utils/response';
|
|
247
247
|
// force rebuild 1781616516
|
|
248
|
+
|
|
249
|
+
export {
|
|
250
|
+
EMPTY_STATE_CODES,
|
|
251
|
+
ERROR_SEMANTICS,
|
|
252
|
+
ERROR_SEMANTICS_META,
|
|
253
|
+
MAX_RETRIES,
|
|
254
|
+
UNPROVISIONED_CODES,
|
|
255
|
+
errorCodeOf,
|
|
256
|
+
isEmptyStateError,
|
|
257
|
+
isNotFoundError,
|
|
258
|
+
isRetryableErrorContractFirst,
|
|
259
|
+
isUnprovisionedError,
|
|
260
|
+
semanticsOfCode,
|
|
261
|
+
semanticsOfError,
|
|
262
|
+
shouldRetry,
|
|
263
|
+
} from './utils/error-semantics';
|
|
264
|
+
export type { Absence, ErrorClass, ErrorSemantics, UIObservation } from './utils/error-semantics';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ERROR_SEMANTICS,
|
|
5
|
+
ERROR_SEMANTICS_META,
|
|
6
|
+
MAX_RETRIES,
|
|
7
|
+
UNPROVISIONED_CODES,
|
|
8
|
+
isNotFoundError,
|
|
9
|
+
isRetryableErrorContractFirst,
|
|
10
|
+
semanticsOfCode,
|
|
11
|
+
semanticsOfError,
|
|
12
|
+
shouldRetry,
|
|
13
|
+
} from '../error-semantics';
|
|
14
|
+
import { isRetryableError } from '../query-state';
|
|
15
|
+
|
|
16
|
+
const httpErr = (status: number, code?: number) => ({
|
|
17
|
+
response: { status, data: code === undefined ? {} : { code } },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('error-semantics:契约优先的重试与语义查询', () => {
|
|
21
|
+
it('契约覆盖 61001801(LDAP 未配置:503 但重试必败)→ 契约口径不可重试,状态口径会说可重试', () => {
|
|
22
|
+
const err = httpErr(503, 61001801);
|
|
23
|
+
expect(isRetryableError(err)).toBe(true); // 状态口径(旧行为)
|
|
24
|
+
expect(isRetryableErrorContractFirst(err)).toBe(false); // 契约口径(新行为)
|
|
25
|
+
expect(shouldRetry(0, err)).toBe(false);
|
|
26
|
+
expect(semanticsOfCode(61001801)?.uiObservation).toBe('action_required');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('未收录码 → 回落状态口径;次数上限为 MAX_RETRIES', () => {
|
|
30
|
+
const err = httpErr(503, 99999999);
|
|
31
|
+
expect(semanticsOfError(err)).toBeUndefined();
|
|
32
|
+
expect(isRetryableErrorContractFirst(err)).toBe(true);
|
|
33
|
+
expect(shouldRetry(0, err)).toBe(true);
|
|
34
|
+
expect(shouldRetry(MAX_RETRIES, err)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('404 一律不重试;契约给出 not_found/entity', () => {
|
|
38
|
+
const err = httpErr(404, 61110004);
|
|
39
|
+
expect(isRetryableErrorContractFirst(err)).toBe(false);
|
|
40
|
+
expect(isNotFoundError(err)).toBe(true);
|
|
41
|
+
expect(semanticsOfCode(61110004)?.absence).toBe('entity');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('凭据不匹配 / 人机验证 / 通用 401 三者语义可区分(R2/R3 的回归锁)', () => {
|
|
45
|
+
expect(semanticsOfCode(61000104)?.uiObservation).toBe('credential_mismatch');
|
|
46
|
+
expect(semanticsOfCode(40800505)?.uiObservation).toBe('captcha_required');
|
|
47
|
+
expect(semanticsOfCode(40800506)?.uiObservation).toBe('captcha_required');
|
|
48
|
+
expect(semanticsOfCode(40000502)?.uiObservation).toBe('session_expired'); // 通用 401 未被覆写
|
|
49
|
+
expect(semanticsOfCode(61002201)?.uiObservation).toBe('reauth_required');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('契约规模自洽 + 未开通台账保持空(目标态)', () => {
|
|
53
|
+
expect(ERROR_SEMANTICS_META.rowCount).toBe(Object.keys(ERROR_SEMANTICS).length);
|
|
54
|
+
expect(UNPROVISIONED_CODES.size).toBe(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 错误语义消费层(**客户端单一真源**)。
|
|
3
|
+
*
|
|
4
|
+
* 契约链:service-core `base/error/semantics.go`(Go,单一真源)→ errors.semantics.json
|
|
5
|
+
* → 本仓 `contracts/`(镜像,sha256 钉住)→ `scripts/gen-error-semantics.mjs` 生成
|
|
6
|
+
* `src/generated/error-semantics.ts`(码 → class/absence/ui/retryable)。
|
|
7
|
+
*
|
|
8
|
+
* 两个真源,各司其职、不重复:
|
|
9
|
+
* 1) `./query-state` = **HTTP 状态**语义(error / 空 / 无权 / 可重试);
|
|
10
|
+
* 2) `../generated/error-semantics` = **业务码**语义(码 → class / absence / ui_observation / retryable)。
|
|
11
|
+
* 本文件把二者组合成调用方可直接消费的判定。
|
|
12
|
+
*
|
|
13
|
+
* 纪律:**页面/应用不得再手写错误码字面量**(手抄 = 漂移,正是 2026-10-14 novel-demo 事故的根因)。
|
|
14
|
+
* 各站点原先各自的 `lib/error-semantics.ts` 垫片,在本包发布含本模块的版本后**删除**并改为从本包导入。
|
|
15
|
+
*/
|
|
16
|
+
import { getHttpStatus, isForbiddenError, isRetryableError } from './query-state';
|
|
17
|
+
import {
|
|
18
|
+
EMPTY_STATE_CODES,
|
|
19
|
+
ERROR_SEMANTICS,
|
|
20
|
+
ERROR_SEMANTICS_META,
|
|
21
|
+
UNPROVISIONED_CODES,
|
|
22
|
+
} from '../generated/error-semantics';
|
|
23
|
+
import type { Absence, ErrorClass, ErrorSemantics, UIObservation } from '../generated/error-semantics';
|
|
24
|
+
|
|
25
|
+
export { EMPTY_STATE_CODES, ERROR_SEMANTICS, ERROR_SEMANTICS_META, UNPROVISIONED_CODES };
|
|
26
|
+
export type { Absence, ErrorClass, ErrorSemantics, UIObservation };
|
|
27
|
+
export { getHttpStatus, isForbiddenError, isRetryableError };
|
|
28
|
+
|
|
29
|
+
/** 单次查询最多重试次数(策略参数,唯一出处)。 */
|
|
30
|
+
export const MAX_RETRIES = 1;
|
|
31
|
+
|
|
32
|
+
/** 从错误对象里取后端 Problem 契约的业务码(与 `getHttpStatus` 的形状对齐)。 */
|
|
33
|
+
export function errorCodeOf(error: unknown): number | undefined {
|
|
34
|
+
if (error === null || error === undefined || typeof error !== 'object') return undefined;
|
|
35
|
+
const e = error as { code?: unknown; data?: { code?: unknown }; response?: { data?: { code?: unknown } } };
|
|
36
|
+
for (const code of [e.response?.data?.code, e.data?.code, e.code]) {
|
|
37
|
+
if (typeof code === 'number' && Number.isInteger(code) && code > 0) return code;
|
|
38
|
+
if (typeof code === 'string' && /^\d+$/.test(code) && Number(code) > 0) return Number(code);
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 码 → 语义。未收录的码返回 undefined(调用方须显式兜底,**禁止猜测**)。 */
|
|
44
|
+
export function semanticsOfCode(code: number | undefined): ErrorSemantics | undefined {
|
|
45
|
+
return code === undefined ? undefined : ERROR_SEMANTICS[code];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 错误对象 → 语义。 */
|
|
49
|
+
export function semanticsOfError(error: unknown): ErrorSemantics | undefined {
|
|
50
|
+
return semanticsOfCode(errorCodeOf(error));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** HTTP 404:按标识寻址的资源不存在(absence=entity)。 */
|
|
54
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
55
|
+
return getHttpStatus(error) === 404;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 「未开通」判定:404 且契约标记 absence='unprovisioned'。 */
|
|
59
|
+
export function isUnprovisionedError(error: unknown): boolean {
|
|
60
|
+
if (getHttpStatus(error) !== 404) return false;
|
|
61
|
+
const code = errorCodeOf(error);
|
|
62
|
+
return code !== undefined && UNPROVISIONED_CODES.has(code);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 该错误是否应按空态渲染(契约 ui_observation='empty_state')。 */
|
|
66
|
+
export function isEmptyStateError(error: unknown): boolean {
|
|
67
|
+
const s = semanticsOfError(error);
|
|
68
|
+
if (s) return s.uiObservation === 'empty_state';
|
|
69
|
+
if (getHttpStatus(error) !== 404) return false;
|
|
70
|
+
const code = errorCodeOf(error);
|
|
71
|
+
return code !== undefined && EMPTY_STATE_CODES.has(code);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* **契约优先的重试判定**(R4):
|
|
76
|
+
* - 有业务码且契约收录 → **以契约 `retryable` 为准**(如 61001801「LDAP 未配置」=503 但重试必败 ⇒ false);
|
|
77
|
+
* - 无业务码 / 未收录 → 回落 HTTP 状态口径(`isRetryableError`:无状态 / 5xx / 408 / 429)。
|
|
78
|
+
* 这样「重试」也收敛到同一份契约,不再出现"状态说可重试、语义说没必要"的分叉。
|
|
79
|
+
*/
|
|
80
|
+
export function isRetryableErrorContractFirst(error: unknown): boolean {
|
|
81
|
+
const s = semanticsOfError(error);
|
|
82
|
+
return s ? s.retryable : isRetryableError(error);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 重试策略(唯一实现)—— 所有 `QueryClient` 默认与调用方都必须走这里。
|
|
87
|
+
* 契约优先(见 `isRetryableErrorContractFirst`);`MAX_RETRIES` 为唯一次数出处。
|
|
88
|
+
*/
|
|
89
|
+
export function shouldRetry(failureCount: number, error: unknown): boolean {
|
|
90
|
+
return isRetryableErrorContractFirst(error) && failureCount < MAX_RETRIES;
|
|
91
|
+
}
|