Skip to content

Commit 2b28a74

Browse files
authored
fix(explorer): alert on Tempo API access failures (#1155)
* fix(explorer): alert on Tempo API access failures * fix(explorer): exclude 401 responses from alerts
1 parent 986879b commit 2b28a74

5 files changed

Lines changed: 89 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const NON_RETRYABLE_HTTP_STATUS = /(?:status:\s*|\b)(402|403|429)\b/i
2+
3+
export function shouldRetryQuery(
4+
failureCount: number,
5+
error: unknown,
6+
): boolean {
7+
if (failureCount >= 2) return false
8+
9+
const message = error instanceof Error ? error.message : String(error)
10+
return !NON_RETRYABLE_HTTP_STATUS.test(message)
11+
}

apps/explorer/src/lib/server/tempo-api.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,52 @@
11
import type * as cadent from 'cadent'
2+
import * as Sentry from '@sentry/cloudflare'
23
import { hc } from 'hono/client'
34
import { serverEnv, tempoApiUrl } from './env.ts'
45

6+
const ALERTABLE_STATUSES = new Set([402, 403, 429])
7+
const REPORT_THROTTLE_MS = 60_000
8+
const lastReportedAt = new Map<string, number>()
9+
10+
export function isAlertableTempoApiStatus(status: number): boolean {
11+
return ALERTABLE_STATUSES.has(status) || status >= 500
12+
}
13+
14+
function reportTempoApiResponse(response: Response, method: string): void {
15+
if (!isAlertableTempoApiStatus(response.status)) return
16+
17+
const url = new URL(response.url)
18+
const key = `${response.status}:${method}:${url.pathname}`
19+
const now = Date.now()
20+
const previous = lastReportedAt.get(key) ?? 0
21+
22+
console.error('[tempo-api] upstream request failed', {
23+
method,
24+
path: url.pathname,
25+
status: response.status,
26+
})
27+
28+
if (now - previous < REPORT_THROTTLE_MS) return
29+
lastReportedAt.set(key, now)
30+
31+
Sentry.captureMessage(`Tempo API returned ${response.status}`, {
32+
level: 'error',
33+
tags: {
34+
component: 'tempo-api-client',
35+
method,
36+
path: url.pathname,
37+
status: String(response.status),
38+
},
39+
})
40+
}
41+
42+
const instrumentedFetch: typeof fetch = async (input, init) => {
43+
const response = await fetch(input, init)
44+
const method =
45+
init?.method ?? (input instanceof Request ? input.method : 'GET')
46+
reportTempoApiResponse(response, method)
47+
return response
48+
}
49+
550
/**
651
* Typed client for the Tempo API. Server-side only.
752
*
@@ -17,6 +62,7 @@ import { serverEnv, tempoApiUrl } from './env.ts'
1762
* (scopes: `data:read`, `indexer:query`).
1863
*/
1964
export const api = hc<cadent.App.App>(tempoApiUrl, {
65+
fetch: instrumentedFetch,
2066
headers: serverEnv.TEMPO_API_KEY
2167
? { 'tempo-api-key': serverEnv.TEMPO_API_KEY }
2268
: undefined,

apps/explorer/src/router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ProfileEvents,
1212
} from '#lib/profiling'
1313
import { initSentry } from '#lib/sentry'
14+
import { shouldRetryQuery } from '#lib/query-retry'
1415
import { routeTree } from '#routeTree.gen.ts'
1516

1617
const queryStartTimes = new WeakMap<object, number>()
@@ -20,6 +21,7 @@ export const getRouter = () => {
2021
const queryClient: QueryClient = new QueryClient({
2122
defaultOptions: {
2223
queries: {
24+
retry: shouldRetryQuery,
2325
staleTime: 60 * 1_000, // needed for SSR - prevents refetch on hydration
2426
gcTime: 1_000 * 60 * 60 * 24, // 24 hours
2527
queryKeyHashFn: hashFn,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { shouldRetryQuery } from '../src/lib/query-retry'
3+
4+
describe('shouldRetryQuery', () => {
5+
it.each([
6+
'402 Payment Required',
7+
'Status: 403',
8+
'Request failed with status: 429',
9+
])('does not retry non-retryable upstream errors: %s', (message) => {
10+
expect(shouldRetryQuery(0, new Error(message))).toBe(false)
11+
})
12+
13+
it('retries other failures at most twice', () => {
14+
expect(shouldRetryQuery(0, new Error('upstream unavailable'))).toBe(true)
15+
expect(shouldRetryQuery(1, new Error('upstream unavailable'))).toBe(true)
16+
expect(shouldRetryQuery(2, new Error('upstream unavailable'))).toBe(false)
17+
})
18+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { isAlertableTempoApiStatus } from '../src/lib/server/tempo-api'
3+
4+
describe('isAlertableTempoApiStatus', () => {
5+
it.each([402, 403, 429, 500, 503])('alerts for status %i', (status) => {
6+
expect(isAlertableTempoApiStatus(status)).toBe(true)
7+
})
8+
9+
it.each([200, 400, 401, 404])('does not alert for status %i', (status) => {
10+
expect(isAlertableTempoApiStatus(status)).toBe(false)
11+
})
12+
})

0 commit comments

Comments
 (0)