Errors and retries
What a call can throw, what the SDK retries automatically, and what it leaves to you.
The errors
| Error | When | Fields |
|---|---|---|
CeejayError | CEEJAY responded with an error: any status outside 200 to 299 | status, message, code, retryAfter, requestId, body |
CeejayConnectionError | No response was received: the network failed, or the call took longer than timeoutMs | message, timedOut, cause |
TypeError | An argument is invalid, such as a number for a member ID. It is thrown before anything is sent | message |
A fourth, CeejaySignatureError, belongs to the two signature checks. It is on
Verifying signatures.
import { CeejayConnectionError, CeejayError } from '@communitystudios/ceejay'
try {
await ceejay.economy.deduct({ discordId: '409876543210987654', amount: 100 })
} catch (error) {
if (error instanceof CeejayError) {
// CEEJAY answered, and refused or failed.
console.log(error.status, error.code, error.message, error.requestId)
console.log(error.body) // the whole answer, for the extra fields some refusals carry
} else if (error instanceof CeejayConnectionError) {
// No answer came back. A write may or may not have been applied.
console.log(error.message, error.timedOut)
} else {
throw error
}
}The fields of a CeejayError:
Prop
Type
Check the code, not the status or the message. The message text can change. A code does not: the list of codes is closed for version 1 of the API, and a code is never renamed or removed. Two errors of the same kind can carry different statuses, for a reason given under Statuses, so the status alone does not identify which rule failed.
Your key is never part of an error, whatever a server puts in its response. The SDK
redacts your key from the response before it reads the message, the code or the body. The
cause of a CeejayConnectionError is a plain copy of what the fetch threw: its name,
its message and its code, and no other properties.
Errors every request can raise
These are the same for every request. The first six come before a request reaches its own checks, in this order.
| Status | code | Cause |
|---|---|---|
401 | missing_api_key | The request carried no key |
401 | invalid_api_key | The key is not in the right format, or is not known |
401 | api_key_revoked | The key was revoked |
401 | api_key_expired | The key is past its expiry date |
429 | rate_limited | The key has used its requests for this minute. retryAfter says how many seconds to wait |
403 | insufficient_scope | The key does not have the permission the request needs. error.body lists required and granted |
405 | method_not_allowed | The request was sent with the wrong method. The SDK always sends the right one |
400 | bad_request | An argument was rejected. error.body.field names the field, when one field is at fault |
500 | internal_error | Something failed on CEEJAY's side |
A key that is missing, or does not start with ceejay_sk_, never leaves your server: the
SDK throws a TypeError when the client is created.
The rate limit is checked before the permission. A key that is over its limit gets
429 from every request, also from one it has no permission for, and that request still
counts. So a 429 does not tell you the key is permitted to make the call.
Two responses are generated before CEEJAY's own code runs, and carry no code and no
requestId: a body larger than 1 MB (413, with the text Body exceeded 1mb limit), and
a body that is not JSON. The SDK always sends JSON, so the second cannot happen through it.
The SDK gives the first the code body_too_large, as it gives it to the 413 of
ceejay.players.setFields and ceejay.players.logEvents for a body over 512 KB.
The rate limit
A key has a rate limit in requests per minute: 60 unless the key was given a different limit, and 600 at
most. ceejay.community.get reports it as key.rateLimitPerMinute.
Every response to a request made with a known key carries three headers, on success and on error:
| Header | What it is |
|---|---|
X-RateLimit-Limit | The key's limit, in requests per minute |
X-RateLimit-Remaining | How many requests are left in the current minute. Never below 0 |
X-RateLimit-Reset | Whole seconds until the current minute ends, from 1 to 60 |
A 401 carries none of them: no key is known, so there is no limit to report.
The SDK does not expose these three headers to your code. It reads what it needs from a
429: the wait, which is on the error as retryAfter. To see how much of its rate limit a
key has left, call ceejay.community.get for its limit and count your own requests.
Statuses
| Status | What it means |
|---|---|
400 | An argument was rejected |
401 | No key, or a key that is not accepted |
403 | Not allowed |
404 | The resource does not exist, or this instance does not support the feature yet |
409 | The request conflicts with a business rule |
429 | Too many requests |
503 | A service CEEJAY depends on, such as Discord, was unavailable. Nothing was changed |
Four errors return 400 for backward compatibility, although the status for a business
rule is 409: quest_not_active, quest_already_completed and quest_limit_reached on
quests.complete, and insufficient_balance on economy.deduct. The other rules of a
quest return 409. This is why you check the code.
Three more errors also keep their original status for backward compatibility: bot_unavailable and
bot_timeout, with 502 or 504, when CEEJAY's bot could not finish a request, and
discord_error, which passes Discord's own status through.
Codes
Every code of version 1 of the API. The page for each area lists the codes its own functions
return, and what else is in error.body.
code | Status | Raised by |
|---|---|---|
missing_api_key, invalid_api_key, api_key_revoked, api_key_expired | 401 | Every request |
insufficient_scope | 403 | Every request but community.get |
method_not_allowed | 405 | Every request |
rate_limited | 429 | Every request |
internal_error | 500 | Every request |
bad_request | 400 | Every request that takes an argument |
not_available | 404 | A request whose feature this instance does not support yet. error.body.feature names it, and community.get reports the same names |
member_not_found | 404 | members.get and xp.deduct, for a member CEEJAY has no profile for. The grants, quests.complete, members.link and the two role changes, for an ID that is not a member of your server |
discord_unavailable | 503 | The grants, quests.complete, quests.reportTask and members.link, when CEEJAY could not query Discord about the member. Nothing was changed |
idempotency_key_reused | 409 | Every write that takes a key, for a key that was already used for a different request |
idempotency_in_progress | 409 | Every write that takes a key, while the first request with the key is still running. retryAfter is 5 |
idempotency_outcome_unknown | 409 | messages.send, events.emit and quests.reportTask, when CEEJAY cannot tell whether an earlier attempt with the key was applied |
idempotency_unavailable | 503 | Every write that takes a key, when you send one and this instance cannot store keys yet |
unknown_platform | 400 | members.link and quests.reportTask, for a platform that is not known |
linked_by_sign_in, player_already_linked | 409 | members.link |
xp_limit_reached | 409 | The XP grants and quests.complete, for a total that would not fit |
balance_limit_reached | 409 | economy.grant and quests.complete, for a total that would not fit |
insufficient_balance | 400 | economy.deduct |
program_not_found, member_not_enrolled | 404 | xp.program.grant and xp.program.deduct |
program_not_active, member_status_not_allowed | 409 | The same two |
quest_not_found | 404 | quests.complete |
quest_not_active, quest_already_completed, quest_limit_reached | 400 | quests.complete, for three of the quest's rules |
quest_not_started, quest_ended, quest_requires_role, quest_requires_level, quest_requires_season_xp, quest_requires_program, quest_full, quest_cooling_down, quest_requires_quest, quest_daily_limit_reached | 409 | quests.complete, for the other rules of the quest |
member_roles_unavailable | 503 | quests.complete, when the quest requires a role and the member's roles could not be read |
role_not_found | 404 | roles.grant and roles.revoke |
role_not_allowed, bot_missing_permission | 403 | The same two. messages.send returns the second as well |
roles_unavailable, dashboard_roles_unavailable | 503 | roles.list, roles.grant and roles.revoke, when a list they need could not be read |
channel_not_found | 404 | messages.send |
channels_unavailable | 503 | channels.list |
discord_rate_limited | 429 | The requests that go through Discord, when Discord rate-limits the bot |
discord_not_configured | 500 | The same requests, on an instance that cannot reach Discord yet |
discord_error | Discord's own | The same requests, for any other error from Discord. error.body.details contains Discord's response |
purchase_not_found | 404 | shop.completePurchase |
wrong_status | 409 | shop.completePurchase, for a purchase whose status has already changed |
bot_unavailable | 502 | quests.reportTask and shop.completePurchase, when CEEJAY's bot could not be reached or rejected the request. On quests.reportTask nothing was changed |
bot_timeout | 502 or 504 | The same two, when CEEJAY's bot did not respond in time. On quests.reportTask also when the bot's response was lost, so the task may or may not have been completed |
Idempotency keys
Every write takes one more option, idempotencyKey, but for the three writes of
Connect, which take none: each of those is safe to retry as it is.
The key is a string you choose, 1 to 128 characters, for one request. The SDK sends it in the
body as idempotencyKey.
- When the same request is sent again with its key, nothing is repeated. The
response is the original one, with
duplicate: true. - Each key identifies one request. Two requests for one event need two keys, for
example
match-9:coinsandmatch-9:xp. The same key on another function, or with other values, is rejected withidempotency_key_reused. - A key is stored only when its request was applied. A rejected request can be sent again with the same key once the cause is fixed.
- A key belongs to the API key that sent it, and is retained for 30 days.
Generate the key from something that identifies the request in your own system, such as an order ID or a match ID. A key generated from the time of the call is new on every attempt, and protects nothing.
The errors an idempotency key can produce:
code | Status | What it means | What to do |
|---|---|---|---|
idempotency_key_reused | 409 | The key was already used for a different request: another function, or other values | Use a new key for a new request |
idempotency_in_progress | 409 | The first request with this key is still running | Nothing. The SDK waits the retryAfter of the response, 5 seconds, and retries |
idempotency_outcome_unknown | 409 | An earlier attempt with this key may or may not have been applied, and CEEJAY did not apply it again | Find out whether it was applied, then send it with a new key if it was not |
idempotency_unavailable | 503 | This instance cannot store keys yet. Nothing was changed | Contact support to complete the update. ceejay.community.get shows it as features.retryKeys.available |
idempotency_outcome_unknown applies to the three writes whose effect is outside CEEJAY
and cannot be reversed or looked up: a posted message, a sent event and a reported quest
task. After an attempt that got no response, CEEJAY retains the key and does not post, send or
report a second time. The other writes are either applied in full or not at all, or are safe
to apply twice, so a retry of them returns the original response.
What the SDK retries
After a 429, a 5xx or no response, the SDK waits and retries the request. It only
does this when it is safe to retry:
- A read is retried.
- A write sent with
idempotencyKeyis retried. - A write without a key is never retried. CEEJAY would apply it twice.
| Function | Retried by the SDK |
|---|---|
community.get, members.get, xp.getBalance, economy.getBalance, quests.list, roles.list, channels.list, programs.list, shop.listPurchases, connect.getSession, connect.listSessions | Yes |
members.link, xp.grant, xp.deduct, xp.program.grant, xp.program.deduct, economy.grant, economy.deduct, quests.complete, quests.reportTask, roles.grant, roles.revoke, messages.send, events.emit, shop.completePurchase | Only with idempotencyKey |
connect.createPass, connect.createSession, connect.confirmSession | No. They take no key. Retrying one yourself is safe |
One more response is waited for and retried, for a write with a key: a 409 with the
code idempotency_in_progress. It means the first request with that key is still running.
The SDK can receive this when it retries after a dropped connection. Waiting returns the
original response.
These are never retried, whatever the request:
- A status from 400 to 499 other than
429, with the one exception above. The request itself is invalid, and would be rejected again. - A
409with the codeidempotency_outcome_unknownoridempotency_key_reused. Waiting changes neither. - A
503with the codeidempotency_unavailable. This instance cannot store keys yet, and waiting does not change that.
How long it waits
| Setting | Default | What it is |
|---|---|---|
maxRetries | 2 | How many times to retry after the first attempt. From 0 to 10 |
timeoutMs | 30000 | How long to wait for a response to one attempt, in milliseconds. 2147483647 at most, which is about 24 days: a timer cannot run for longer, and a larger number is rejected with a TypeError |
Between two attempts the SDK waits as long as retryAfter in the response specifies. When
the response has no retryAfter, it uses exponential backoff: the first wait is between 250
and 500 milliseconds, and each wait after that is twice as long, up to 8 seconds.
A wait of more than 60 seconds is not waited for. The error is thrown immediately, with
retryAfter set, so your own code can decide.
Set both for every call when you create the client, or for one call in its last argument:
import { Ceejay } from '@communitystudios/ceejay'
const patient = new Ceejay({ apiKey: process.env.CEEJAY_API_KEY, maxRetries: 4, timeoutMs: 10_000 })
// One call that must answer quickly or not at all.
const member = await patient.members.get('409876543210987654', { maxRetries: 0, timeoutMs: 2_000 })
console.log(member.level)A write that got no response
After a CeejayConnectionError, a write may or may not have been applied.
- With
idempotencyKey, the SDK has already retried it with the same key. If it still fails, retry it later with the same key: it is applied once. - Without a key, read before you write again. After a grant of XP that timed out, call
ceejay.xp.getBalanceand compare the total with what you expect.
A request that the rate limit rejected changed nothing, so it is always safe to retry. With a key, the SDK does that for you:
const grant = await ceejay.xp.grant(
{ discordId: '409876543210987654', amount: 250, reason: 'Won match 881' },
{ idempotencyKey: 'match-881:xp', maxRetries: 4 },
)
console.log(grant.xp, grant.duplicate)Discord's own limit
roles.list, roles.grant, roles.revoke, channels.list and messages.send go
through Discord, and one bot serves every community on an instance. When Discord rate-limits the
bot, the call fails with status 429 and the code discord_rate_limited. retryAfter
is Discord's wait in whole seconds, when Discord provided one. This is not your key's own
rate limit: that is rate_limited.
Do not retry these in a tight loop: Discord can block the bot for a while, for every feature.
A member who has only just joined your server can take up to 15 seconds to be found. After
a 404 with the code member_not_found for such a member, wait 15 seconds before you
retry.
Stopping a call
Pass an AbortSignal as signal. When it is aborted the call stops and throws the reason
of the signal, not one of the errors above.
const controller = new AbortController()
setTimeout(() => controller.abort(), 1_500)
const page = await ceejay.quests.list({ limit: 100 }, { signal: controller.signal })
console.log(page.total)When you ask us for help
Send us the requestId of the error, and the time and the function you called. CEEJAY
logs the URL path, method, status code and timestamp of every request made with a key
it accepts.
Every request the SDK sends carries the header
User-Agent: ceejay-sdk-typescript/0.1.0, with the version of the SDK you use.

