Skip to content

Commit 3c3bbaa

Browse files
authored
fix(resume): retry automatic resumes after read deadlines and an unreadable usage ledger (#9046)
* fix(resume): retry automatic resumes after read deadlines and an unreadable usage ledger * fix(resume): keep the stack on the usage-status failure log * fix(resume): log usage-status failures with the logger's redacted stack
1 parent 811b10a commit 3c3bbaa

6 files changed

Lines changed: 101 additions & 8 deletions

File tree

‎apps/sim/lib/billing/calculations/usage-monitor.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { toError } from '@sim/utils/errors'
4+
import { describeError, toError } from '@sim/utils/errors'
55
import { eq } from 'drizzle-orm'
66
import { USAGE_UNAVAILABLE_MESSAGE } from '@/lib/billing/constants'
77
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
@@ -171,14 +171,10 @@ export async function checkUsageStatus(
171171

172172
return buildUsageData({ currentUsage, limit, scope, organizationId })
173173
} catch (error) {
174-
logger.error('Error checking usage status', {
175-
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
176-
userId,
177-
})
178-
179174
logger.error('Cannot determine usage status - blocking execution', {
180175
userId,
181-
error: toError(error).message,
176+
error,
177+
cause: describeError(error),
182178
})
183179

184180
return {

‎apps/sim/lib/concurrency/singleflight.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const DEFAULT_SETTLE_TIMEOUT_MS = 30_000
1313
* fresh producer instead of joining the wedged one.
1414
*/
1515
export class CoalesceSettleTimeoutError extends Error {
16+
readonly code = 'COALESCE_SETTLE_TIMEOUT' as const
17+
1618
constructor(key: string, timeoutMs: number) {
1719
super(`Coalesced producer for "${key}" did not settle within ${timeoutMs}ms`)
1820
this.name = 'CoalesceSettleTimeoutError'

‎apps/sim/lib/core/errors/retryable-infrastructure.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ const RETRYABLE_PG_CLIENT_ERROR_CODES = new Set([
5555
'CONNECTION_DESTROYED',
5656
])
5757

58+
/**
59+
* Deadlines the app enforces on its own reads: a read whose budget ran out before its query
60+
* began (`DatabaseReadDeadlineError`) and a coalesced read that never settled
61+
* (`CoalesceSettleTimeoutError`). Neither read produced an answer or an effect, the same
62+
* class as `ETIMEDOUT` above.
63+
*/
64+
const RETRYABLE_READ_DEADLINE_ERROR_CODES = new Set([
65+
'DATABASE_READ_DEADLINE_EXCEEDED',
66+
'COALESCE_SETTLE_TIMEOUT',
67+
])
68+
5869
function getErrorChain(error: unknown): Array<Error & Record<string, unknown>> {
5970
const chain: Array<Error & Record<string, unknown>> = []
6071
let current: unknown = error
@@ -79,6 +90,7 @@ export function describeRetryableInfrastructureError(
7990
(code && RETRYABLE_NETWORK_ERROR_CODES.has(code)) ||
8091
(code && RETRYABLE_APP_ERROR_CODES.has(code)) ||
8192
(code && RETRYABLE_PG_CLIENT_ERROR_CODES.has(code)) ||
93+
(code && RETRYABLE_READ_DEADLINE_ERROR_CODES.has(code)) ||
8294
(errno && RETRYABLE_NETWORK_ERROR_CODES.has(errno))
8395
) {
8496
return {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const TRANSIENT_READ_CODES = new Set([
2929

3030
/** A read exhausted its budget before its query could begin. */
3131
export class DatabaseReadDeadlineError extends Error {
32+
readonly code = 'DATABASE_READ_DEADLINE_EXCEEDED' as const
33+
3234
constructor() {
3335
super('Database read deadline exceeded')
3436
this.name = 'DatabaseReadDeadlineError'

‎apps/sim/lib/execution/preprocessing.test.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import { createMockRedis } from '@sim/testing/mocks/redis.mock'
2020
import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing/mocks/redis-config.mock'
2121
import { utilsHelpersMock } from '@sim/testing/mocks/utils-helpers.mock'
2222
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
23+
import { coalesceLocally } from '@/lib/concurrency/singleflight'
2324
import { ADMISSION_REJECTION_CODE } from '@/lib/core/admission/rejection'
2425
import { ADMISSION_ERROR_CODE } from '@/lib/core/admission/transient-failure'
26+
import { DatabaseReadDeadlineError } from '@/lib/db/read-retry'
2527
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
2628

2729
const { mockCheckRateLimit } = vi.hoisted(() => ({
@@ -1099,3 +1101,82 @@ describe('preprocessExecution admission rejection codes and blocked-run log thro
10991101
expect(loggingSession.safeCompleteWithError).toHaveBeenCalledOnce()
11001102
})
11011103
})
1104+
1105+
describe('preprocessExecution usage gate retry classification for resumes', () => {
1106+
const resumeAdmission = () =>
1107+
preprocessExecution({
1108+
workflowId: 'workflow-1',
1109+
userId: 'actor-1',
1110+
triggerType: 'manual',
1111+
executionId: 'execution-1',
1112+
requestId: 'request-1',
1113+
checkRateLimit: false,
1114+
checkDeployment: false,
1115+
logPreprocessingErrors: false,
1116+
workspaceId: 'workspace-1',
1117+
billingAttribution: { ...ORGANIZATION_ATTRIBUTION, actorUserId: 'actor-1' },
1118+
executionType: 'async',
1119+
})
1120+
1121+
beforeEach(() => {
1122+
mockGetActivelyBannedUserIds.mockResolvedValue([])
1123+
})
1124+
1125+
it('marks a usage read that outlived its coalesced settle deadline retryable', async () => {
1126+
mockCheckAttributedUsageLimits.mockImplementationOnce(() =>
1127+
coalesceLocally('usage-gate:org-1', () => new Promise<never>(() => {}), 1)
1128+
)
1129+
1130+
const result = await resumeAdmission()
1131+
1132+
expect(result).toMatchObject({
1133+
success: false,
1134+
error: {
1135+
message: 'Unable to determine usage limits. Execution blocked for security.',
1136+
statusCode: 500,
1137+
retryable: true,
1138+
},
1139+
})
1140+
})
1141+
1142+
it('marks a usage read that exhausted its database read budget retryable', async () => {
1143+
mockCheckAttributedUsageLimits.mockRejectedValueOnce(new DatabaseReadDeadlineError())
1144+
1145+
const result = await resumeAdmission()
1146+
1147+
expect(result).toMatchObject({
1148+
success: false,
1149+
error: { statusCode: 500, retryable: true },
1150+
})
1151+
})
1152+
1153+
it('marks an unreadable usage ledger retryable without tagging it as over the limit', async () => {
1154+
mockCheckAttributedUsageLimits.mockResolvedValueOnce({
1155+
isExceeded: true,
1156+
reason: 'usage_unavailable',
1157+
message: 'Usage is temporarily unavailable',
1158+
payerUsage: { currentUsage: 0, limit: 0 },
1159+
})
1160+
1161+
const result = await resumeAdmission()
1162+
1163+
expect(result).toMatchObject({ success: false, error: { statusCode: 402, retryable: true } })
1164+
expect(result.success === false && result.error.code).toBeUndefined()
1165+
})
1166+
1167+
it('keeps a payer genuinely over its limit non-retryable', async () => {
1168+
mockCheckAttributedUsageLimits.mockResolvedValueOnce({
1169+
isExceeded: true,
1170+
message: 'Usage limit exceeded',
1171+
payerUsage: { currentUsage: 12, limit: 10 },
1172+
})
1173+
1174+
const result = await resumeAdmission()
1175+
1176+
expect(result).toMatchObject({
1177+
success: false,
1178+
error: { statusCode: 402, code: ADMISSION_REJECTION_CODE.USAGE_LIMIT_EXCEEDED },
1179+
})
1180+
expect(result.success === false && result.error.retryable).not.toBe(true)
1181+
})
1182+
})

‎apps/sim/lib/execution/preprocessing.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,7 @@ export async function preprocessExecution(
769769
statusCode: 402,
770770
// An unreadable ledger fails closed; that is no verdict on the payer, so senders retry.
771771
...(usageCheck.reason === 'usage_unavailable'
772-
? {}
772+
? { retryable: true }
773773
: { code: ADMISSION_REJECTION_CODE.USAGE_LIMIT_EXCEEDED }),
774774
},
775775
},

0 commit comments

Comments
 (0)