Handling failures
When a retry is safe, and what to do about the reasons that need more than a retry.
Most code only cares about two things: did it work, and can I ask again.
local Ok, Why = Store:Edit(UserId, "GrantItem", { Item = "Sword" }):Wait()
if Ok then
-- it went through, and a retry under the same id would answer true again
elseif Why == Ledger.Reason.Unresolved or Why == Ledger.Reason.Busy then
-- no answer yet, ask again later with the same id
else
-- Refused, Spent, Closed, Backlog, Full, Invalid, Behind:
-- it didn't happen, and asking again with this id won't change that
endReads answer the same way with the value in front:
local State, Why = Store:Peek(UserId):Wait()
if State == nil then
-- Why says which of the failures it was
return
endA key nobody has ever written folds to your Default, so a read never gives you nil on success.
nil always means it failed.
Why a retry is safe
Every op carries an id, and an id applies at most once on a key. A retry that already went through
answers true and moves nothing the second time. That holds for
Once names, transfer ids and transaction ids.
The id has to be the same one. Build it before the call and keep it for the retry:
local OrderId = `order:{Receipt.PurchaseId}`
local Ok, Why = Store:Edit(UserId, "Grant", { ProductId = 123, Once = OrderId }):Wait()
if Why == Ledger.Reason.Unresolved then
-- same OrderId, so the retry costs nothing if the first one applied
Ok = Store:DidApply(UserId, OrderId):Wait() == true
endGenerate a new id on the retry and Ledger has nothing to match it against, so the retry applies a second time.
Unresolved
Ledger doesn't know whether the write applied. Unresolved does not mean no. Treat it as a refusal
and you will eventually refuse something that already went through, which costs you money.
Retry with the same id. See above. Without an id you have no way to ask again safely.
Ask instead of guessing. Store:DidApply(Key, Name) says
whether a Once name applied. Compare it against true, because a failed read gives nil.
In ProcessReceipt, answer NotProcessedYet. Roblox calls you again, and the next call gets a
straight answer. The full pattern is in Once.
For a stuck leg, Store:Resettle(Key) settles it now instead
of waiting for the sweep. true means the key has nothing unfinished on it.
Spent
Nothing was applied. Spent looks like success and it isn't.
For a transfer the money is back with the sender. Hand out the item on a Spent and you have
paid for something that was refunded.
true is the answer that means "already happened". Call Transfer again with an id that delivered,
or Tx again with an id that committed, and you get true with nothing moved.
So on Spent, decide what to do about a name you can't reuse. Read the keys, pick a new id, or tell
the player it didn't go through. See Ids and retries and
The id.
Behind
Retrying is pointless. This server is running an older build than the one that wrote the record, so nothing changes until the deploy finishes.
Never treat Behind as "this player has no data". The record is fine. Writing a fresh profile over
the top would destroy it.
Pass OnLoadFailed if you deploy while people are playing. A
rejoin puts them back on the same old server, so Behind wants a teleport. See
Rolling deploys.
The rest
| Reason | What to do |
|---|---|
Refused | Your own rule said no. Tell the player. |
Busy | Retry in a few seconds. See Stuck legs. |
Closed | The session is gone. Write before Unload, see Shutting down. |
Backlog | Saves aren't going through. Stop writing and look at the datastore, see Autosave. |
Full | Remove something first, see Size. |
Invalid | A bug in the calling code. Ledger warns with the field, see What you can store. |