I fixed a boring little backend bug this week that would have been expensive in exactly the way AI features like to be expensive: quietly, under concurrency, while every individual request looked valid.
The app has Claude-backed generation behind a monthly credit allowance. Simple shape:
- authenticate the user
- check their subscription and monthly allowance
- call Claude
- record the generation and token cost
- increment
credits_used
That looks fine until you remember the model call is the slow part.
The old gate read credits_used, saw the user was under the cap, then let the request continue. Only after the multi-second Claude call did it increment the counter.
So a user sitting at 29 / 30 credits could fire 50 parallel requests. All 50 read 29. All 50 passed the gate. All 50 spent tokens. Then the counter landed at 79.
No exotic attack. No clever prompt injection. Just check-then-act around a slow side effect.
The fix was to stop treating the credit check as application logic and make Postgres do the one thing it is very good at: serialize writes to the row.
create or replace function public.reserve_ai_credit(
p_user_id uuid,
p_month text,
p_limit integer
) returns integer language plpgsql security definer as $$
declare
v_used integer;
begin
insert into public.ai_usage (user_id, month, credits_used)
values (p_user_id, p_month, 1)
on conflict (user_id, month) do update
set credits_used = ai_usage.credits_used + 1
where ai_usage.credits_used < p_limit
returning credits_used into v_used;
return v_used;
end;
$$;
That where ai_usage.credits_used < p_limit on the conflict update is the whole trick. Concurrent requests now queue on the same row. The first one increments. The next sees the updated value. Once the cap is reached, returning gives nothing back and the request stops before the model call.
The Edge Function now reserves a credit before calling Claude:
const { data: used, error } = await supabase.rpc("reserve_ai_credit", {
p_user_id: user.id,
p_month: month,
p_limit: allowance,
});
if (used === null || used === undefined) {
return json({ error: "Credit limit reached" }, 429);
}
That changes the failure shape, so the other half of the fix was refunds. If the model call fails, or the structured response violates the schema, the function calls refund_ai_credit. Failed generations should not bill the user, but they also should not leave the gate open until after the expensive work is done.
While I was in there, I removed a duplicate credit gate from one function and made it call the shared runCreditGate path like the others. That cut about 52 lines and made the concurrency fix apply everywhere instead of only to the function I happened to be staring at.
A few adjacent bugs fell out of the same pass:
- uncapped text fields could push huge strings into prompts
-
avgSleepHours.toFixed()could throw when the client sent a string - an unknown Claude model name logged cost as
$0silently - one suggestion endpoint was returning
401for “no subscription”, which signs the user out instead of showing the paywall
None of those needed a grand framework. Just input clamps at the boundary, one shared gate, and loud logs when accounting metadata drifts.
The useful lesson: if an AI feature has a quota, the quota has to be enforced before the model call, in the database, atomically. Anything else is a polite suggestion with a cloud bill attached.
I ran the Supabase Edge Function helper tests after the fix: 62 Deno tests, 0 failed.
Top comments (0)