DEV Community

Todd Sullivan
Todd Sullivan

Posted on

The Credit Counter Race That Let 50 AI Requests Through

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:

  1. authenticate the user
  2. check their subscription and monthly allowance
  3. call Claude
  4. record the generation and token cost
  5. 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;
$$;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 $0 silently
  • one suggestion endpoint was returning 401 for “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)