fix: 标准化钉钉限流重试策略
This commit is contained in:
parent
5be0b308bc
commit
3fb60314a2
@ -29,4 +29,6 @@ YYD_NOTIFICATION_PROJECTS_JSON=[{"code":"material_library","name":"素材库","s
|
|||||||
|
|
||||||
WORKER_ENABLED=true
|
WORKER_ENABLED=true
|
||||||
WORKER_INTERVAL_MS=1000
|
WORKER_INTERVAL_MS=1000
|
||||||
|
# 钉钉发送请求的最小启动间隔,统一限制在 100~5000ms;默认 250ms(最多约 4 条/秒)。
|
||||||
|
WORKER_SEND_MIN_INTERVAL_MS=250
|
||||||
READ_STATUS_INTERVAL_MS=5000
|
READ_STATUS_INTERVAL_MS=5000
|
||||||
|
|||||||
@ -170,4 +170,4 @@ POST /v1/auth/tickets/redeem
|
|||||||
- `YYD_NOTIFICATION_PUBLIC_BASE_URL`:通知中心公网 HTTPS 基地址,生产为反向代理后的 `/notification-center` 地址。
|
- `YYD_NOTIFICATION_PUBLIC_BASE_URL`:通知中心公网 HTTPS 基地址,生产为反向代理后的 `/notification-center` 地址。
|
||||||
- `DINGTALK_LOGIN_TICKET_TTL_SECONDS`:30~60 秒,默认 60 秒。
|
- `DINGTALK_LOGIN_TICKET_TTL_SECONDS`:30~60 秒,默认 60 秒。
|
||||||
|
|
||||||
新建任务最多投递 5 次,明确的限流等未受理结果按 1、5、15、30 分钟退避。发送调用若在请求已发出后发生网络断开、超时、HTTP 408/5xx、钉钉成功响应缺少 `processQueryKey`,或进程在发送中途崩溃并遗留过期租约,会以 `DELIVERY_OUTCOME_UNKNOWN` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。
|
新建任务最多投递 5 次,钉钉 HTTP 429、`QpsLimit` 和其他明确限流统一归一为 `FLOW_CONTROLLED`,按 1、5、15、30 分钟退避;接入方只观察统一状态和错误码,不解析钉钉厂商错误码。中心默认将发送请求启动间隔限制为至少 250ms(`WORKER_SEND_MIN_INTERVAL_MS` 可在 100~5000ms 内调整),避免整点任务集中冲击下游。发送调用若在请求已发出后发生网络断开、超时、非限流 HTTP 408/5xx、钉钉成功响应缺少 `processQueryKey`,或进程在发送中途崩溃并遗留过期租约,会以 `DELIVERY_OUTCOME_UNKNOWN` 结束并停止自动重试,避免不确定结果造成重复单聊。历史任务保留创建时的最大尝试次数,不通过迁移篡改。
|
||||||
|
|||||||
@ -20,6 +20,7 @@ services:
|
|||||||
YYD_NOTIFICATION_ADMIN_API_SECRET: ${YYD_NOTIFICATION_ADMIN_API_SECRET}
|
YYD_NOTIFICATION_ADMIN_API_SECRET: ${YYD_NOTIFICATION_ADMIN_API_SECRET}
|
||||||
YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY: ${YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY}
|
YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY: ${YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY}
|
||||||
WORKER_ENABLED: ${WORKER_ENABLED:-true}
|
WORKER_ENABLED: ${WORKER_ENABLED:-true}
|
||||||
|
WORKER_SEND_MIN_INTERVAL_MS: ${WORKER_SEND_MIN_INTERVAL_MS:-250}
|
||||||
volumes:
|
volumes:
|
||||||
- notification_data:/app/data
|
- notification_data:/app/data
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,14 @@
|
|||||||
|
|
||||||
业务项目一般不需要自己重发 `pending / sending / retrying / sent / unread` 状态。通知中心会处理明确可重试的失败并查询已读状态。
|
业务项目一般不需要自己重发 `pending / sending / retrying / sent / unread` 状态。通知中心会处理明确可重试的失败并查询已读状态。
|
||||||
|
|
||||||
## 3. 常见错误码
|
## 3. 标准边界
|
||||||
|
|
||||||
|
- 业务项目必须按统一请求契约提交 `recipientUserId` 或已登记的 `recipientAlias`、标题、正文、跳转地址和幂等键;通知中心不识别采购流程、供应商账号、邮箱等项目私有字段。
|
||||||
|
- 收件人解析和业务人员配置由接入方负责。姓名、邮箱、外部联系人 ID 和自行拼接的标识不能代替钉钉内部员工 `userId`。
|
||||||
|
- 接入方以 HTTP 状态和投递 `status` 作为自动控制流程依据;`lastErrorCode` 用于诊断和人工处置,不得根据未知错误码自行重发。
|
||||||
|
- 本规范公布的统一 `lastErrorCode` 语义稳定;尚未归一化的钉钉厂商错误码或附在 `lastErrorMessage` 中的厂商信息只供通知中心维护人排查,不构成对接契约。
|
||||||
|
|
||||||
|
## 4. 常见错误码
|
||||||
|
|
||||||
| 错误码 | 原因 | 处理方式 |
|
| 错误码 | 原因 | 处理方式 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@ -46,10 +53,14 @@
|
|||||||
| `IDEMPOTENCY_CONFLICT` | 同一幂等键对应了不同内容 | 修复幂等键或业务数据,不得随机换键规避 |
|
| `IDEMPOTENCY_CONFLICT` | 同一幂等键对应了不同内容 | 修复幂等键或业务数据,不得随机换键规避 |
|
||||||
| `NOT_FOUND` | 记录不存在或跨项目访问 | 检查投递 ID 和项目身份 |
|
| `NOT_FOUND` | 记录不存在或跨项目访问 | 检查投递 ID 和项目身份 |
|
||||||
| `INTERNAL_ERROR` | 通知中心内部异常 | 原幂等键退避重试并通知维护人员 |
|
| `INTERNAL_ERROR` | 通知中心内部异常 | 原幂等键退避重试并通知维护人员 |
|
||||||
|
| `FLOW_CONTROLLED` | 钉钉明确拒绝本次请求并要求限流 | 接入方等待,通知中心内部自动有限重试 |
|
||||||
|
| `INVALID_USER` | 钉钉确认收件人内部员工 `userId` 无效或不存在 | 接入方修正人员数据,不得改用邮箱或姓名重试 |
|
||||||
|
| `FILTERED_USER` | 收件人不在统一机器人可见范围内 | 由通知中心维护人核对应用权限范围 |
|
||||||
|
| `DELIVERY_OUTCOME_UNKNOWN` | 请求可能已经送达,无法安全确认 | 停止自动重试,人工核验后决定是否补发 |
|
||||||
|
|
||||||
投递进入 `failed` 时,还应记录返回中的 `lastErrorCode` 和 `lastErrorMessage`,但不要直接把内部错误原文展示给最终用户。
|
投递进入 `failed` 时,还应记录返回中的 `lastErrorCode` 和 `lastErrorMessage`,但不要直接把内部错误原文展示给最终用户。
|
||||||
|
|
||||||
## 4. 业务端重试原则
|
## 5. 业务端重试原则
|
||||||
|
|
||||||
1. 连接失败或 HTTP `500` 时可以有限重试,必须复用原 `idempotencyKey`,每次生成新 nonce。
|
1. 连接失败或 HTTP `500` 时可以有限重试,必须复用原 `idempotencyKey`,每次生成新 nonce。
|
||||||
2. HTTP `200 / 202` 后不要重新创建通知,改为查询返回的投递 ID。
|
2. HTTP `200 / 202` 后不要重新创建通知,改为查询返回的投递 ID。
|
||||||
@ -57,7 +68,7 @@
|
|||||||
4. 通知中心内部会处理明确未受理的钉钉限流等情况,业务项目不得再叠加无限重试。
|
4. 通知中心内部会处理明确未受理的钉钉限流等情况,业务项目不得再叠加无限重试。
|
||||||
5. `DELIVERY_OUTCOME_UNKNOWN` 表示发送结果无法确定。为避免用户收到重复单聊,通知中心会停止自动重试;业务负责人确认后才能人工补发。
|
5. `DELIVERY_OUTCOME_UNKNOWN` 表示发送结果无法确定。为避免用户收到重复单聊,通知中心会停止自动重试;业务负责人确认后才能人工补发。
|
||||||
|
|
||||||
## 5. 建议监控
|
## 6. 建议监控
|
||||||
|
|
||||||
业务项目至少记录:
|
业务项目至少记录:
|
||||||
|
|
||||||
|
|||||||
@ -252,6 +252,7 @@ center_lines = [
|
|||||||
'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY=' + quoted(config_encryption_key),
|
'YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY=' + quoted(config_encryption_key),
|
||||||
'WORKER_ENABLED=true',
|
'WORKER_ENABLED=true',
|
||||||
'WORKER_INTERVAL_MS=1000',
|
'WORKER_INTERVAL_MS=1000',
|
||||||
|
'WORKER_SEND_MIN_INTERVAL_MS=250',
|
||||||
'READ_STATUS_INTERVAL_MS=5000',
|
'READ_STATUS_INTERVAL_MS=5000',
|
||||||
]
|
]
|
||||||
target_path.write_text('\n'.join(center_lines) + '\n', encoding='utf-8')
|
target_path.write_text('\n'.join(center_lines) + '\n', encoding='utf-8')
|
||||||
|
|||||||
@ -13,6 +13,7 @@ test('生产发布面透传免登配置并保留已有项目 login', () => {
|
|||||||
'DINGTALK_CORP_ID',
|
'DINGTALK_CORP_ID',
|
||||||
'YYD_NOTIFICATION_PUBLIC_BASE_URL',
|
'YYD_NOTIFICATION_PUBLIC_BASE_URL',
|
||||||
'DINGTALK_LOGIN_TICKET_TTL_SECONDS',
|
'DINGTALK_LOGIN_TICKET_TTL_SECONDS',
|
||||||
|
'WORKER_SEND_MIN_INTERVAL_MS',
|
||||||
]) {
|
]) {
|
||||||
assert.match(compose, new RegExp(key))
|
assert.match(compose, new RegExp(key))
|
||||||
assert.match(example, new RegExp(key))
|
assert.match(example, new RegExp(key))
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { DingTalkError, sendRobotMessage } from '../dingtalk'
|
import { DingTalkError, sendRobotMessage } from '../dingtalk'
|
||||||
import prisma from '../prisma'
|
import prisma from '../prisma'
|
||||||
import { classifyDeliveryFailure, cleanupDeliveries, quarantineExpiredSendingLeases } from '../worker'
|
import {
|
||||||
|
classifyDeliveryFailure,
|
||||||
|
cleanupDeliveries,
|
||||||
|
normalizeWorkerSendMinIntervalMs,
|
||||||
|
quarantineExpiredSendingLeases,
|
||||||
|
} from '../worker'
|
||||||
|
|
||||||
test('token 失效先刷新重试一次,避免缓存过期导致批量误判终态', async () => {
|
test('token 失效先刷新重试一次,避免缓存过期导致批量误判终态', async () => {
|
||||||
const originalFetch = global.fetch
|
const originalFetch = global.fetch
|
||||||
@ -192,6 +197,96 @@ test('机器人 ActionCard 原样发送统一标题、分段正文和按钮跳
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('钉钉 HTTP 403 QPS 限流归一为中心标准错误并进入自动重试', async () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
const oldAppKey = process.env.DINGTALK_APP_KEY
|
||||||
|
const oldAppSecret = process.env.DINGTALK_APP_SECRET
|
||||||
|
const oldRobotCode = process.env.DINGTALK_ROBOT_CODE
|
||||||
|
process.env.DINGTALK_APP_KEY = 'test-app-key'
|
||||||
|
process.env.DINGTALK_APP_SECRET = 'test-app-secret'
|
||||||
|
process.env.DINGTALK_ROBOT_CODE = 'test-robot-code'
|
||||||
|
global.fetch = (async (input) => {
|
||||||
|
if (String(input).includes('/gettoken?')) {
|
||||||
|
return new Response(JSON.stringify({ errcode: 0, access_token: 'qps-test-token', expires_in: 7200 }), { status: 200 })
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
code: 'Forbidden.AccessDenied.QpsLimitForApi',
|
||||||
|
message: '当前所有钉钉应用调用该接口次数过多,超出了该接口承受的最大qps,请求被暂时限制了',
|
||||||
|
}), { status: 403 })
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
try {
|
||||||
|
let caught: unknown
|
||||||
|
try {
|
||||||
|
await sendRobotMessage({ recipientUserId: 'ding-user-1', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' })
|
||||||
|
} catch (error) {
|
||||||
|
caught = error
|
||||||
|
}
|
||||||
|
assert.ok(caught instanceof DingTalkError)
|
||||||
|
assert.equal(caught.code, 'FLOW_CONTROLLED')
|
||||||
|
assert.equal(caught.providerCode, 'Forbidden.AccessDenied.QpsLimitForApi')
|
||||||
|
assert.equal(caught.deterministic, false)
|
||||||
|
assert.equal(caught.outcomeUnknown, false)
|
||||||
|
assert.deepEqual(classifyDeliveryFailure({ error: caught, attemptCount: 1, maxAttempts: 5 }), {
|
||||||
|
failed: false,
|
||||||
|
errorCode: 'FLOW_CONTROLLED',
|
||||||
|
errorMessage: '[Forbidden.AccessDenied.QpsLimitForApi] 当前所有钉钉应用调用该接口次数过多,超出了该接口承受的最大qps,请求被暂时限制了',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
process.env.DINGTALK_APP_KEY = oldAppKey
|
||||||
|
process.env.DINGTALK_APP_SECRET = oldAppSecret
|
||||||
|
process.env.DINGTALK_ROBOT_CODE = oldRobotCode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('钉钉 staff 不存在归一为收件人无效并保持终态失败', async () => {
|
||||||
|
const originalFetch = global.fetch
|
||||||
|
const oldAppKey = process.env.DINGTALK_APP_KEY
|
||||||
|
const oldAppSecret = process.env.DINGTALK_APP_SECRET
|
||||||
|
const oldRobotCode = process.env.DINGTALK_ROBOT_CODE
|
||||||
|
process.env.DINGTALK_APP_KEY = 'test-app-key'
|
||||||
|
process.env.DINGTALK_APP_SECRET = 'test-app-secret'
|
||||||
|
process.env.DINGTALK_ROBOT_CODE = 'test-robot-code'
|
||||||
|
global.fetch = (async (input) => {
|
||||||
|
if (String(input).includes('/gettoken?')) {
|
||||||
|
return new Response(JSON.stringify({ errcode: 0, access_token: 'invalid-user-test-token', expires_in: 7200 }), { status: 200 })
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
code: 'staffId.notExisted',
|
||||||
|
message: '错误描述: staff 不存在;解决方案:请确认 userIds 是否正确;',
|
||||||
|
}), { status: 400 })
|
||||||
|
}) as typeof fetch
|
||||||
|
|
||||||
|
try {
|
||||||
|
let caught: unknown
|
||||||
|
try {
|
||||||
|
await sendRobotMessage({ recipientUserId: 'ding-user-invalid', title: '测试', content: '测试', actionTitle: '查看', actionUrl: 'https://example.com' })
|
||||||
|
} catch (error) {
|
||||||
|
caught = error
|
||||||
|
}
|
||||||
|
assert.ok(caught instanceof DingTalkError)
|
||||||
|
assert.equal(caught.code, 'INVALID_USER')
|
||||||
|
assert.equal(caught.providerCode, 'staffId.notExisted')
|
||||||
|
assert.equal(caught.deterministic, true)
|
||||||
|
assert.equal(classifyDeliveryFailure({ error: caught, attemptCount: 1, maxAttempts: 5 }).failed, true)
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch
|
||||||
|
process.env.DINGTALK_APP_KEY = oldAppKey
|
||||||
|
process.env.DINGTALK_APP_SECRET = oldAppSecret
|
||||||
|
process.env.DINGTALK_ROBOT_CODE = oldRobotCode
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('中心发送间隔使用安全默认值并限制配置边界', () => {
|
||||||
|
assert.equal(normalizeWorkerSendMinIntervalMs(undefined), 250)
|
||||||
|
assert.equal(normalizeWorkerSendMinIntervalMs(''), 250)
|
||||||
|
assert.equal(normalizeWorkerSendMinIntervalMs('invalid'), 250)
|
||||||
|
assert.equal(normalizeWorkerSendMinIntervalMs('0'), 100)
|
||||||
|
assert.equal(normalizeWorkerSendMinIntervalMs('375'), 375)
|
||||||
|
assert.equal(normalizeWorkerSendMinIntervalMs('99999'), 5000)
|
||||||
|
})
|
||||||
|
|
||||||
test('明确限流可安全重试,确定性错误和耗尽次数则停止', () => {
|
test('明确限流可安全重试,确定性错误和耗尽次数则停止', () => {
|
||||||
assert.equal(classifyDeliveryFailure({
|
assert.equal(classifyDeliveryFailure({
|
||||||
error: new DingTalkError('限流', 'FLOW_CONTROLLED'), attemptCount: 1, maxAttempts: 4,
|
error: new DingTalkError('限流', 'FLOW_CONTROLLED'), attemptCount: 1, maxAttempts: 4,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export class DingTalkError extends Error {
|
|||||||
public readonly code: string,
|
public readonly code: string,
|
||||||
public readonly deterministic = false,
|
public readonly deterministic = false,
|
||||||
public readonly outcomeUnknown = false,
|
public readonly outcomeUnknown = false,
|
||||||
|
public readonly providerCode?: string,
|
||||||
) {
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'DingTalkError'
|
this.name = 'DingTalkError'
|
||||||
@ -32,18 +33,34 @@ const requestJson = async (
|
|||||||
})
|
})
|
||||||
const data = await response.json().catch(() => ({})) as DingTalkPayload
|
const data = await response.json().catch(() => ({})) as DingTalkPayload
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const code = clean(data.code) || `HTTP_${response.status}`
|
const providerCode = clean(data.code) || `HTTP_${response.status}`
|
||||||
const message = clean(data.message) || `钉钉接口返回 HTTP ${response.status}`
|
const message = clean(data.message) || `钉钉接口返回 HTTP ${response.status}`
|
||||||
const outcomeUnknown = Boolean(options?.deliveryOutcomeCanBeUnknown && (response.status === 408 || response.status >= 500))
|
const code = normalizeDingTalkErrorCode(providerCode, message, response.status)
|
||||||
|
const rateLimited = code === 'FLOW_CONTROLLED'
|
||||||
|
const outcomeUnknown = Boolean(
|
||||||
|
options?.deliveryOutcomeCanBeUnknown &&
|
||||||
|
!rateLimited &&
|
||||||
|
(response.status === 408 || response.status >= 500),
|
||||||
|
)
|
||||||
throw new DingTalkError(
|
throw new DingTalkError(
|
||||||
message,
|
message,
|
||||||
code,
|
code,
|
||||||
response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429,
|
!rateLimited && response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429,
|
||||||
outcomeUnknown,
|
outcomeUnknown,
|
||||||
|
providerCode !== code ? providerCode : undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (data.errcode !== undefined && Number(data.errcode) !== 0) {
|
if (data.errcode !== undefined && Number(data.errcode) !== 0) {
|
||||||
throw new DingTalkError(clean(data.errmsg) || '钉钉接口调用失败', String(data.errcode), isDeterministic(data.errcode, data.errmsg))
|
const providerCode = String(data.errcode)
|
||||||
|
const message = clean(data.errmsg) || '钉钉接口调用失败'
|
||||||
|
const code = normalizeDingTalkErrorCode(providerCode, message)
|
||||||
|
throw new DingTalkError(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
isDeterministic(code, message),
|
||||||
|
false,
|
||||||
|
providerCode !== code ? providerCode : undefined,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -64,9 +81,27 @@ const requestJson = async (
|
|||||||
|
|
||||||
const clean = (value: unknown) => String(value || '').trim()
|
const clean = (value: unknown) => String(value || '').trim()
|
||||||
const stringList = (value: unknown) => Array.isArray(value) ? value.map(clean).filter(Boolean) : []
|
const stringList = (value: unknown) => Array.isArray(value) ? value.map(clean).filter(Boolean) : []
|
||||||
const isDeterministic = (code: unknown, message: unknown) =>
|
|
||||||
['40014', '40056', '40057', '40078', '41001', '41009', '60111', '60121', '70020'].includes(String(code)) ||
|
const isDingTalkRateLimitError = (code: unknown, message: unknown, status?: number) => {
|
||||||
/不存在|不在可见范围|无权限|invalid user|forbidden user|userid/i.test(clean(message))
|
const normalizedCode = clean(code).toLowerCase()
|
||||||
|
const normalizedMessage = clean(message)
|
||||||
|
return status === 429 ||
|
||||||
|
/qps.?limit|rate.?limit|too.?many.?requests|throttl/i.test(normalizedCode) ||
|
||||||
|
/最大\s*qps|qps\s*限制|请求.*(?:过多|限流|暂时限制)|too many requests|rate limit/i.test(normalizedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeDingTalkErrorCode = (providerCode: unknown, message: unknown, status?: number) => {
|
||||||
|
const code = clean(providerCode)
|
||||||
|
if (isDingTalkRateLimitError(code, message, status)) return 'FLOW_CONTROLLED'
|
||||||
|
if (code.toLowerCase() === 'staffid.notexisted') return 'INVALID_USER'
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDeterministic = (code: unknown, message: unknown) => {
|
||||||
|
if (clean(code) === 'FLOW_CONTROLLED') return false
|
||||||
|
return ['40014', '40056', '40057', '40078', '41001', '41009', '60111', '60121', '70020', 'INVALID_USER'].includes(String(code)) ||
|
||||||
|
/不存在|不在可见范围|无权限|invalid user|forbidden user|userid/i.test(clean(message))
|
||||||
|
}
|
||||||
|
|
||||||
// token 过期属于可恢复错误:先作废本地缓存、用新 token 重试一次;仍失败再按原语义上抛。
|
// token 过期属于可恢复错误:先作废本地缓存、用新 token 重试一次;仍失败再按原语义上抛。
|
||||||
const AUTH_EXPIRED_CODES = new Set(['40014', 'HTTP_401', 'InvalidAuthentication'])
|
const AUTH_EXPIRED_CODES = new Set(['40014', 'HTTP_401', 'InvalidAuthentication'])
|
||||||
|
|||||||
@ -6,6 +6,9 @@ const workerId = `${os.hostname()}:${process.pid}:${Math.random().toString(36).s
|
|||||||
const backoffMinutes = [1, 5, 15, 30]
|
const backoffMinutes = [1, 5, 15, 30]
|
||||||
// 单 tick 批量消费,避免积压时每秒只推进一条。
|
// 单 tick 批量消费,避免积压时每秒只推进一条。
|
||||||
const MAX_DELIVERIES_PER_TICK = 10
|
const MAX_DELIVERIES_PER_TICK = 10
|
||||||
|
const DEFAULT_SEND_MIN_INTERVAL_MS = 250
|
||||||
|
const MIN_SEND_MIN_INTERVAL_MS = 100
|
||||||
|
const MAX_SEND_MIN_INTERVAL_MS = 5000
|
||||||
// sent/unread 超过该天数仍无终态则停止回查;终态记录保留该天数后清理。
|
// sent/unread 超过该天数仍无终态则停止回查;终态记录保留该天数后清理。
|
||||||
const READ_STATUS_EXPIRY_DAYS = 14
|
const READ_STATUS_EXPIRY_DAYS = 14
|
||||||
const DELIVERY_RETENTION_DAYS = 90
|
const DELIVERY_RETENTION_DAYS = 90
|
||||||
@ -14,6 +17,20 @@ let busy = false
|
|||||||
let readBusy = false
|
let readBusy = false
|
||||||
const outcomeUnknownMessage = '钉钉发送结果不确定,请先在钉钉侧人工核验,系统不会自动重试'
|
const outcomeUnknownMessage = '钉钉发送结果不确定,请先在钉钉侧人工核验,系统不会自动重试'
|
||||||
|
|
||||||
|
export const normalizeWorkerSendMinIntervalMs = (value: unknown) => {
|
||||||
|
if (value === undefined || value === null || String(value).trim() === '') return DEFAULT_SEND_MIN_INTERVAL_MS
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) return DEFAULT_SEND_MIN_INTERVAL_MS
|
||||||
|
return Math.min(MAX_SEND_MIN_INTERVAL_MS, Math.max(MIN_SEND_MIN_INTERVAL_MS, Math.round(parsed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveryErrorMessage = (error: unknown) => {
|
||||||
|
if (error instanceof DingTalkError && error.providerCode) {
|
||||||
|
return `[${error.providerCode}] ${error.message}`.slice(0, 2000)
|
||||||
|
}
|
||||||
|
return String((error as any)?.message || '通知发送失败').slice(0, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
export const quarantineExpiredSendingLeases = async (now = new Date()) => prisma.notificationDelivery.updateMany({
|
export const quarantineExpiredSendingLeases = async (now = new Date()) => prisma.notificationDelivery.updateMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'sending',
|
status: 'sending',
|
||||||
@ -46,7 +63,7 @@ export const classifyDeliveryFailure = (input: {
|
|||||||
return {
|
return {
|
||||||
failed: deterministic || exhausted,
|
failed: deterministic || exhausted,
|
||||||
errorCode: error instanceof DingTalkError ? error.code : 'UNKNOWN',
|
errorCode: error instanceof DingTalkError ? error.code : 'UNKNOWN',
|
||||||
errorMessage: String((error as any)?.message || '通知发送失败').slice(0, 2000),
|
errorMessage: deliveryErrorMessage(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,10 +89,11 @@ const claimNext = async () => {
|
|||||||
return prisma.notificationDelivery.findUnique({ where: { id: candidate.id } })
|
return prisma.notificationDelivery.findUnique({ where: { id: candidate.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const processOne = async (): Promise<boolean> => {
|
const processOne = async (beforeSend?: () => Promise<void>): Promise<boolean> => {
|
||||||
const delivery = await claimNext()
|
const delivery = await claimNext()
|
||||||
if (!delivery) return false
|
if (!delivery) return false
|
||||||
try {
|
try {
|
||||||
|
await beforeSend?.()
|
||||||
const processQueryKey = await sendRobotMessage({
|
const processQueryKey = await sendRobotMessage({
|
||||||
recipientUserId: delivery.recipientUserId,
|
recipientUserId: delivery.recipientUserId,
|
||||||
title: delivery.renderedTitle,
|
title: delivery.renderedTitle,
|
||||||
@ -173,12 +191,19 @@ export const startWorker = () => {
|
|||||||
if (process.env.WORKER_ENABLED === 'false') return
|
if (process.env.WORKER_ENABLED === 'false') return
|
||||||
const interval = Math.max(500, Number(process.env.WORKER_INTERVAL_MS) || 1000)
|
const interval = Math.max(500, Number(process.env.WORKER_INTERVAL_MS) || 1000)
|
||||||
const readInterval = Math.max(2000, Number(process.env.READ_STATUS_INTERVAL_MS) || 5000)
|
const readInterval = Math.max(2000, Number(process.env.READ_STATUS_INTERVAL_MS) || 5000)
|
||||||
|
const sendMinInterval = normalizeWorkerSendMinIntervalMs(process.env.WORKER_SEND_MIN_INTERVAL_MS)
|
||||||
|
let lastSendStartedAt = 0
|
||||||
|
const waitForSendSlot = async () => {
|
||||||
|
const waitMs = Math.max(0, sendMinInterval - (Date.now() - lastSendStartedAt))
|
||||||
|
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs))
|
||||||
|
lastSendStartedAt = Date.now()
|
||||||
|
}
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
for (let processed = 0; processed < MAX_DELIVERIES_PER_TICK; processed++) {
|
for (let processed = 0; processed < MAX_DELIVERIES_PER_TICK; processed++) {
|
||||||
const claimed = await processOne()
|
const claimed = await processOne(waitForSendSlot)
|
||||||
if (!claimed) break
|
if (!claimed) break
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user