@@ -20,8 +20,10 @@ import { createMockRedis } from '@sim/testing/mocks/redis.mock'
2020import { redisConfigMockFns , resetRedisConfigMock } from '@sim/testing/mocks/redis-config.mock'
2121import { utilsHelpersMock } from '@sim/testing/mocks/utils-helpers.mock'
2222import { afterAll , afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
23+ import { coalesceLocally } from '@/lib/concurrency/singleflight'
2324import { ADMISSION_REJECTION_CODE } from '@/lib/core/admission/rejection'
2425import { ADMISSION_ERROR_CODE } from '@/lib/core/admission/transient-failure'
26+ import { DatabaseReadDeadlineError } from '@/lib/db/read-retry'
2527import type { LoggingSession } from '@/lib/logs/execution/logging-session'
2628
2729const { 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+ } )
0 commit comments