Error Handling in SDKs
Every SDK call that reaches the Descope servers can fail. When it does, the SDK reports a Descope error code (for example E061102), a human-readable description, and sometimes a more specific message. You can look up any code on the Common Errors page.
Note
For a list of common error codes and what they mean, see the Common Errors reference.
Checking for Errors by SDK
Each tab shows how one SDK types its errors and which codes it exposes, plus how to:
- Check for a specific error code
- Check for a category, such as unauthorized or rate limited
- Read the code and description off an error
The Node.js SDK does not throw when the server returns an error. Every call returns an SdkResponse<T> — check ok before using data, and read the details off error. The one exception is a response whose body is not valid JSON, which rejects instead of returning.
{
ok: boolean; // false when the request failed
code?: number; // HTTP status code (e.g. 401, 429)
error?: {
errorCode: string; // Descope error code, e.g. "E061102"
errorDescription: string; // human-readable summary
errorMessage?: string; // extra context (not always present)
};
data?: T; // present when ok === true
}Import the client and the typed error-code map (DescopeErrors):
import DescopeClient from '@descope/node-sdk';
const descopeClient = DescopeClient({ projectId: '__ProjectID__' });
const { DescopeErrors } = DescopeClient;
const resp = await descopeClient.otp.verify.email(loginId, code);
if (!resp.ok) {
// Read the code + description off the response
console.log(resp.error?.errorCode); // "E061103"
console.log(resp.error?.errorDescription);
console.log(resp.code); // HTTP status, e.g. 401
// Check for a specific typed error code
if (resp.error?.errorCode === DescopeErrors.tooManyOTPAttempts) {
// too many wrong OTP attempts (E061103)
}
// Check for a category using the HTTP status code
if (resp.code === 429) {
// rate limited
}
} else {
// Success — use resp.data
const { sessionJwt } = resp.data;
}Codes exposed on DescopeErrors:
| Constant | Code |
|---|---|
DescopeErrors.badRequest | E011001 |
DescopeErrors.missingArguments | E011002 |
DescopeErrors.invalidRequest | E011003 |
DescopeErrors.invalidArguments | E011004 |
DescopeErrors.wrongOTPCode | E061102 |
DescopeErrors.tooManyOTPAttempts | E061103 |
DescopeErrors.enchantedLinkPending | E062503 |
DescopeErrors.userNotFound | E062108 |
When the Error Body Cannot Be Parsed
A failing response does not always contain a Descope error body — a gateway may return an HTML error page, or an empty 401. Node.js is the one SDK that does not produce an SdkResponse in that case: parsing the body is what populates error, so a non-JSON body makes the call reject instead of returning. Wrap calls in try/catch in addition to checking ok if you need to survive a malformed gateway response.
The Python SDK raises one of two exceptions, both defined in descope.exceptions and both inherit directly from Exception.
class AuthException(Exception):
status_code: int | None # HTTP status code (e.g. 400, 401, 500)
error_type: str | None # always "server error" for failed requests
error_message: str | None # the raw response body for failed requests
class RateLimitException(Exception):
status_code: str | None # Descope code string, e.g. "E130429" — not the HTTP int used on AuthException
error_type: str | None # always "API rate limit exceeded"
error_description: str | None # from the errorDescription JSON field
error_message: str | None # from the errorMessage JSON field
rate_limit_parameters: dict # {"Retry-After": <seconds as int>}Note
Because RateLimitException does not subclass AuthException, an except AuthException block silently misses rate-limit errors. Always handle RateLimitException in its own clause, and list it first. Also note that RateLimitException.status_code is a Descope code string (for example "E130429"), while AuthException.status_code is an HTTP status int.
Note
For every failure other than 429, the SDK does not parse the JSON error body. error_type is always the literal string "server error" and error_message holds the raw response body. To read a Descope error code, parse that string as JSON yourself.
import json
from descope import (
API_RATE_LIMIT_RETRY_AFTER_HEADER,
AuthException,
DeliveryMethod,
DescopeClient,
RateLimitException,
)
descope_client = DescopeClient(project_id="__ProjectID__")
try:
jwt_response = descope_client.otp.verify_code(
DeliveryMethod.EMAIL, login_id, code
)
session_token = jwt_response["sessionToken"].get("jwt")
# RateLimitException is a separate type and is not caught by AuthException
except RateLimitException as e:
retry_after = e.rate_limit_parameters.get(API_RATE_LIMIT_RETRY_AFTER_HEADER, 0)
# e.status_code holds the Descope code, e.g. "E130429" — not 429
except AuthException as e:
# Read the HTTP status code off the exception
if e.status_code == 401:
# unauthorized
pass
# Check for a specific error code by parsing the raw body
try:
body = json.loads(e.error_message)
except (TypeError, ValueError):
body = {}
if body.get("errorCode") == "E061103":
# too many wrong OTP attempts
passLocal argument validation raises AuthException before any request is sent, with status_code set to 400 and error_type set to "invalid argument" — for example when login_id is empty.
Constants exposed on the descope.exceptions module:
| Constant | Value |
|---|---|
ERROR_TYPE_INVALID_ARGUMENT | invalid argument |
ERROR_TYPE_SERVER_ERROR | server error |
ERROR_TYPE_INVALID_PUBLIC_KEY | invalid public key |
ERROR_TYPE_INVALID_TOKEN | invalid token |
ERROR_TYPE_API_RATE_LIMIT | API rate limit exceeded |
API_RATE_LIMIT_RETRY_AFTER_HEADER | Retry-After |
Only ERROR_TYPE_SERVER_ERROR, ERROR_TYPE_API_RATE_LIMIT, and API_RATE_LIMIT_RETRY_AFTER_HEADER are re-exported from the top-level descope package. There is no typed map of Descope error codes equivalent to the Node.js DescopeErrors.
When the Error Body Cannot Be Parsed
A failing response does not always contain a Descope error body — a gateway may return an HTML error page, or an empty 401. In that case Python gives you no Descope error code; the raw response body is left as the error message. Guard for this when you branch on an error code, since the value will not be a Descope E-prefixed code.
The Go SDK returns a standard error whose concrete type is *descope.Error. Use the package helpers to inspect it.
type Error struct {
Code string // e.g. "E061102"
Description string // human-readable summary
Message string // extra context (not always present)
Info map[string]any // extra metadata (HTTP status, Retry-After)
}result, err := descopeClient.Auth.OTP().VerifyCode(ctx, descope.MethodEmail, loginID, code, w)
// 1. Check for a specific error code
if descope.IsError(err, "E061102") {
// wrong OTP code
}
// 2. Or match a predefined error with the standard errors.Is (requires: import "errors")
if errors.Is(err, descope.ErrInvalidOneTimeCode) {
// wrong OTP code (E061102)
}
// 3. Check for a category (400 / 401 / 403 / 404)
if descope.IsUnauthorizedError(err) {
// unauthorized
}
// 4. Read the code and description directly
if de := descope.AsError(err); de != nil {
log.Printf("failed: [%s] %s", de.Code, de.Description)
}Rate-limit errors carry a Retry-After value in the Info map:
if de := descope.AsError(err, descope.ErrRateLimitExceeded.Code); de != nil {
retryAfter := de.Info[descope.ErrorInfoKeys.RateLimitExceededRetryAfter] // seconds
}Predefined error variables (each is a *descope.Error with a fixed Code):
| Variable | Code |
|---|---|
descope.ErrBadRequest | E011001 |
descope.ErrMissingArguments | E011002 |
descope.ErrValidationFailure | E011003 |
descope.ErrInvalidArguments | E011004 |
descope.ErrInvalidOneTimeCode | E061102 |
descope.ErrUserAlreadyExists | E062107 |
descope.ErrEnchantedLinkUnauthorized | E062503 |
descope.ErrPasswordExpired | E062909 |
descope.ErrTokenExpiredByLoggedOut | E064001 |
descope.ErrNOTPUnauthorized | E066103 |
descope.ErrManagementUserNotFound | E112102 |
descope.ErrRateLimitExceeded | E130429 |
Client-side errors (SDK/config, not from the server) use G-prefixed codes, such as descope.ErrMissingProjectID (G010001), descope.ErrInvalidToken (G030002), and descope.ErrRefreshToken (G030003).
When the Error Body Cannot Be Parsed
A failing response does not always contain a Descope error body — a gateway may return an HTML error page, or an empty 401. Go returns descope.ErrInvalidResponse (G020002), with the HTTP status available in the Info map. Guard for this when you branch on an error code, since the value will not be a Descope E-prefixed code.
The Java SDK throws exceptions that all extend the abstract DescopeException, in the com.descope.exception package. Every one of them is unchecked — they extend RuntimeException, so the throws DescopeException on the service interfaces does not force you to catch anything.
| Exception | Raised for |
|---|---|
ServerCommonException | API errors and argument validation failures |
RateLimitExceededException | HTTP 429, or a body carrying E130429 |
ClientSetupException | Client configuration, such as a missing or malformed project ID |
ClientFunctionalException | Local JWT/token validation failures |
getCode() returns the Descope error code from the response body, and getMessage() resolves the first of errorMessage, message, or errorDescription that is present. ServerCommonException.getServerResponse() additionally gives you the raw response body.
import com.descope.client.Config;
import com.descope.client.DescopeClient;
import com.descope.enums.DeliveryMethod;
import com.descope.exception.ClientFunctionalException;
import com.descope.exception.DescopeException;
import com.descope.exception.ErrorCode;
import com.descope.exception.RateLimitExceededException;
import com.descope.exception.ServerCommonException;
import com.descope.model.auth.AuthenticationInfo;
import com.descope.sdk.auth.OTPService;
DescopeClient descopeClient = new DescopeClient(
Config.builder().projectId("__ProjectID__").build());
OTPService otps = descopeClient.getAuthenticationServices().getOTPService();
try {
AuthenticationInfo info = otps.verifyCode(DeliveryMethod.EMAIL, loginId, code);
}
// 1. Rate limits carry the number of seconds to wait
catch (RateLimitExceededException e) {
long waitSeconds = e.getRetryAfterSeconds();
}
// 2. Local token validation failed, e.g. code "G030002"
catch (ClientFunctionalException e) {
System.err.printf("%s - %s%n", e.getCode(), e.getMessage());
}
// 3. API and argument errors
catch (ServerCommonException e) {
// Check for a specific error code
if (ErrorCode.INVALID_ARGUMENT.equals(e.getCode())) {
// invalid argument (E011004)
}
if ("E061103".equals(e.getCode())) {
// too many wrong OTP attempts
}
String rawBody = e.getServerResponse();
}
// 4. Or catch the base type and read code + message off any of them
catch (DescopeException e) {
System.err.printf("%s - %s%n", e.getCode(), e.getMessage());
}When the Error Body Cannot Be Parsed
A failing response does not always contain a Descope error body — a gateway may return an HTML error page, or an empty 401. Java falls back to the bare HTTP status as a string, so getCode() returns values like "400" or "401". Guard for this when you branch on an error code, since the value will not be a Descope E-prefixed code.
Codes exposed on the ErrorCode class:
| Constant | Code |
|---|---|
ErrorCode.INTERNAL_SERVER_ERROR | 500 |
ErrorCode.INVALID_ARGUMENT | E011004 |
ErrorCode.ERR_MISSING_ARGUMENTS | E011002 |
ErrorCode.RATE_LIMIT_EXCEEDED | E130429 |
ErrorCode.MISSING_PROJECT_ID | G010001 |
ErrorCode.INVALID_PROJECT_ID | G010002 |
ErrorCode.INVALID_TOKEN | G030002 |
ErrorCode.ERR_REFRESH_TOKEN | G030003 |
ErrorCode.INVALID_SIGNING_KEY | J010001 |
Any other code, such as E061103, arrives as a plain string from the server and has no matching constant — compare it as a string literal.
The Ruby SDK raises two families of errors, both defined in lib/descope/exception.rb. Which family you get tells you where the failure happened, and they do not share a common parent below Descope::Exception.
Descope::Exception # base class, exposes #error_data
├── Descope::AuthException # raised by local validation, before any request
├── Descope::ArgumentException # raised by local validation, before any request
└── Descope::HTTPError # raised from an HTTP response; adds #http_code and #headers
├── Descope::BadRequest # 400
├── Descope::Unauthorized # 401
├── Descope::AccessDenied # 403
├── Descope::NotFound # 404
├── Descope::MethodNotAllowed # 405
├── Descope::RateLimitException # 429
├── Descope::ServerError # 500
├── Descope::Unsupported # any other status
└── Descope::RequestTimeout # the request timed outAny status not listed above — 502 and 503, for example — is raised as Descope::Unsupported.
Note
A failed verification raises a Descope::HTTPError subclass, not Descope::AuthException. Rescuing only Descope::AuthException catches local validation errors while letting real API failures escape. Rescue Descope::Exception to cover both.
Note
On failure the SDK puts the raw response body into the error message without parsing it, and defines no error-code constants. To read a Descope error code, parse e.message as JSON yourself.
require 'descope'
require 'json'
descope_client = Descope::Client.new(project_id: '__ProjectID__')
begin
jwt_response = descope_client.otp_verify_code(
method: Descope::Mixins::Common::DeliveryMethod::EMAIL,
login_id: 'user@example.com',
code: '123456'
)
# 1. Check for a category by rescuing the matching class
rescue Descope::RateLimitException => e
# rate limited (429); the SDK already retried this a few times
puts "Rate limited, headers: #{e.headers}"
rescue Descope::HTTPError => e
# 2. Read the HTTP status off the error
puts "API error (#{e.http_code})"
# 3. Check for a specific error code by parsing the raw body
body = begin
JSON.parse(e.message)
rescue JSON::ParserError
{}
end
puts body['errorCode'] # e.g. "E061103"
puts body['errorDescription']
rescue Descope::Exception => e
# 4. Local validation failed before the request was sent
puts "Validation error: #{e.message} (#{e.error_data[:code]})"
endLocal validation errors set a code inside error_data rather than exposing #http_code, so read them with e.error_data[:code].
When the Error Body Cannot Be Parsed
A failing response does not always contain a Descope error body — a gateway may return an HTML error page, or an empty 401. In that case Ruby gives you no Descope error code; the raw response body is left as the error message. Guard for this when you branch on an error code, since the value will not be a Descope E-prefixed code.
The PHP SDK throws four exception classes in the Descope\SDK\Exception namespace. All of them implement the DescopeException interface, so catching that single type covers everything the SDK throws.
| Exception | Raised for |
|---|---|
AuthException | Most request failures, plus local argument validation |
RateLimitException | HTTP 429 responses |
TokenException | Local JWT parsing and signature verification failures |
ValidationException | A required argument was missing before the request |
Note
AuthException and RateLimitException keep statusCode and errorType as private properties, and the status code and error type are only reachable by casting the exception to a string, which yields JSON.
The server's error code is stored in the field named errorType, and the message resolves the first of errorDescription, errorMessage, or message that is present:
{
"statusCode": 400,
"errorType": "E061102",
"errorMessage": "One time code is invalid"
}use Descope\SDK\DescopeSDK;
use Descope\SDK\Exception\AuthException;
use Descope\SDK\Exception\RateLimitException;
use Descope\SDK\Exception\TokenException;
use Descope\SDK\Exception\ValidationException;
$descopeSDK = new DescopeSDK([
'projectId' => $_ENV['DESCOPE_PROJECT_ID'],
]);
try {
$response = $descopeSDK->otp->verifyCode('email', 'user@example.com', '123456');
}
// 1. Rate limited (429)
catch (RateLimitException $e) {
// No Retry-After value is parsed; back off on your own schedule
}
catch (AuthException $e) {
// 2. Read the description off the exception
echo $e->getMessage();
// 3. Check for a specific error code, which lives in "errorType"
$details = json_decode((string) $e, true);
if (($details['errorType'] ?? null) === 'E061103') {
// too many wrong OTP attempts
}
// 4. Check for a category using the HTTP status code
if (($details['statusCode'] ?? null) === 401) {
// unauthorized
}
// The underlying Guzzle exception is available for logging
$guzzleException = $e->getPrevious();
}
catch (ValidationException $e) {
// A required argument was empty, e.g. a missing session token
}
catch (TokenException $e) {
// Local JWT validation failed, e.g. "Invalid signature"
}RateLimitException also carries a rateLimitParameters field, but the SDK does not read the Retry-After header, so it is always empty.
When the Error Body Cannot Be Parsed
A failing response does not always contain a Descope error body — a gateway may return an HTML error page, or an empty 401. In that case PHP gives you no Descope error code; the raw response body is left as the error message. Guard for this when you branch on errorType, since the value will not be a Descope E-prefixed code.
The .NET SDK throws DescopeException when a request fails. It exposes ErrorCode, ErrorDescription, and ErrorMessage, and its Message is formatted as [code]: description (message).
public class DescopeException : ApplicationException
{
public string? ErrorCode { get; } // "E062504"
public string? ErrorDescription { get; } // "Token expired"
public string? ErrorMessage { get; } // "Failed to load magic link token"
// Message => "[E062504]: Token expired (Failed to load magic link token)"
}using Descope;
try
{
var response = await client.Auth.V1.Auth.Magiclink.Verify.PostAsync(
new VerifyMagicLinkRequest { Token = token });
}
catch (DescopeException e)
{
// Read the details off the exception
Console.WriteLine(e.ErrorCode); // "E062504"
Console.WriteLine(e.ErrorDescription); // "Token expired"
Console.WriteLine(e.Message); // "[E062504]: Token expired (Failed to load magic link token)"
// Check for a specific error code
if (e.ErrorCode == "E061103")
{
// too many wrong OTP attempts
}
// Unparseable body: SDK substitutes HTTP<status>, e.g. "HTTP401"
if (e.ErrorCode == "HTTP401")
{
// unauthorized (server returned 401 with no parseable error body)
}
}When the Error Body Cannot Be Parsed
A failing response does not always contain a Descope error body — a gateway may return an HTML error page, or an empty 401. .NET substitutes a synthetic code of HTTP<status>, such as HTTP401 for an unauthorized response. Guard for this when you branch on an error code, since the value will not be a Descope E-prefixed code.
Automatic Retries
Before surfacing an error, the SDKs transparently retry requests that failed with a transient server status.
The NodeJS, Python, Go, Java, PHP, and .NET SDKs all retry HTTP 503, 520, 521, 522, 524, and 530 — the Cloudflare and service-unavailable statuses — up to three times after the initial attempt, waiting 100ms before the first retry and 5 seconds before each of the next two.
The Ruby SDK is the exception: it does not retry those statuses at all. Instead it retries only HTTP 429, three times by default, using exponential backoff with jitter.