Skip to content

Commit 2594372

Browse files
committed
Align metrics reporter with default Datadog retry behavior
1 parent fac9c78 commit 2594372

3 files changed

Lines changed: 183 additions & 22 deletions

File tree

packages/base/src/helpers/__tests__/metrics.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ describe('metrics', () => {
1818
beforeEach(() => {
1919
jest.restoreAllMocks()
2020
mockedGetProxyDispatcher.mockReturnValue(fakeDispatcher)
21+
delete process.env.DATADOG_API_KEY
22+
delete process.env.DD_API_KEY
23+
delete process.env.DATADOG_SITE
24+
delete process.env.DD_SITE
25+
delete process.env.DATADOG_API_HOST
2126
mockedHttpRequest.mockResolvedValue({
2227
config: {},
2328
data: {},
@@ -68,6 +73,29 @@ describe('metrics', () => {
6873
})
6974
})
7075

76+
test('should strip an app. prefix from the configured site', async () => {
77+
const reporter = new UndiciDatadogReporter({
78+
apiKey: 'test-api-key',
79+
datadogSite: 'app.datadoghq.eu',
80+
})
81+
82+
await reporter.report([gaugeMetric])
83+
84+
expect(mockedHttpRequest).toHaveBeenCalledWith({
85+
data: {series: [gaugeMetric]},
86+
dispatcher: fakeDispatcher,
87+
headers: {'DD-API-KEY': 'test-api-key'},
88+
method: 'POST',
89+
url: 'https://api.datadoghq.eu/api/v1/series',
90+
})
91+
})
92+
93+
test('should throw when no Datadog API key is configured', () => {
94+
expect(() => new UndiciDatadogReporter()).toThrow(
95+
'Datadog API key not found. You must specify one via the `apiKey` configuration option or the DATADOG_API_KEY or DD_API_KEY environment variable.'
96+
)
97+
})
98+
7199
test('should retry retryable request errors', async () => {
72100
jest.spyOn(global, 'setTimeout').mockImplementation((handler: Parameters<typeof setTimeout>[0]) => {
73101
if (typeof handler === 'function') {
@@ -99,9 +127,63 @@ describe('metrics', () => {
99127
expect(mockedHttpRequest).toHaveBeenCalledTimes(2)
100128
})
101129

130+
test('should use the retry-after header when retrying 429 responses', async () => {
131+
const setTimeoutSpy = jest
132+
.spyOn(global, 'setTimeout')
133+
.mockImplementation((handler: Parameters<typeof setTimeout>[0]) => {
134+
if (typeof handler === 'function') {
135+
handler()
136+
}
137+
138+
return {} as ReturnType<typeof setTimeout>
139+
})
140+
141+
mockedHttpRequest
142+
.mockRejectedValueOnce(
143+
new RequestError('Request failed', {} as RequestConfig, {
144+
data: {},
145+
headers: {'retry-after': '3'},
146+
status: 429,
147+
statusText: 'Too Many Requests',
148+
})
149+
)
150+
.mockResolvedValueOnce({
151+
config: {},
152+
data: {},
153+
headers: {},
154+
status: 202,
155+
statusText: 'Accepted',
156+
})
157+
158+
const reporter = new UndiciDatadogReporter({
159+
apiKey: 'test-api-key',
160+
datadogSite: 'datadoghq.com',
161+
})
162+
163+
await reporter.report([gaugeMetric])
164+
165+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3000)
166+
expect(mockedHttpRequest).toHaveBeenCalledTimes(2)
167+
})
168+
169+
test('should not retry unknown network errors without an upstream retryable code', async () => {
170+
const unknownNetworkError = new RequestError('socket hang up', {} as RequestConfig)
171+
;(unknownNetworkError as RequestError & {code?: string}).code = 'UNKNOWN'
172+
173+
mockedHttpRequest.mockRejectedValue(unknownNetworkError)
174+
175+
const reporter = new UndiciDatadogReporter({
176+
apiKey: 'test-api-key',
177+
datadogSite: 'datadoghq.com',
178+
})
179+
180+
await expect(reporter.report([gaugeMetric])).rejects.toThrow('socket hang up')
181+
expect(mockedHttpRequest).toHaveBeenCalledTimes(1)
182+
})
183+
102184
test('should not retry non-retryable request errors', async () => {
103185
mockedHttpRequest.mockRejectedValue(
104-
new RequestError('Request failed', {} as RequestConfig, {data: {}, status: 403, statusText: 'Forbidden'})
186+
new RequestError('Request failed', {} as RequestConfig, {data: {}, status: 413, statusText: 'Too Large'})
105187
)
106188

107189
const reporter = new UndiciDatadogReporter({
@@ -112,6 +194,21 @@ describe('metrics', () => {
112194
await expect(reporter.report([gaugeMetric])).rejects.toThrow('Request failed')
113195
expect(mockedHttpRequest).toHaveBeenCalledTimes(1)
114196
})
197+
198+
test('should wrap 403 responses with the default authorization error message', async () => {
199+
mockedHttpRequest.mockRejectedValue(
200+
new RequestError('Request failed', {} as RequestConfig, {data: {}, status: 403, statusText: 'Forbidden'})
201+
)
202+
203+
const reporter = new UndiciDatadogReporter({
204+
apiKey: 'test-api-key',
205+
datadogSite: 'datadoghq.com',
206+
})
207+
208+
await expect(reporter.report([gaugeMetric])).rejects.toThrow(
209+
'Your Datadog API key is not authorized to send metrics.'
210+
)
211+
})
115212
})
116213

117214
describe('getMetricsLogger', () => {

packages/base/src/helpers/metrics.ts

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import {BufferedMetricsLogger} from 'datadog-metrics'
22

3-
import {getApiUrl, getDatadogSite} from './api'
3+
import {DATADOG_SITE_US1} from '../constants'
4+
5+
import {getDatadogSiteFromEnv} from './api'
46
import {getProxyDispatcher, httpRequest, isRequestError} from './request'
57

68
const DEFAULT_RETRIES = 2
79
const DEFAULT_RETRY_BACKOFF_SECONDS = 1
8-
const NON_RETRYABLE_STATUS_CODES = new Set([400, 403, 413])
10+
const RETRYABLE_ERROR_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EPIPE', 'ETIMEDOUT'])
11+
const AUTHORIZATION_ERROR_MESSAGE =
12+
'Your Datadog API key is not authorized to send metrics. Check to make sure the DATADOG_API_KEY or DD_API_KEY environment variable or the `apiKey` init option is set to a valid API key for your Datadog account, and that it is not an *application* key. For more, see: https://docs.datadoghq.com/account_management/api-app-keys/'
913

1014
interface MetricSeries {
1115
metric: string
@@ -15,43 +19,98 @@ interface MetricSeries {
1519
tags: string[]
1620
}
1721

22+
interface UndiciDatadogReporterOptions {
23+
apiKey?: string
24+
datadogSite?: string
25+
retries?: number
26+
retryBackoff?: number
27+
}
28+
1829
const sleep = async (milliseconds: number): Promise<void> =>
1930
new Promise((resolve) => {
2031
setTimeout(resolve, milliseconds)
2132
})
2233

23-
const isRetryableError = (error: unknown): boolean => {
24-
if (!isRequestError(error) || error.response === undefined) {
25-
return true
34+
const getRetryableErrorCode = (error: unknown): string | undefined => {
35+
if (typeof error === 'object' && error && 'code' in error && typeof (error as {code?: unknown}).code === 'string') {
36+
return (error as {code: string}).code
2637
}
2738

28-
if (NON_RETRYABLE_STATUS_CODES.has(error.response.status)) {
29-
return false
39+
return undefined
40+
}
41+
42+
const getRetryDelay = (error: unknown, attempt: number, retryBackoffSeconds: number): number => {
43+
const retryAfterHeader =
44+
isRequestError(error) && error.response?.status === 429
45+
? (error.response.headers?.['retry-after'] ?? error.response.headers?.['x-ratelimit-reset'])
46+
: undefined
47+
48+
const retryAfterSeconds = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : Number.NaN
49+
if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
50+
return retryAfterSeconds * 1000
3051
}
3152

32-
return error.response.status === 429 || error.response.status >= 500
53+
return 2 ** attempt * retryBackoffSeconds * 1000
54+
}
55+
56+
const isRetryableError = (error: unknown, attempt: number, retries: number): boolean => {
57+
const status = isRequestError(error) ? error.response?.status : undefined
58+
const code = getRetryableErrorCode(error)
59+
60+
return (
61+
attempt < retries &&
62+
(RETRYABLE_ERROR_CODES.has(code ?? '') || status === 429 || (status !== undefined && status >= 500))
63+
)
3364
}
3465

35-
const getRetryDelay = (attempt: number): number => 2 ** attempt * DEFAULT_RETRY_BACKOFF_SECONDS * 1000
66+
const getReporterSite = (site?: string): string => {
67+
const resolvedSite = site || getDatadogSiteFromEnv() || process.env.DATADOG_API_HOST || DATADOG_SITE_US1
68+
69+
return resolvedSite.replace(/^app\./i, '')
70+
}
3671

3772
export class UndiciDatadogReporter {
38-
private apiKey?: string
73+
private apiKey: string
3974
private apiUrl: string
4075
private dispatcher = getProxyDispatcher('')
76+
private retries: number
77+
private retryBackoffSeconds: number
78+
79+
constructor(options: UndiciDatadogReporterOptions = {}) {
80+
this.apiKey = options.apiKey ?? process.env.DATADOG_API_KEY ?? process.env.DD_API_KEY ?? ''
81+
if (!this.apiKey) {
82+
throw new Error(
83+
'Datadog API key not found. You must specify one via the `apiKey` configuration option or the DATADOG_API_KEY or DD_API_KEY environment variable.'
84+
)
85+
}
4186

42-
constructor(options: {apiKey?: string; datadogSite?: string}) {
43-
this.apiKey = options.apiKey ?? process.env.DATADOG_API_KEY ?? process.env.DD_API_KEY
44-
this.apiUrl = getApiUrl(options.datadogSite)
87+
this.retries = options.retries !== undefined && options.retries >= 0 ? options.retries : DEFAULT_RETRIES
88+
this.retryBackoffSeconds =
89+
options.retryBackoff !== undefined && options.retryBackoff >= 0
90+
? options.retryBackoff
91+
: DEFAULT_RETRY_BACKOFF_SECONDS
92+
this.apiUrl = `https://api.${getReporterSite(options.datadogSite)}`
4593
}
4694

4795
public async report(series: MetricSeries[]): Promise<void> {
4896
const metricsSeries = series.filter((metric) => metric.type !== 'distribution')
4997
const distributionsSeries = series.filter((metric) => metric.type === 'distribution')
50-
51-
await Promise.all([
98+
const submissions = [
5299
this.sendSeries('/api/v1/series', metricsSeries),
53100
this.sendSeries('/api/v1/distribution_points', distributionsSeries),
54-
])
101+
]
102+
103+
try {
104+
await Promise.all(submissions)
105+
} catch (error) {
106+
if (isRequestError(error) && error.response?.status === 403) {
107+
const wrappedError = new Error(AUTHORIZATION_ERROR_MESSAGE)
108+
;(wrappedError as Error & {cause?: unknown}).cause = error
109+
throw wrappedError
110+
}
111+
112+
throw error
113+
}
55114
}
56115

57116
private async sendSeries(path: string, series: MetricSeries[]): Promise<void> {
@@ -73,11 +132,11 @@ export class UndiciDatadogReporter {
73132

74133
return
75134
} catch (error) {
76-
if (attempt >= DEFAULT_RETRIES || !isRetryableError(error)) {
135+
if (!isRetryableError(error, attempt, this.retries)) {
77136
throw error
78137
}
79138

80-
await sleep(getRetryDelay(attempt))
139+
await sleep(getRetryDelay(error, attempt, this.retryBackoffSeconds))
81140
}
82141
}
83142
}
@@ -96,7 +155,7 @@ export interface MetricsLoggerOptions {
96155
}
97156

98157
export const getMetricsLogger = (opts: MetricsLoggerOptions): MetricsLogger => {
99-
const datadogSite = getDatadogSite(opts.datadogSite)
158+
const datadogSite = getReporterSite(opts.datadogSite)
100159
const apiUrl = `api.${datadogSite}`
101160

102161
const metricsOpts = {

packages/base/src/helpers/request.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@ export interface RequestResponse<T = any> {
3030
export class RequestError extends Error {
3131
public config: RequestConfig
3232
public isRequestError = true as const
33-
public response?: {data: any; status: number; statusText: string}
33+
public response?: {data: any; headers?: Record<string, string>; status: number; statusText: string}
3434

35-
constructor(message: string, config: RequestConfig, response?: {data: any; status: number; statusText: string}) {
35+
constructor(
36+
message: string,
37+
config: RequestConfig,
38+
response?: {data: any; headers?: Record<string, string>; status: number; statusText: string}
39+
) {
3640
super(message)
3741
this.name = 'RequestError'
3842
this.config = config
@@ -183,6 +187,7 @@ export const httpRequest = async <T = any>(config: RequestConfig): Promise<Reque
183187
const message = `Request failed with status code ${response.status}`
184188
throw new RequestError(message, config, {
185189
data,
190+
headers: responseHeaders,
186191
status: response.status,
187192
statusText: response.statusText,
188193
})

0 commit comments

Comments
 (0)