Survive the limits
The API is free and unauthenticated, so it is bounded. This guide keeps an integration working against those bounds rather than discovering them in production.
Decide what to retry
Section titled “Decide what to retry”| Code | Retry? | What to do |
|---|---|---|
invalid_request |
No | Fix the request. It will fail identically forever. |
invalid_formula |
No | Fix the formula, or show the message to whoever wrote it. |
too_many_dice |
No, not unchanged | Split the request and send the parts. |
payload_too_large |
No, not unchanged | Send fewer rolls per request. |
rate_limited |
Yes, after a pause | Back off, then retry the same request. |
internal_error |
Once | If it persists, the problem is not yours. |
Anything you retry unchanged after a 400 will fail the same way. Only rate_limited and
internal_error are worth sending again as they are.
Back off a rate limit
Section titled “Back off a rate limit”The limit is 60 requests every 60 seconds per address. A 429 means you have spent it.
async function rollWithBackoff(body, attempts = 4) { for (let attempt = 0; attempt < attempts; attempt++) { const response = await fetch('https://api.rollful.dev/v1/roll', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), })
if (response.status !== 429) return response
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000)) }
throw new Error('rate limited')}Retrying sooner than the window suggests is reasonable here: the limit is counted per Cloudflare location rather than globally, so the budget you are waiting on is not the only one that exists. It also means the ceiling you can reach worldwide is higher than the number above, which is why it is a guard against abuse rather than a quota you can plan around. If you need throughput you can depend on, roll locally with the package instead.
Stay inside the dice budget
Section titled “Stay inside the dice budget”One request may roll 1000 dice, counted across every roll in it rather than per roll. A batch of 20 rolls asking for 100 dice each is 2000 dice and is refused before a single die is thrown.
Count before you send:
// Both bounds are on the limits page. Read them from there rather than from memory.function chunk(rolls, { maxDice, maxRolls }) { const batches = [] let batch = [] let dice = 0
for (const roll of rolls) { const cost = diceIn(roll.formula)
if (batch.length && (dice + cost > maxDice || batch.length === maxRolls)) { batches.push(batch) batch = [] dice = 0 }
batch.push(roll) dice += cost }
return batch.length ? [...batches, batch] : batches}diceIn is yours to write — parseFormula will give you the terms if you are in JavaScript,
and a rough count from the digits before each d is enough if you are not. Being
approximate is fine; the API does the exact sum and refuses if you are over.
An advantage formula is charged for every die it throws, not for the one it keeps.
4d20adv costs four.
Keep the body small
Section titled “Keep the body small”A request body may be 8 KB. You will only meet this with generated batches, since 20 hand-written rolls do not come close. If you are generating formulas, cap the batch on bytes as well as on count.
Treat a 500 as broken, not unlucky
Section titled “Treat a 500 as broken, not unlucky”internal_error means the random source rejected a thousand draws in a row. On a working
system that is a one-in-2^1000 event. Retry once; if it happens again, something is wrong
here rather than with your request.