Skip to content

Commit 56c2dba

Browse files
fix(billing): add idempotency to billing
1 parent 64cdab2 commit 56c2dba

File tree

2 files changed

+45
-1
lines changed

2 files changed

+45
-1
lines changed

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { recordUsage } from '@/lib/billing/core/usage-log'
66
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
77
import { checkInternalApiKey } from '@/lib/copilot/request/http'
88
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
9+
import { type AtomicClaimResult, billingIdempotency } from '@/lib/core/idempotency/service'
910
import { generateRequestId } from '@/lib/core/utils/request'
1011

1112
const logger = createLogger('BillingUpdateCostAPI')
@@ -19,6 +20,7 @@ const UpdateCostSchema = z.object({
1920
source: z
2021
.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block'])
2122
.default('copilot'),
23+
idempotencyKey: z.string().min(1).optional(),
2224
})
2325

2426
/**
@@ -28,6 +30,7 @@ const UpdateCostSchema = z.object({
2830
export async function POST(req: NextRequest) {
2931
const requestId = generateRequestId()
3032
const startTime = Date.now()
33+
let claim: AtomicClaimResult | null = null
3134

3235
try {
3336
logger.info(`[${requestId}] Update cost request started`)
@@ -75,9 +78,30 @@ export async function POST(req: NextRequest) {
7578
)
7679
}
7780

78-
const { userId, cost, model, inputTokens, outputTokens, source } = validation.data
81+
const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey } =
82+
validation.data
7983
const isMcp = source === 'mcp_copilot'
8084

85+
claim = idempotencyKey
86+
? await billingIdempotency.atomicallyClaim('update-cost', idempotencyKey)
87+
: null
88+
89+
if (claim && !claim.claimed) {
90+
logger.warn(`[${requestId}] Duplicate billing update rejected`, {
91+
idempotencyKey,
92+
userId,
93+
source,
94+
})
95+
return NextResponse.json(
96+
{
97+
success: false,
98+
error: 'Duplicate request: idempotency key already processed',
99+
requestId,
100+
},
101+
{ status: 409 }
102+
)
103+
}
104+
81105
logger.info(`[${requestId}] Processing cost update`, {
82106
userId,
83107
cost,
@@ -149,6 +173,17 @@ export async function POST(req: NextRequest) {
149173
duration,
150174
})
151175

176+
if (claim?.claimed) {
177+
await billingIdempotency
178+
.release(claim.normalizedKey, claim.storageMethod)
179+
.catch((releaseErr) => {
180+
logger.warn(`[${requestId}] Failed to release idempotency claim`, {
181+
error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),
182+
normalizedKey: claim?.normalizedKey,
183+
})
184+
})
185+
}
186+
152187
return NextResponse.json(
153188
{
154189
success: false,

apps/sim/lib/core/idempotency/service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ export class IdempotencyService {
343343
logger.debug(`Stored idempotency result in database: ${normalizedKey}`)
344344
}
345345

346+
async release(normalizedKey: string, storageMethod: 'redis' | 'database'): Promise<void> {
347+
return this.deleteKey(normalizedKey, storageMethod)
348+
}
349+
346350
private async deleteKey(
347351
normalizedKey: string,
348352
storageMethod: 'redis' | 'database'
@@ -482,3 +486,8 @@ export const pollingIdempotency = new IdempotencyService({
482486
ttlSeconds: 60 * 60 * 24 * 3, // 3 days
483487
retryFailures: true,
484488
})
489+
490+
export const billingIdempotency = new IdempotencyService({
491+
namespace: 'billing',
492+
ttlSeconds: 60 * 60, // 1 hour
493+
})

0 commit comments

Comments
 (0)