Skip to content

Commit aaf0f44

Browse files
authored
fix(api): classify opaque Convex server errors as transient (koala73#4507)
* fix(api): treat opaque Convex server errors as transient * Address PR review feedback (koala73#4507) - Widen opaque Convex request-id matching while keeping the message anchored - Add boundary tests for decorated request-id server-error variants * Address follow-up PR feedback (koala73#4507) - Share opaque Convex server-error matcher with Sentry tagging - Add Sentry-context boundary coverage for request-id server errors
1 parent 1aff092 commit aaf0f44

4 files changed

Lines changed: 72 additions & 9 deletions

File tree

api/_convex-error.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ function hasConvexCode(msg, code) {
3535
return new RegExp(`"code":\\s*"${code}"`).test(msg);
3636
}
3737

38+
/**
39+
* Match Convex opaque request-id server-error wrapper. The shape is ambiguous:
40+
* it can represent platform/internal 5xxs or a deterministic function throw
41+
* without structured ConvexError data. For /api/user-prefs, #4504 intentionally
42+
* treats this exact wrapper as transient while Sentry keeps a distinct
43+
* `convex_server_error` bucket for volume-based monitoring.
44+
*
45+
* Keep this helper shared between response classification and Sentry tagging so
46+
* `SERVICE_UNAVAILABLE` responses never drift from the `convex_server_error`
47+
* bucket.
48+
*
49+
* @param {string} msg
50+
* @returns {boolean}
51+
*/
52+
export function isOpaqueConvexServerError(msg) {
53+
return /^\[Request ID:\s*[\w-]+\]\s*Server Error$/i.test(msg);
54+
}
55+
3856
/**
3957
* Extract the named-error `kind` from a Convex client throw. Prefers the
4058
* structured `err.data.kind` (server-side `ConvexError({ kind, ... })`),
@@ -118,6 +136,13 @@ export function extractConvexErrorKind(err, msg) {
118136
// classifier tags these `convex_worker_overloaded` so they stay queryable
119137
// apart from genuine ServiceUnavailable 503s and InternalServerError 500s.
120138
if (hasConvexCode(msg, 'WorkerOverloaded')) return 'SERVICE_UNAVAILABLE';
139+
// Convex generic platform/internal 5xx: when the SDK receives only an
140+
// opaque request-id wrapper (`[Request ID: X] Server Error`) with no
141+
// machine-readable `code` JSON and no structured ConvexError data, treat
142+
// it like the recognized Convex platform 5xx family above. Keep the
143+
// matcher exact-ish so unrelated free-form server errors still fall
144+
// through to the unknown/500 path.
145+
if (isOpaqueConvexServerError(msg)) return 'SERVICE_UNAVAILABLE';
121146
if (msg.includes('CONFLICT')) return 'CONFLICT';
122147
if (msg.includes('BLOB_TOO_LARGE')) return 'BLOB_TOO_LARGE';
123148
if (msg.includes('UNAUTHENTICATED')) return 'UNAUTHENTICATED';

api/user-prefs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { jsonResponse } from './_json-response.js';
1717
// @ts-expect-error — JS module, no declaration file
1818
import { captureSilentError } from './_sentry-edge.js';
1919
// @ts-expect-error — JS module, no declaration file
20-
import { extractConvexErrorKind, readConvexErrorNumber } from './_convex-error.js';
20+
import { extractConvexErrorKind, isOpaqueConvexServerError, readConvexErrorNumber } from './_convex-error.js';
2121
import { ConvexHttpClient } from 'convex/browser';
2222
import { validateBearerToken } from '../server/auth-session';
2323

@@ -390,7 +390,7 @@ export function buildSentryContext(
390390
// bucket so on-call can tell worker-saturation apart from internal-500s
391391
// and genuine 503s when triaging (WORLDMONITOR-PG).
392392
: /"code":\s*"WorkerOverloaded"/.test(msg) ? 'convex_worker_overloaded'
393-
: /\[Request ID:\s*[a-f0-9]+\]\s*Server Error/i.test(msg) ? 'convex_server_error'
393+
: isOpaqueConvexServerError(msg) ? 'convex_server_error'
394394
// Cloudflare edge error (520-527) fronting the Convex deployment — see
395395
// _convex-error.js. Mapped to SERVICE_UNAVAILABLE (503 + Retry-After)
396396
// there; kept as its own Sentry bucket so on-call can tell CDN-layer

tests/user-prefs-convex-error.test.mjs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,13 +252,34 @@ describe('extractConvexErrorKind — Convex client error → kind', () => {
252252
assert.equal(extractConvexErrorKind(err, err.message), 'UNAUTHENTICATED');
253253
});
254254

255-
it('does NOT match a generic "Server Error" message (the bug pre-fix)', () => {
256-
// This is the exact symptom the structured-data fix exists to address:
257-
// Convex's `[Request ID: X] Server Error` wrapper used to bypass every
258-
// catch branch in the edge handler. Confirm the fallback still returns
259-
// null for it (so the caller treats it as a real 500).
260-
const err = new Error('[Request ID: 9fee2a2bfa791253] Server Error');
261-
assert.equal(extractConvexErrorKind(err, err.message), null);
255+
it('maps opaque Convex request-id server errors to SERVICE_UNAVAILABLE', () => {
256+
// Convex generic platform/internal 5xx wrapper carries no JSON `code`
257+
// field, but it has the same transient retry profile as the classified
258+
// platform 5xx shapes above. Map it to SERVICE_UNAVAILABLE so callers
259+
// return 503 + Retry-After instead of a hard 500.
260+
const hits = [
261+
'[Request ID: 9fee2a2bfa791253] Server Error',
262+
'[Request ID: ABCdef_123-456] Server Error',
263+
];
264+
for (const msg of hits) {
265+
const err = new Error(msg);
266+
assert.equal(extractConvexErrorKind(err, err.message), 'SERVICE_UNAVAILABLE');
267+
}
268+
});
269+
270+
it('does NOT match partial or decorated request-id server error variants', () => {
271+
const misses = [
272+
'[Request ID: 9fee2a2bfa791253] Server Error: extra details',
273+
'prefix [Request ID: 9fee2a2bfa791253] Server Error',
274+
'[Request ID: ] Server Error',
275+
];
276+
for (const msg of misses) {
277+
const err = new Error(msg);
278+
assert.equal(
279+
extractConvexErrorKind(err, err.message), null,
280+
`expected null for: ${msg}`,
281+
);
282+
}
262283
});
263284
});
264285

tests/user-prefs-sentry-context.test.mts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ describe('buildSentryContext — errorShapeOverride', () => {
5757
assert.equal(ctx.tags.error_shape, 'convex_server_error');
5858
assert.deepEqual(ctx.fingerprint, ['api/user-prefs', 'POST', 'convex_server_error']);
5959
});
60+
61+
it('shares opaque request-id server-error boundaries with the Convex classifier', () => {
62+
const hit = new Error('[Request ID: ABCdef_123-456] Server Error');
63+
const hitCtx = buildSentryContext(hit, hit.message, baseOpts);
64+
assert.equal(hitCtx.tags.error_shape, 'convex_server_error');
65+
66+
const misses = [
67+
'[Request ID: 9fee2a2bfa791253] Server Error: extra details',
68+
'prefix [Request ID: 9fee2a2bfa791253] Server Error',
69+
'[Request ID: ] Server Error',
70+
];
71+
for (const msg of misses) {
72+
const err = new Error(msg);
73+
const ctx = buildSentryContext(err, err.message, baseOpts);
74+
assert.equal(ctx.tags.error_shape, 'unknown', `expected unknown for: ${msg}`);
75+
}
76+
});
6077
});
6178

6279
describe('buildSentryContext — extraTags', () => {

0 commit comments

Comments
 (0)