signalapp/Signal-Server · warning · ClientErrorException

429 Too Many Requests (rate limit exceeded)

Error message

429 Too Many Requests (rate limit exceeded)

What it means

updateSession converts a RateLimitExceededException from handleCaptcha (or other handle* checks) into an HTTP 429 ClientErrorException carrying a response built by buildResponseForRateLimitExceeded, which includes the retry duration. The registration service rate-limits verification attempts (captcha assessments, code requests) per number/session to prevent abuse. The client must wait until the indicated retry time before retrying.

Solutions

  1. Read the retry duration from the 429 response body and wait until then before retrying.
  2. Back off exponentially and add jitter to verification retries in your client.
  3. Reduce redundant updateSession calls — batch or cache state instead of polling.
  4. For tests, use a test/dev profile that disables or raises the rate limits (rate limiters configuration).
Defensive patterns

Strategy: retry

Try / catch

try { await updateSession(...); } catch (e) {
  if (e.status === 429) { await sleep(e.body.retryAfterMs ?? backoff()); return updateSession(...); }
  throw e;
}

Prevention

When it happens

Trigger: PUT/PATCH to the verification session endpoint when handleCaptcha (or another handler) hits a rate limiter — e.g. repeated failed captcha submissions or too many session updates for one phone number within the limiter window.

Common situations: Automated tests or scripts hammering the verification endpoint with the same number; a user repeatedly failing captcha; load testing without mocking the rate limiter; shared infrastructure (NAT/proxy) making many users share one rate-limit key.

Understand the failure class

Related errors


AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09). Data as JSON: /api/errors/e40eb2a43fc6ce4f. Report an issue: GitHub.

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java:311

        updateVerificationSessionRequest);

    try {
      // these handle* methods ordered from least likely to fail to most, so take care when considering a change

      verificationSession = verificationCheck.updatedSession().orElse(verificationSession);

      verificationSession = handlePushToken(pushTokenAndType, verificationSession);

      verificationSession = handlePushChallenge(updateVerificationSessionRequest, registrationServiceSession,
          verificationSession);

      verificationSession = handleCaptcha(sourceHost, updateVerificationSessionRequest, registrationServiceSession,
          verificationSession, userAgent, verificationCheck.scoreThreshold());
    } catch (final RateLimitExceededException e) {

      final Response response = buildResponseForRateLimitExceeded(verificationSession, registrationServiceSession,
          e.getRetryDuration());
      throw new ClientErrorException(response);

    } catch (final ForbiddenException e) {

      throw new ClientErrorException(Response.status(Response.Status.FORBIDDEN)
          .entity(buildResponse(registrationServiceSession, verificationSession))
          .build());

    } finally {
      // Each of the handle* methods may update requestedInformation, submittedInformation, and allowedToRequestCode,
      // and we want to be sure to store a changes, even if a later method throws
      verificationSessionManager.update(verificationSession);
    }

    return buildResponse(registrationServiceSession, verificationSession);
  }

  /**
   * If {@code pushTokenAndType} values are not {@code null}, sends a push challenge. If there is no existing push

View on GitHub (pinned to 100ab61c82)