Errors and Troubleshooting

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.

SdkResponse shape
{
  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):

error-handling.js
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:

ConstantCode
DescopeErrors.badRequestE011001
DescopeErrors.missingArgumentsE011002
DescopeErrors.invalidRequestE011003
DescopeErrors.invalidArgumentsE011004
DescopeErrors.wrongOTPCodeE061102
DescopeErrors.tooManyOTPAttemptsE061103
DescopeErrors.enchantedLinkPendingE062503
DescopeErrors.userNotFoundE062108

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.

Exception shapes
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.

error_handling.py
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
        pass

Local 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:

ConstantValue
ERROR_TYPE_INVALID_ARGUMENTinvalid argument
ERROR_TYPE_SERVER_ERRORserver error
ERROR_TYPE_INVALID_PUBLIC_KEYinvalid public key
ERROR_TYPE_INVALID_TOKENinvalid token
ERROR_TYPE_API_RATE_LIMITAPI rate limit exceeded
API_RATE_LIMIT_RETRY_AFTER_HEADERRetry-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.

descope.Error shape
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)
}
error_handling.go
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):

VariableCode
descope.ErrBadRequestE011001
descope.ErrMissingArgumentsE011002
descope.ErrValidationFailureE011003
descope.ErrInvalidArgumentsE011004
descope.ErrInvalidOneTimeCodeE061102
descope.ErrUserAlreadyExistsE062107
descope.ErrEnchantedLinkUnauthorizedE062503
descope.ErrPasswordExpiredE062909
descope.ErrTokenExpiredByLoggedOutE064001
descope.ErrNOTPUnauthorizedE066103
descope.ErrManagementUserNotFoundE112102
descope.ErrRateLimitExceededE130429

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.

ExceptionRaised for
ServerCommonExceptionAPI errors and argument validation failures
RateLimitExceededExceptionHTTP 429, or a body carrying E130429
ClientSetupExceptionClient configuration, such as a missing or malformed project ID
ClientFunctionalExceptionLocal 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.

ErrorHandling.java
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:

ConstantCode
ErrorCode.INTERNAL_SERVER_ERROR500
ErrorCode.INVALID_ARGUMENTE011004
ErrorCode.ERR_MISSING_ARGUMENTSE011002
ErrorCode.RATE_LIMIT_EXCEEDEDE130429
ErrorCode.MISSING_PROJECT_IDG010001
ErrorCode.INVALID_PROJECT_IDG010002
ErrorCode.INVALID_TOKENG030002
ErrorCode.ERR_REFRESH_TOKENG030003
ErrorCode.INVALID_SIGNING_KEYJ010001

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.

Error families
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 out

Any 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.

error_handling.rb
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]})"
end

Local 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.

ExceptionRaised for
AuthExceptionMost request failures, plus local argument validation
RateLimitExceptionHTTP 429 responses
TokenExceptionLocal JWT parsing and signature verification failures
ValidationExceptionA 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:

AuthException cast to string
{
  "statusCode": 400,
  "errorType": "E061102",
  "errorMessage": "One time code is invalid"
}
error-handling.php
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).

DescopeException shape
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)"
}
ErrorHandling.cs
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.

Was this helpful?

On this page