Skip to content

Commit a5fee0f

Browse files
committed
fix(api): handle MongoDB duplicate key race conditions
1 parent d6f2ce9 commit a5fee0f

2 files changed

Lines changed: 78 additions & 9 deletions

File tree

app/api/track-user/route.test.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ vi.mock('@/lib/mongodb', () => ({
1010

1111
vi.mock('@/models/User', () => ({
1212
User: {
13-
findOneAndUpdate: vi.fn(),
13+
updateOne: vi.fn(),
1414
},
1515
}));
1616

@@ -98,10 +98,10 @@ describe('POST /api/track-user', () => {
9898
expect(dbConnect).toHaveBeenCalled();
9999

100100
// Trims and lowercases
101-
expect(User.findOneAndUpdate).toHaveBeenCalledWith(
101+
expect(User.updateOne).toHaveBeenCalledWith(
102102
{ username: 'octocat' },
103103
{ $setOnInsert: { username: 'octocat' } },
104-
{ upsert: true, new: true }
104+
{ upsert: true }
105105
);
106106

107107
expect(response.status).toBe(200);
@@ -123,5 +123,50 @@ describe('POST /api/track-user', () => {
123123

124124
consoleErrorSpy.mockRestore();
125125
});
126+
127+
it('gracefully handles concurrent duplicate key (code 11000) race conditions', async () => {
128+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
129+
130+
const mongoError = new Error('E11000 duplicate key error collection: username') as Error & {
131+
code?: number;
132+
keyPattern?: Record<string, number>;
133+
};
134+
mongoError.code = 11000;
135+
mongoError.keyPattern = { username: 1 };
136+
vi.mocked(User.updateOne).mockRejectedValueOnce(mongoError);
137+
138+
const response = await POST(makeRequest({ username: 'octocat' }));
139+
140+
expect(response.status).toBe(200);
141+
const data = await response.json();
142+
expect(data.success).toBe(true);
143+
expect(consoleErrorSpy).not.toHaveBeenCalled();
144+
145+
consoleErrorSpy.mockRestore();
146+
});
147+
148+
it('rethrows duplicate key (code 11000) error if it is not related to username', async () => {
149+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
150+
151+
const mongoError = new Error(
152+
'E11000 duplicate key error collection: other_field'
153+
) as Error & {
154+
code?: number;
155+
keyPattern?: Record<string, number>;
156+
};
157+
mongoError.code = 11000;
158+
mongoError.keyPattern = { other_field: 1 };
159+
vi.mocked(User.updateOne).mockRejectedValueOnce(mongoError);
160+
161+
const response = await POST(makeRequest({ username: 'octocat' }));
162+
163+
expect(response.status).toBe(500);
164+
const data = await response.json();
165+
expect(data.success).toBe(false);
166+
expect(data.error).toBe('Internal server error');
167+
expect(consoleErrorSpy).toHaveBeenCalled();
168+
169+
consoleErrorSpy.mockRestore();
170+
});
126171
});
127172
});

app/api/track-user/route.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,36 @@ export async function POST(req: Request) {
3434
// Connect to database
3535
await dbConnect();
3636

37-
// Upsert the user: create if doesn't exist, do nothing if exists
38-
await User.findOneAndUpdate(
39-
{ username: trimmedUsername },
40-
{ $setOnInsert: { username: trimmedUsername } },
41-
{ upsert: true, new: true }
42-
);
37+
try {
38+
// Upsert the user: create if doesn't exist, do nothing if exists
39+
await User.updateOne(
40+
{ username: trimmedUsername },
41+
{ $setOnInsert: { username: trimmedUsername } },
42+
{ upsert: true }
43+
);
44+
} catch (upsertError) {
45+
// Gracefully handle MongoDB E11000 duplicate key race conditions under high concurrency.
46+
// Concurrent upserts for the same username can race on the unique index, causing
47+
// MongoDB to throw a duplicate key error (code 11000) for one of the requests.
48+
// We can safely treat this as a successful no-op because another request already created it.
49+
if (
50+
upsertError &&
51+
typeof upsertError === 'object' &&
52+
'code' in upsertError &&
53+
upsertError.code === 11000
54+
) {
55+
const err = upsertError as Record<string, unknown>;
56+
const isUsernameConflict =
57+
(err.keyPattern && typeof err.keyPattern === 'object' && 'username' in err.keyPattern) ||
58+
(err.keyValue && typeof err.keyValue === 'object' && 'username' in err.keyValue) ||
59+
(typeof err.message === 'string' && err.message.includes('username'));
60+
61+
if (isUsernameConflict) {
62+
return NextResponse.json({ success: true });
63+
}
64+
}
65+
throw upsertError;
66+
}
4367

4468
return NextResponse.json({ success: true });
4569
} catch (error) {

0 commit comments

Comments
 (0)