Skip to content

Commit 1737a5e

Browse files
committed
fix(logs): return 503 instead of 500 when a v2 logs read is cancelled
1 parent 2da47b9 commit 1737a5e

4 files changed

Lines changed: 70 additions & 15 deletions

File tree

‎apps/sim/lib/api/server/routes/billing-read.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
1-
import { getPostgresCancellationReason } from '@sim/utils/errors'
21
import {
32
extendInternalErrorPolicy,
43
internalErrorResponse,
54
internalOrchestrationErrorPolicy,
65
} from '@/lib/api/server/routes/internal-json-route'
7-
import { DatabaseReadDeadlineError, isTransientDatabaseReadError } from '@/lib/db/read-retry'
6+
import { isDatabaseReadUnavailable } from '@/lib/db/read-retry'
87

98
/** Presents known temporary read failures as retryable 503s without masking permanent errors. */
109
export const internalBillingReadErrorPolicy = extendInternalErrorPolicy(
1110
internalOrchestrationErrorPolicy,
1211
(error) => {
13-
const cancellationReason = getPostgresCancellationReason(error)
14-
if (
15-
!(error instanceof DatabaseReadDeadlineError) &&
16-
!isTransientDatabaseReadError(error) &&
17-
cancellationReason !== 'statement_timeout' &&
18-
cancellationReason !== 'transaction_timeout'
19-
)
20-
return null
12+
if (!isDatabaseReadUnavailable(error)) return null
2113
return internalErrorResponse(
2214
503,
2315
{ error: 'Billing information is temporarily unavailable. Please try again.' },

‎apps/sim/lib/db/read-retry.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createLogger } from '@sim/logger'
2-
import { getPostgresErrorCode } from '@sim/utils/errors'
2+
import { getPostgresCancellationReason, getPostgresErrorCode } from '@sim/utils/errors'
33
import { sleep } from '@sim/utils/helpers'
44
import { backoffWithJitter } from '@sim/utils/retry'
55

@@ -43,6 +43,19 @@ export function isTransientDatabaseReadError(error: unknown): boolean {
4343
return code !== undefined && TRANSIENT_READ_CODES.has(code)
4444
}
4545

46+
/**
47+
* Whether a failed read is temporary for the caller: a retryable driver error, an exhausted read
48+
* deadline, or a statement or transaction timeout. Surfaces present these as retry-later responses;
49+
* the timeouts stay out of {@link isTransientDatabaseReadError} because re-running a cancelled
50+
* scan in-process only repeats its cost. Narrower than `isRetryableInfrastructureError`, which
51+
* also treats a user-requested cancel as retryable.
52+
*/
53+
export function isDatabaseReadUnavailable(error: unknown): boolean {
54+
if (error instanceof DatabaseReadDeadlineError || isTransientDatabaseReadError(error)) return true
55+
const cancellationReason = getPostgresCancellationReason(error)
56+
return cancellationReason === 'statement_timeout' || cancellationReason === 'transaction_timeout'
57+
}
58+
4659
/**
4760
* Retries independent reads, rebuilding each statement or fresh read-only transaction
4861
* after the previous attempt has fully rolled back. Never retry writes, locking reads,

‎apps/sim/lib/logs/api/route-policies.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,55 @@ import {
33
authInternalDelegationMock,
44
authInternalDelegationMockFns,
55
} from '@sim/testing/mocks/auth-internal-delegation.mock'
6+
import { DrizzleQueryError } from 'drizzle-orm/errors'
67
import { NextRequest } from 'next/server'
78
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
89

910
vi.mock('@/lib/auth/internal-delegation', () => authInternalDelegationMock)
1011
vi.unmock('@/lib/auth/internal')
1112

1213
import { generateInternalDelegationToken } from '@/lib/auth/internal'
13-
import { internalLogsSessionOrExecutorAuth } from '@/lib/logs/api/route-policies'
14+
import {
15+
internalLogsSessionOrExecutorAuth,
16+
v2LogErrorPolicies,
17+
} from '@/lib/logs/api/route-policies'
18+
19+
function failedQuery(code: string, message: string): DrizzleQueryError {
20+
return new DrizzleQueryError('select', [], Object.assign(new Error(message), { code }))
21+
}
22+
23+
describe('v2 logs error policies', () => {
24+
const policies = Object.entries(v2LogErrorPolicies)
25+
26+
it.each(policies)(
27+
'%s answers a replica recovery conflict with a retryable 503',
28+
async (_, policy) => {
29+
const response = policy.render(
30+
failedQuery('40001', 'canceling statement due to conflict with recovery')
31+
)
32+
33+
expect(response?.status).toBe(503)
34+
expect(response?.headers.get('Retry-After')).toBeTruthy()
35+
expect(await response?.json()).toMatchObject({ error: { code: 'SERVICE_UNAVAILABLE' } })
36+
}
37+
)
38+
39+
it.each(policies)('%s answers a statement timeout with a retryable 503', (_, policy) => {
40+
const response = policy.render(
41+
failedQuery('57014', 'canceling statement due to statement timeout')
42+
)
43+
44+
expect(response?.status).toBe(503)
45+
expect(response?.headers.get('Retry-After')).toBeTruthy()
46+
})
47+
48+
it.each(policies)('%s leaves a permanent database failure unhandled', (_, policy) => {
49+
expect(policy.render(failedQuery('42P01', 'relation does not exist'))).toBeNull()
50+
expect(
51+
policy.render(failedQuery('57014', 'canceling statement due to user request'))
52+
).toBeNull()
53+
})
54+
})
1455

1556
const mockGetSession = authMockFns.mockGetSession
1657
const mockBindDelegation = authInternalDelegationMockFns.mockBindInternalExecutorDelegation

‎apps/sim/lib/logs/api/route-policies.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
22
createInternalSessionOrExecutorAuth,
33
createV2ResourceConcealmentPolicy,
4-
v2OrchestrationErrorPolicy,
4+
type V2ErrorPolicy,
55
} from '@/lib/api/server/routes'
6+
import { isDatabaseReadUnavailable } from '@/lib/db/read-retry'
67
import { LOGS_DELEGATION_AUDIENCE } from '@/lib/logs/application/authorization'
8+
import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
79

810
const internalLogsSessionOrExecutorAuthBase = createInternalSessionOrExecutorAuth({
911
audience: LOGS_DELEGATION_AUDIENCE,
@@ -27,6 +29,12 @@ export const internalLogsSessionOrExecutorAuth = {
2729
},
2830
}
2931

32+
const renderV2LogError: V2ErrorPolicy['render'] = (error) => {
33+
if (isDatabaseReadUnavailable(error))
34+
return v2Error('SERVICE_UNAVAILABLE', 'Logs are temporarily unavailable. Please try again.')
35+
return v2CaughtOrchestrationError(error)
36+
}
37+
3038
/**
3139
* `GET /logs` and `GET /billing/logs` both take a caller-named `workspaceId` and
3240
* answer differently when the caller cannot reach it: this list projects the
@@ -49,8 +57,9 @@ export const internalLogsSessionOrExecutorAuth = {
4957
* inconsistency for a larger one.
5058
*/
5159
export const v2LogErrorPolicies = {
52-
default: v2OrchestrationErrorPolicy,
60+
default: { render: renderV2LogError },
5361
concealDetailAuthorization: createV2ResourceConcealmentPolicy({
5462
notFoundMessage: 'Log not found',
63+
render: renderV2LogError,
5564
}),
56-
} as const
65+
} as const satisfies Record<string, V2ErrorPolicy>

0 commit comments

Comments
 (0)