优化high-frequency Request 问题
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import http from 'node:http'
|
||||
|
||||
import { cloudtentaclesRequest } from './http-client.js'
|
||||
|
||||
type MockResponse = {
|
||||
status?: number
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
test('cloudtentaclesRequest retries high-frequency business errors', async () => {
|
||||
let callCount = 0
|
||||
const server = await createMockServer(() => {
|
||||
callCount += 1
|
||||
return {
|
||||
body: callCount === 1
|
||||
? { code: 1, message: 'high-frequency Request' }
|
||||
: { code: 0, data: 'ok' },
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await cloudtentaclesRequest('/user/get_asset', {
|
||||
baseUrl: server.baseUrl,
|
||||
token: 'retry-token',
|
||||
sourceKey: 'retry-source',
|
||||
rateLimitIntervalMs: 0,
|
||||
rateLimitRetries: 1,
|
||||
rateLimitRetryDelayMs: 1,
|
||||
})
|
||||
|
||||
assert.equal(callCount, 2)
|
||||
assert.equal(result.payload?.data, 'ok')
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('cloudtentaclesRequest maps high-frequency exhaustion to 429', async () => {
|
||||
const server = await createMockServer(() => ({
|
||||
body: { code: 1, message: 'high-frequency Request' },
|
||||
}))
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
cloudtentaclesRequest('/user/get_asset', {
|
||||
baseUrl: server.baseUrl,
|
||||
token: 'limited-token',
|
||||
sourceKey: 'limited-source',
|
||||
rateLimitIntervalMs: 0,
|
||||
rateLimitRetries: 0,
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error instanceof Error, true)
|
||||
assert.equal(Reflect.get(error as object, 'statusCode'), 429)
|
||||
assert.equal(Reflect.get(error as object, 'errorCode'), 'cloudtentacles_rate_limited')
|
||||
return true
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('cloudtentaclesRequest spaces concurrent requests in the same bucket', async () => {
|
||||
const requestTimes: number[] = []
|
||||
const server = await createMockServer(() => {
|
||||
requestTimes.push(Date.now())
|
||||
return { body: { code: 0, data: 'ok' } }
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
cloudtentaclesRequest('/sku/list', {
|
||||
baseUrl: server.baseUrl,
|
||||
token: 'throttle-token',
|
||||
sourceKey: 'throttle-source',
|
||||
rateLimitIntervalMs: 25,
|
||||
rateLimitRetries: 0,
|
||||
}),
|
||||
cloudtentaclesRequest('/user/get_knapsack', {
|
||||
baseUrl: server.baseUrl,
|
||||
token: 'throttle-token',
|
||||
sourceKey: 'throttle-source',
|
||||
rateLimitIntervalMs: 25,
|
||||
rateLimitRetries: 0,
|
||||
}),
|
||||
])
|
||||
|
||||
assert.equal(requestTimes.length, 2)
|
||||
assert.ok(Math.abs(Number(requestTimes[1]) - Number(requestTimes[0])) >= 20)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
async function createMockServer(handler: () => MockResponse) {
|
||||
const server = http.createServer((_request, response) => {
|
||||
const result = handler()
|
||||
response.statusCode = result.status || 200
|
||||
response.setHeader('content-type', 'application/json')
|
||||
response.end(JSON.stringify(result.body ?? { code: 0, data: null }))
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
assert.equal(typeof address, 'object')
|
||||
assert.notEqual(address, null)
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address?.port}`,
|
||||
close: () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { logInfo } from '../../../utils/logger.js'
|
||||
import { logInfo, logWarn } from '../../../utils/logger.js'
|
||||
import { notifyCloudtentaclesAuthExpired } from '../../notification/domain-notifications.js'
|
||||
import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './helpers.js'
|
||||
|
||||
@@ -17,13 +17,18 @@ type NodeHttpResponse = {
|
||||
bodyText: string
|
||||
}
|
||||
|
||||
const DEFAULT_RATE_LIMIT_INTERVAL_MS = 300
|
||||
const DEFAULT_RATE_LIMIT_RETRIES = 2
|
||||
const DEFAULT_RATE_LIMIT_RETRY_DELAY_MS = 800
|
||||
const MAX_RETRY_JITTER_MS = 180
|
||||
const bucketNextAvailableAt = new Map<string, number>()
|
||||
let rateLimitQueue = Promise.resolve()
|
||||
|
||||
export async function cloudtentaclesRequest(pathname: unknown, options: JsonObject = {}) {
|
||||
const normalizedPathname = String(pathname || '').trim()
|
||||
const config = resolveCloudtentaclesConfig(options)
|
||||
const url = buildCloudtentaclesUrl(config.baseUrl, normalizedPathname, options.searchParams)
|
||||
const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 5000)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
const method = String(options.method || 'GET').trim().toUpperCase()
|
||||
const headers = buildCloudtentaclesHeaders({
|
||||
token: options.token,
|
||||
@@ -32,23 +37,41 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
deviceType: options.deviceType ?? config.deviceType,
|
||||
extra: options.headers,
|
||||
})
|
||||
const rateLimitBucketKey = resolveRateLimitBucketKey({
|
||||
baseUrl: config.baseUrl,
|
||||
token: options.token,
|
||||
sourceKey: options.sourceKey,
|
||||
deviceId: options.deviceId ?? config.deviceId,
|
||||
})
|
||||
const rateLimitIntervalMs = normalizeNonNegativeInteger(options.rateLimitIntervalMs, DEFAULT_RATE_LIMIT_INTERVAL_MS)
|
||||
const maxRetries = normalizeNonNegativeInteger(options.rateLimitRetries, DEFAULT_RATE_LIMIT_RETRIES)
|
||||
const retryDelayMs = normalizeNonNegativeInteger(options.rateLimitRetryDelayMs, DEFAULT_RATE_LIMIT_RETRY_DELAY_MS)
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
try {
|
||||
await waitForCloudtentaclesSlot(rateLimitBucketKey, rateLimitIntervalMs)
|
||||
const body = normalizeRequestBody(options.body, headers['content-type'])
|
||||
const response = await requestViaNodeHttp(url, {
|
||||
const response = await executeCloudtentaclesRequest({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
timeoutMs,
|
||||
...(body !== undefined ? { body } : {}),
|
||||
})
|
||||
|
||||
const rawText = response.bodyText
|
||||
const payload = tryParseJson(rawText)
|
||||
|
||||
if (!response.ok) {
|
||||
if (isCloudtentaclesHttpRateLimited(response.status) && attempt < maxRetries) {
|
||||
await waitBeforeRateLimitRetry(attempt, normalizedPathname, method, response.status, retryDelayMs)
|
||||
continue
|
||||
}
|
||||
|
||||
throw createHttpError(`cloudtentacles 请求失败,HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'cloudtentacles_http_error',
|
||||
statusCode: isCloudtentaclesHttpRateLimited(response.status) ? 429 : 502,
|
||||
errorCode: isCloudtentaclesHttpRateLimited(response.status)
|
||||
? 'cloudtentacles_rate_limited'
|
||||
: 'cloudtentacles_http_error',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,6 +80,22 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
const statusCode = Number(options.businessErrorStatusCode || 400)
|
||||
const errorCode = String(options.businessErrorCode || 'cloudtentacles_business_error')
|
||||
|
||||
if (isHighFrequencyMessage(message)) {
|
||||
if (attempt < maxRetries) {
|
||||
await waitBeforeRateLimitRetry(attempt, normalizedPathname, method, response.status, retryDelayMs)
|
||||
continue
|
||||
}
|
||||
|
||||
throw createHttpError('cloudtentacles 请求过于频繁,请稍后重试', {
|
||||
statusCode: 429,
|
||||
errorCode: 'cloudtentacles_rate_limited',
|
||||
context: {
|
||||
pathname: normalizedPathname,
|
||||
upstreamMessage: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isCloudtentaclesExpiredMessage(message)) {
|
||||
await notifyCloudtentaclesAuthExpired({
|
||||
pathname: normalizedPathname,
|
||||
@@ -77,6 +116,7 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
method,
|
||||
pathname: normalizedPathname,
|
||||
status: response.status,
|
||||
attempt: attempt + 1,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -93,10 +133,15 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje
|
||||
errorCode: 'cloudtentacles_request_timeout',
|
||||
})
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpError('cloudtentacles 请求过于频繁,请稍后重试', {
|
||||
statusCode: 429,
|
||||
errorCode: 'cloudtentacles_rate_limited',
|
||||
})
|
||||
}
|
||||
|
||||
function isCloudtentaclesExpiredMessage(message: unknown) {
|
||||
@@ -104,6 +149,121 @@ function isCloudtentaclesExpiredMessage(message: unknown) {
|
||||
return normalized.includes('expired') || normalized.includes('过期') || normalized.includes('失效')
|
||||
}
|
||||
|
||||
function isHighFrequencyMessage(message: unknown) {
|
||||
return String(message || '').trim().toLowerCase().includes('high-frequency request')
|
||||
}
|
||||
|
||||
function isCloudtentaclesHttpRateLimited(status: unknown) {
|
||||
return Number(status) === 429
|
||||
}
|
||||
|
||||
async function executeCloudtentaclesRequest({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
timeoutMs,
|
||||
}: {
|
||||
url: URL
|
||||
method: string
|
||||
headers: JsonObject
|
||||
body?: string | URLSearchParams
|
||||
timeoutMs: number
|
||||
}) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
return await requestViaNodeHttp(url, {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
...(body !== undefined ? { body } : {}),
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCloudtentaclesSlot(bucketKey: string, intervalMs: number) {
|
||||
if (intervalMs <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousQueue = rateLimitQueue
|
||||
let releaseQueue: () => void = () => {}
|
||||
rateLimitQueue = new Promise<void>((resolve) => {
|
||||
releaseQueue = resolve
|
||||
})
|
||||
|
||||
await previousQueue
|
||||
|
||||
let waitMs = 0
|
||||
try {
|
||||
const now = Date.now()
|
||||
const nextAvailableAt = bucketNextAvailableAt.get(bucketKey) || 0
|
||||
waitMs = Math.max(0, nextAvailableAt - now)
|
||||
bucketNextAvailableAt.set(bucketKey, Math.max(now, nextAvailableAt) + intervalMs)
|
||||
} finally {
|
||||
releaseQueue()
|
||||
}
|
||||
|
||||
if (waitMs > 0) {
|
||||
await sleep(waitMs)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitBeforeRateLimitRetry(
|
||||
attempt: number,
|
||||
pathname: string,
|
||||
method: string,
|
||||
status: number,
|
||||
retryDelayMs: number,
|
||||
) {
|
||||
const delayMs = retryDelayMs * (attempt + 1) + Math.floor(Math.random() * MAX_RETRY_JITTER_MS)
|
||||
logWarn('[cloudtentacles/http]', '请求触发云触手限频,准备重试', {
|
||||
method,
|
||||
pathname,
|
||||
status,
|
||||
attempt: attempt + 1,
|
||||
retryDelayMs: delayMs,
|
||||
})
|
||||
await sleep(delayMs)
|
||||
}
|
||||
|
||||
function resolveRateLimitBucketKey({
|
||||
baseUrl,
|
||||
token,
|
||||
sourceKey,
|
||||
deviceId,
|
||||
}: {
|
||||
baseUrl: unknown
|
||||
token: unknown
|
||||
sourceKey: unknown
|
||||
deviceId: unknown
|
||||
}) {
|
||||
return [
|
||||
String(baseUrl || '').trim(),
|
||||
String(sourceKey || '').trim() || String(token || '').trim() || 'anonymous',
|
||||
String(deviceId || '').trim(),
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value: unknown, fallback: number) {
|
||||
const numericValue = Number(value)
|
||||
if (Number.isInteger(numericValue) && numericValue >= 0) {
|
||||
return numericValue
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function inferContentType(body: unknown) {
|
||||
if (body == null) {
|
||||
return ''
|
||||
|
||||
Reference in New Issue
Block a user