We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Idempotent Usage Tracking in Elixir: Never Bill a Retry Twice
By Liam Killingback ·
Idempotent Usage Tracking in Elixir: Never Bill a Retry Twice
Every usage-based billing system eventually charges somebody twice for the same thing. Not because the counting code is wrong, but because the work around it retries. An Oban job times out after the model call succeeded and runs again. A client loses its connection and resubmits. A webhook provider delivers the same event three times, as it is documented to do. A deploy kills a process between “did the work” and “wrote that I did it”.
Each of those is harmless on its own. Combined with a counter that only knows how to add, each one is an overcharge, and overcharges are the support tickets that cost you customers.
The fix is idempotent usage tracking: every unit of billable usage carries an identity from your own domain, and recording the same identity twice is a no-op that tells you it was a duplicate. This post shows how to build that in Phoenix, where the identities should come from, the two traps that break it (timestamps and swallowed conflicts), and how to keep it correct when you batch, gate and export.
The examples use Aurora Meter, the MIT usage-metering library for Phoenix, because its durable record/4 path was designed around this problem. The reasoning applies to any metering code you write yourself.
Why a counter cannot be idempotent
The usual first version of metering is an increment:
AuroraMeter.track(org, :api_calls)
# or, hand-rolled:
# UPDATE usage SET count = count + 1 WHERE org_id = $1
This is the right tool for a lot of things. Aurora Meter’s track/4 is an ETS increment with no database round-trip, which is why it is fine on the hot path of every request, and it is exactly what you want for dashboards, soft limits and analytics. (Our post on real-time usage metering with ETS covers that path.)
But an increment has no memory of why it happened. +1 from the first attempt and +1 from the retry are indistinguishable. You cannot deduplicate what you cannot identify, so no amount of care at the call site makes an increment safe to retry. If a number goes on an invoice, it needs a different shape: a fact with an identity.
The rule: identity comes from the work, not the attempt
An idempotency key only works if every retry of the same work produces the same key, and different work never does. That rules out the obvious candidates:
-
Not
Ecto.UUID.generate()at the call site. Each attempt generates a new one, so every retry looks like new usage. - Not a timestamp. Two attempts never share one.
- Not a hash of the quantity. Two genuinely different requests can use the same number of tokens.
Good keys come from something that already exists before the work starts and survives a retry:
| Source of usage | A good identity |
|---|---|
| An API request |
The client’s Idempotency-Key header, or your request id persisted with the request row |
| An Oban job |
A domain id from the job args ("report:#{report_id}"), not the attempt number |
| An AI generation |
The id of the generations row you inserted before calling the model |
| A webhook | The provider’s event id |
| An imported log line | The source system’s line or event id |
Namespace them. In Aurora Meter an event id is unique per tenant across every feature, so "gen:4821" and "export:4821" are different facts while two features both using a bare "4821" would collide.
Setting up the durable path
Aurora Meter gives each feature exactly one reporting source. Buffered features are counted in ETS by track/4. Event-sourced features are the sum of durable, identified facts written by record/4. A feature cannot be both, because a feature that was both would be billed twice.
# config/config.exs
config :aurora_meter,
repo: MyApp.Repo,
pubsub: MyApp.PubSub,
plans: MyApp.Plans,
feature_sources: %{tokens: :events}
defmodule MyApp.Plans do
use AuroraMeter.Plans
plan :scale do
price 4_900
metered :tokens, included: 1_000_000, unit_price: 1
end
end
With :tokens set to :events, calling track/4 on it raises before writing anything. That sounds harsh, and it is deliberate: a forgotten track call left over from the old code would otherwise be a silent second count.
What record/4 tells you
AuroraMeter.record(org, :tokens, 1_420,
id: "gen:#{generation.id}",
occurred_at: generation.finished_at,
dimensions: %{"model" => generation.model}
)
The return value is where idempotency becomes something you can act on:
| Result | Meaning | What to do |
|---|---|---|
{:ok, event, :inserted} |
New fact committed, with its total and its export intent | Carry on |
{:ok, event, :duplicate} |
This id was already recorded with this exact payload | Carry on. This is a retry working as intended |
{:error, {:conflict, existing}} |
This id exists with a different payload | Stop and alert. Something is wrong |
{:error, {:unavailable, reason}} |
It may or may not have committed | Retry with the same id and same payload |
{:error, {:invalid, errors}} |
The request never reached the database | Fix the call |
The important line is :unavailable. A timeout on a write does not tell you whether the write happened. The only safe answer to “maybe” is to repeat the identical call, which either inserts it now or reports the duplicate. Retrying with a fresh id is how double billing gets in.
Note also that there is no fallback from record/4 to track/4. A refused durable write never quietly becomes a buffered one, because then you would believe a fact was recorded that has no identity and no way to be deduplicated.
Trap one: the timestamp that changes on retry
Here is the bug that catches almost everyone:
# Looks fine. Is not.
AuroraMeter.record(org, :tokens, tokens,
id: "gen:#{generation.id}",
occurred_at: DateTime.utc_now()
)
The payload includes occurred_at to the microsecond. On a retry, DateTime.utc_now() returns a new instant, so the second call carries the same id with a different payload. That is not a duplicate. It is a {:conflict, existing}, and if your code treats any error as “try again later”, it will try again forever.
The fix is to decide the timestamp once and persist it before you record:
defmodule MyApp.AI do
alias MyApp.{Repo, Generation}
def finish_generation(%Generation{} = gen, output, tokens) do
# Persist the facts of the work first, including when it finished.
gen =
gen
|> Generation.finish_changeset(%{
output: output,
tokens: tokens,
finished_at: DateTime.utc_now()
})
|> Repo.update!()
record_usage(gen)
end
# Safe to call any number of times: it reads everything from the row.
def record_usage(%Generation{} = gen) do
AuroraMeter.record(gen.org_id, :tokens, gen.tokens,
id: "gen:#{gen.id}",
occurred_at: gen.finished_at,
dimensions: %{"model" => gen.model}
)
end
end
Now record_usage/1 is a pure function of a stored row. Calling it from a retry, a reconciliation sweep or an IEx session at 2am all produce the same payload, and therefore the same answer.
Recording from an Oban job
Oban retries a job that returns an error or raises, which is exactly what you want here, provided the job reads its payload rather than inventing it:
defmodule MyApp.Workers.RecordGenerationUsage do
use Oban.Worker, queue: :metering, max_attempts: 20
require Logger
alias MyApp.{AI, Repo, Generation}
@impl Oban.Worker
def perform(%Oban.Job{args: %{"generation_id" => id}}) do
gen = Repo.get!(Generation, id)
case AI.record_usage(gen) do
{:ok, _event, _inserted_or_duplicate} ->
:ok
{:error, {:unavailable, reason}} ->
# Unknown outcome: Oban will retry with the same stored payload.
{:error, {:unavailable, reason}}
{:error, {:conflict, existing}} ->
Logger.error("usage conflict for gen:#{id}, stored=#{inspect(existing)}")
{:cancel, :usage_conflict}
{:error, other} ->
{:cancel, other}
end
end
end
Two choices worth copying. A conflict is cancelled, not retried, because retrying cannot fix it and the job would burn every attempt. And it is logged loudly, because a conflict means two different pieces of work claimed the same identity, or a payload was rebuilt differently, and both are bugs you want to see the day they happen.
Also notice that :duplicate is a success. A retry that lands as a duplicate is the whole system working.
Recording inside your own transaction
Often the cleanest place to record usage is in the same transaction that marks the work done, so you can never have one without the other. Aurora Meter supports that: inside a host transaction, record/4 runs on a savepoint, and nothing is published until you say the commit happened.
def complete_export(export, rows) do
result =
Repo.transaction(fn ->
export = export |> Export.done_changeset(%{rows: rows}) |> Repo.update!()
{:ok, event, _} =
AuroraMeter.record(export.org_id, :exported_rows, rows,
id: "export:#{export.id}",
occurred_at: export.completed_at
)
{export, event}
end)
with {:ok, {export, event}} <- result do
AuroraMeter.Events.after_commit(event)
{:ok, export}
end
end
If anything in the transaction raises, the rollback removes your row, the usage fact, its total and its export intent together. If it commits, after_commit/1 updates the in-memory totals and broadcasts the change, so a LiveView showing the customer’s usage moves straight away.
Batches and imports: record_batch/2
When usage arrives in bulk (draining a queue, importing a day of gateway logs, backfilling after an outage), recording one fact per round trip is slow. record_batch/2 writes many facts in one transaction, with the same identity rules:
def import_gateway_lines(lines) do
lines
|> Enum.map(fn line ->
%{
tenant: line.org_id,
feature: :api_calls,
quantity: 1,
id: "gw:#{line.request_id}",
occurred_at: line.timestamp
}
end)
|> Enum.chunk_every(500)
|> Enum.reduce_while(:ok, fn chunk, :ok ->
case AuroraMeter.record_batch(chunk) do
{:ok, _results} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
Things the batch does for you, and one it does not:
-
Each chunk is all or nothing. One conflicting element rolls the whole chunk back with
{:error, {:conflict, index, existing}}, so you never half-import a chunk. -
Re-importing the same file is safe. Lines already recorded come back as
:duplicatein their position. - Two identical elements in one chunk collapse into one fact. Two elements with the same id and different payloads are refused before any database call.
- The limit is 500 elements and 1 MiB of encoded dimensions and metadata per call, which is why the example chunks at 500.
- It does not decide what counts as the same request. Aurora Meter stores no customer content, so if your source has no stable id, you have to make one, and that design decision is yours.
Gating before you do the work
Idempotent recording answers “how much did they use”. It does not answer “should we let them”. For a hard limit you still want admission control before the expensive call. with_quota/4 works on an event-sourced feature too: it reserves capacity, runs your function, and releases the reservation on success, because the recorded event is the charge.
AuroraMeter.with_quota(org, :tokens, estimated_tokens, fn ->
{:ok, output, tokens} = MyApp.LLM.complete(prompt)
{:ok, _event, _} = MyApp.AI.finish_generation(gen, output, tokens)
output
end)
# => {:ok, output} | {:error, :limit_exceeded} | {:error, :not_entitled}
For the full story of quotas and when to reach for them instead of a rate limit, see rate limits vs quotas in Phoenix.
Testing that a retry is not a charge
The property you care about is easy to state and easy to test: recording the same work twice changes nothing the second time.
test "a retried generation is recorded once" do
gen = insert(:generation, tokens: 1_420, finished_at: ~U[2026-11-03 10:00:00.000000Z])
assert {:ok, _, :inserted} = MyApp.AI.record_usage(gen)
assert {:ok, _, :duplicate} = MyApp.AI.record_usage(gen)
assert AuroraMeter.usage(gen.org_id, :tokens) == 1_420
end
test "a changed payload under the same id is a conflict" do
gen = insert(:generation, tokens: 1_420, finished_at: ~U[2026-11-03 10:00:00.000000Z])
{:ok, _, :inserted} = MyApp.AI.record_usage(gen)
assert {:error, {:conflict, _}} = MyApp.AI.record_usage(%{gen | tokens: 99})
end
Add one for your worker: given an :unavailable result it returns an error (so Oban retries), and given a conflict it cancels. Those two branches are where a well-meaning refactor usually reintroduces double billing.
Getting it to Stripe without double billing there either
Recording a fact once in your database is half the job. The other half is sending it to the payment provider once. Stripe’s meter events carry their own identifier for deduplication, but only within a window, and a send whose response never arrived is the same “maybe” problem one layer further out.
Aurora Meter’s core writes an export intent in the same transaction as the fact, so nothing is ever recorded without a matching intent. Delivering those intents is what Aurora Meter Pro does: each send carries an identifier Stripe deduplicates on, an uncertain send is held rather than blindly replayed past the provider’s deduplication horizon, and anything too old to resend safely is surfaced for a person to decide instead of being replayed. If a fact later needs to come down, corrections flow through the same path; our post on refunding usage-based billing in Elixir covers that side.
Pro is licensed per app from $99/mo, with a 14-day free trial claimed once per customer. See pricing.
Honest limits
-
Buffered tracking is still not idempotent.
track/4is the right tool for counters you do not invoice. Do not use it for anything that goes on a bill. -
The legacy
track(..., durable: true)option is not the same thing. It writes an event row with no caller identity, so two identical calls write two rows. It is deprecated and will go in 2.0. Userecord/4. - The source is a deploy-time choice. Moving a feature from buffered to events needs a planned cutover at a period boundary, not a config flip mid-month.
- You still own the identity. The library can guarantee one fact per id. It cannot know that two requests with the same tokens and timestamp were really one request.
Or skip the plumbing
The free core, including record/4, record_batch/2, corrections and the transactional export intent, is MIT on GitHub and on Hex. Start with the Aurora Meter docs. If you want a whole metered SaaS rather than a library, phx_saas is the Phoenix starter with auth, Stripe and email already wired, ready for Aurora Meter to drop in.
Summary
Idempotent usage tracking in Elixir comes down to three decisions. Give every billable unit of work an identity that exists before the work and survives a retry. Persist the whole payload, timestamp included, so a retry sends exactly the same thing. Treat the answers precisely: a duplicate is success, unavailable means retry with the same payload, and a conflict is a bug to surface, not a failure to retry. Do those, and the retries your system will inevitably perform stop being overcharges and start being no-ops.