Skip to content

Commit c29ffcf

Browse files
authored
fix: Do not capture 404 responses from external APIs at Sentry (#904)
## Description External APIs (Jupiter, CoinGecko, Rugcheck) return 404 for tokens or coins that simply don't exist — a normal scenario, not an error. Downgrade 404s to Logger.debug so they no longer trigger Sentry alerts, matching the existing pattern in the OSEC verified-programs route. ## Type of change <!-- Check the appropriate options that apply to this PR --> - [x] Bug fix ## Testing - Tests should pass ## Related Issues Closes [HOO-363](https://linear.app/solana-fndn/issue/HOO-363/do-not-capture-404-responses-from-external-apis-at-sentry) ## Checklist <!-- Verify that you have completed the following before requesting review --> - [x] My code follows the project's style guidelines - [x] I have added tests that prove my fix/feature works - [x] All tests pass locally and in CI - [x] CI/CD checks pass
1 parent 6a386b6 commit c29ffcf

File tree

5 files changed

+143
-9
lines changed

5 files changed

+143
-9
lines changed

app/api/receipt/price/[mintAddress]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export async function GET(_request: Request, { params: { mintAddress } }: Params
4141
});
4242

4343
if (!response.ok) {
44-
if (response.status === 429) {
44+
if (response.status === 404) {
45+
Logger.debug('[api:jupiter-price] Token not found', { mintAddress });
46+
} else if (response.status === 429) {
4547
Logger.warn('Jupiter price API rate limit exceeded', { sentry: true });
4648
} else {
4749
Logger.error(new Error(`Jupiter price API error: ${response.status}`), { sentry: true });

app/api/verification/coingecko/[coinId]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export async function GET(_request: Request, { params: { coinId } }: Params) {
4343
});
4444

4545
if (!response.ok) {
46-
if (response.status === 429) {
46+
if (response.status === 404) {
47+
Logger.debug('[api:coingecko] Coin not found', { coinId });
48+
} else if (response.status === 429) {
4749
Logger.warn('[api:coingecko] Rate limit exceeded', { sentry: true });
4850
} else {
4951
Logger.panic(new Error(`Coingecko API error: ${response.status}`));

app/api/verification/jupiter/[mintAddress]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export async function GET(_request: Request, { params: { mintAddress } }: Params
4242
});
4343

4444
if (!response.ok) {
45-
if (response.status === 429) {
45+
if (response.status === 404) {
46+
Logger.debug('[api:jupiter] Token not found', { mintAddress });
47+
} else if (response.status === 429) {
4648
Logger.warn('[api:jupiter] Rate limit exceeded', { sentry: true });
4749
} else {
4850
Logger.panic(new Error(`Jupiter API error: ${response.status}`));

app/api/verification/rugcheck/[mintAddress]/route.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PublicKey } from '@solana/web3.js';
22
import { NextResponse } from 'next/server';
33
import fetch from 'node-fetch';
4-
import { is, number, type } from 'superstruct';
4+
import { is, literal, number, type } from 'superstruct';
55

66
import { Logger } from '@/app/shared/lib/logger';
77

@@ -11,8 +11,6 @@ const RugCheckResponseSchema = type({
1111
score_normalised: number(),
1212
});
1313

14-
const RUGCHECK_API_KEY = process.env.RUGCHECK_API_KEY;
15-
1614
type Params = {
1715
params: {
1816
mintAddress: string;
@@ -26,7 +24,9 @@ export async function GET(_request: Request, { params: { mintAddress } }: Params
2624
return NextResponse.json({ error: 'Invalid mint address' }, { status: 400 });
2725
}
2826

29-
if (!RUGCHECK_API_KEY) {
27+
const apiKey = process.env.RUGCHECK_API_KEY;
28+
29+
if (!apiKey) {
3030
return NextResponse.json(
3131
{ error: 'Rugcheck API is misconfigured' },
3232
{ headers: NO_STORE_HEADERS, status: 500 },
@@ -37,12 +37,18 @@ export async function GET(_request: Request, { params: { mintAddress } }: Params
3737
const response = await fetch(`https://premium.rugcheck.xyz/v1/tokens/${mintAddress}/report`, {
3838
headers: {
3939
'Content-Type': 'application/json',
40-
'x-api-key': RUGCHECK_API_KEY,
40+
'x-api-key': apiKey,
4141
},
4242
});
4343

4444
if (!response.ok) {
45-
if (response.status === 429) {
45+
if (response.status === 404 || (await isNotFoundResponse(response))) {
46+
Logger.debug('[api:rugcheck] Token not found', { mintAddress });
47+
return NextResponse.json(
48+
{ error: 'Failed to fetch rugcheck data' },
49+
{ headers: NO_STORE_HEADERS, status: 404 },
50+
);
51+
} else if (response.status === 429) {
4652
Logger.warn('[api:rugcheck] Rate limit exceeded', { sentry: true });
4753
} else {
4854
Logger.panic(new Error(`Rugcheck API error: ${response.status}`));
@@ -71,3 +77,18 @@ export async function GET(_request: Request, { params: { mintAddress } }: Params
7177
);
7278
}
7379
}
80+
81+
const RugCheckNotFoundSchema = type({
82+
error: literal('not found'),
83+
});
84+
85+
// Rugcheck returns 400 {"error":"not found"} for unrecognized tokens instead of 404
86+
async function isNotFoundResponse(response: import('node-fetch').Response): Promise<boolean> {
87+
if (response.status !== 400) return false;
88+
try {
89+
const body = await response.json();
90+
return is(body, RugCheckNotFoundSchema);
91+
} catch {
92+
return false;
93+
}
94+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import fetch from 'node-fetch';
2+
import { vi } from 'vitest';
3+
4+
import { Logger } from '@/app/shared/lib/logger';
5+
6+
import { GET } from '../[mintAddress]/route';
7+
8+
const VALID_MINT = 'B61SyRxF2b8JwSLZHgEUF6rtn6NUikkrK1EMEgP6nhXW';
9+
10+
vi.mock('node-fetch', async () => {
11+
const actual = await vi.importActual('node-fetch');
12+
return {
13+
...actual,
14+
default: vi.fn(),
15+
};
16+
});
17+
18+
const fetchMock = vi.mocked(fetch);
19+
20+
describe('Rugcheck API Route', () => {
21+
const originalEnv = process.env;
22+
23+
beforeEach(() => {
24+
process.env = { ...originalEnv, RUGCHECK_API_KEY: 'test-key' };
25+
});
26+
27+
afterEach(() => {
28+
process.env = originalEnv;
29+
vi.clearAllMocks();
30+
});
31+
32+
it('should return 400 for an invalid mint address', async () => {
33+
const response = await callRoute('not-a-valid-key');
34+
expect(response.status).toBe(400);
35+
expect(await response.json()).toEqual({ error: 'Invalid mint address' });
36+
});
37+
38+
it('should return 500 when API key is missing', async () => {
39+
delete process.env.RUGCHECK_API_KEY;
40+
const response = await callRoute(VALID_MINT);
41+
expect(response.status).toBe(500);
42+
expect(await response.json()).toEqual({ error: 'Rugcheck API is misconfigured' });
43+
});
44+
45+
it('should return 404 when rugcheck responds with 400 (not found)', async () => {
46+
mockFetchResponse(400, { error: 'not found' });
47+
const response = await callRoute(VALID_MINT);
48+
expect(response.status).toBe(404);
49+
expect(await response.json()).toEqual({ error: 'Failed to fetch rugcheck data' });
50+
});
51+
52+
it('should return 400 and report to sentry when rugcheck responds with 400 and unexpected body', async () => {
53+
mockFetchResponse(400, { error: 'bad request' });
54+
const response = await callRoute(VALID_MINT);
55+
expect(response.status).toBe(400);
56+
expect(Logger.panic).toHaveBeenCalled();
57+
});
58+
59+
it('should return 404 when rugcheck responds with 404', async () => {
60+
mockFetchResponse(404);
61+
const response = await callRoute(VALID_MINT);
62+
expect(response.status).toBe(404);
63+
expect(await response.json()).toEqual({ error: 'Failed to fetch rugcheck data' });
64+
});
65+
66+
it('should return 429 and log to sentry when rate limited', async () => {
67+
mockFetchResponse(429);
68+
const response = await callRoute(VALID_MINT);
69+
expect(response.status).toBe(429);
70+
expect(Logger.warn).toHaveBeenCalledWith('[api:rugcheck] Rate limit exceeded', { sentry: true });
71+
});
72+
73+
it('should return 502 when response schema is invalid', async () => {
74+
mockFetchResponse(200, { unexpected: 'shape' });
75+
const response = await callRoute(VALID_MINT);
76+
expect(response.status).toBe(502);
77+
expect(await response.json()).toEqual({ error: 'Invalid response from rugcheck API' });
78+
});
79+
80+
it('should return score on success', async () => {
81+
mockFetchResponse(200, { score_normalised: 85 });
82+
const response = await callRoute(VALID_MINT);
83+
expect(response.status).toBe(200);
84+
expect(await response.json()).toEqual({ score: 85 });
85+
});
86+
87+
it('should return 500 when fetch throws', async () => {
88+
fetchMock.mockRejectedValueOnce(new Error('Network error'));
89+
const response = await callRoute(VALID_MINT);
90+
expect(response.status).toBe(500);
91+
expect(await response.json()).toEqual({ error: 'Failed to fetch rugcheck data' });
92+
});
93+
});
94+
95+
function mockFetchResponse(status: number, body: Record<string, unknown> = {}) {
96+
const ok = status >= 200 && status < 300;
97+
fetchMock.mockResolvedValueOnce({
98+
json: async () => body,
99+
ok,
100+
status,
101+
} as Awaited<ReturnType<typeof fetch>>);
102+
}
103+
104+
function callRoute(mintAddress: string) {
105+
const request = new Request(`http://localhost:3000/api/verification/rugcheck/${mintAddress}`);
106+
return GET(request, { params: { mintAddress } });
107+
}

0 commit comments

Comments
 (0)