Skip to content

Commit 6b2e83b

Browse files
fix(billing): add idempotency to billing (#4157)
* fix(billing): add idempotency to billing * Only release redis lock if billed
1 parent 367415f commit 6b2e83b

File tree

2 files changed

+52
-1
lines changed

2 files changed

+52
-1
lines changed

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

Lines changed: 43 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,8 @@ 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
34+
let usageCommitted = false
3135

3236
try {
3337
logger.info(`[${requestId}] Update cost request started`)
@@ -75,9 +79,30 @@ export async function POST(req: NextRequest) {
7579
)
7680
}
7781

78-
const { userId, cost, model, inputTokens, outputTokens, source } = validation.data
82+
const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey } =
83+
validation.data
7984
const isMcp = source === 'mcp_copilot'
8085

86+
claim = idempotencyKey
87+
? await billingIdempotency.atomicallyClaim('update-cost', idempotencyKey)
88+
: null
89+
90+
if (claim && !claim.claimed) {
91+
logger.warn(`[${requestId}] Duplicate billing update rejected`, {
92+
idempotencyKey,
93+
userId,
94+
source,
95+
})
96+
return NextResponse.json(
97+
{
98+
success: false,
99+
error: 'Duplicate request: idempotency key already processed',
100+
requestId,
101+
},
102+
{ status: 409 }
103+
)
104+
}
105+
81106
logger.info(`[${requestId}] Processing cost update`, {
82107
userId,
83108
cost,
@@ -113,6 +138,7 @@ export async function POST(req: NextRequest) {
113138
],
114139
additionalStats,
115140
})
141+
usageCommitted = true
116142

117143
logger.info(`[${requestId}] Recorded usage`, {
118144
userId,
@@ -149,6 +175,22 @@ export async function POST(req: NextRequest) {
149175
duration,
150176
})
151177

178+
if (claim?.claimed && !usageCommitted) {
179+
await billingIdempotency
180+
.release(claim.normalizedKey, claim.storageMethod)
181+
.catch((releaseErr) => {
182+
logger.warn(`[${requestId}] Failed to release idempotency claim`, {
183+
error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),
184+
normalizedKey: claim?.normalizedKey,
185+
})
186+
})
187+
} else if (claim?.claimed && usageCommitted) {
188+
logger.warn(
189+
`[${requestId}] Error occurred after usage committed; retaining idempotency claim to prevent double-billing`,
190+
{ normalizedKey: claim.normalizedKey }
191+
)
192+
}
193+
152194
return NextResponse.json(
153195
{
154196
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)