signalapp/Signal-Server · error · UncheckedIOException

Unexpected HTTP status code

Error message

Unexpected HTTP status code %s from androidpublisher: %s

What it means

GooglePlayBillingManager wraps the Google Play Android Publisher API. When an API call fails with an unexpected GoogleJsonResponseException status code, it formats 'Unexpected HTTP status code %s from androidpublisher: %s' with any error details, logs it, and rethrows as UncheckedIOException(IOException(message)). All Play Billing operations (purchases, subscriptions, cancellations) funnel through executeTokenOperation and can surface this.

Solutions

  1. Read the logged message/details to get the exact status code and Google error reason.
  2. For 401/403: verify the service account credentials and that 'Google Play Android Developer API' is enabled, and the account is linked in Play Console.
  3. For 404: the purchase token is invalid/expired — verify the token and package name.
  4. For 429/5xx: add retry with exponential backoff around billing operations.
  5. Catch UncheckedIOException at the subscription/purchase endpoints and map to a meaningful client-facing error.

Example fix

// before: any unexpected status becomes a raw UncheckedIOException
throw new UncheckedIOException(new IOException(message));

// after: classify statuses
int status = e.getStatusCode();
if (status == 429 || status >= 500) {
  throw new TransientPaymentServiceException(status); // caller retries with backoff
}
throw new PaymentServiceException("androidpublisher status " + status + ": " + details);
Defensive patterns

Strategy: try-catch

Try / catch

try {
  receipt = billingManager.getReceiptItem(...);
} catch (UncheckedIOException e) {
  Throwable root = ExceptionUtils.getRootCause(e);
  String msg = root.getMessage(); // "Unexpected HTTP status code %s from androidpublisher: %s"
  if (msg != null && (msg.contains("429") || msg.contains("503"))) {
    // retry with exponential backoff
  } else {
    // surface as permanent payment error
  }
}

Prevention

When it happens

Trigger: executeTokenOperation (via cancelAllActiveSubscriptions, getReceiptItem, productPurchaseV2, claimOneToOnePurchase/claimOneTimePurchase, lookupSubscription) receives a GoogleJsonResponseException whose status is not one of the explicitly handled codes — e.g. 401 bad/revoked service-account credentials, 403 Play API not enabled, 404 invalid purchase token, 429 quota exceeded, 500 from Google.

Common situations: Expired or misconfigured Google Play service-account JSON credentials, Google Play Android Developer API not enabled in the cloud project, wrong package name bound to the credentials, quota exhaustion from high purchase-check volume, testing with fabricated purchase tokens.

Related errors


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

Appendix: source

Thrown at service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java:414

          || e.getStatusCode() == Response.Status.GONE.getStatusCode()) {
        throw new SubscriptionNotFoundException();
      }
      if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) {
        throw new RateLimitExceededException(null);
      }

      final String details;

      if (e instanceof GoogleJsonResponseException googleJsonResponseException && googleJsonResponseException.getDetails() != null) {
        details = googleJsonResponseException.getDetails().toString();
      } else {
        details = "";
      }

      final String message =
          String.format("Unexpected HTTP status code %s from androidpublisher: %s", e.getStatusCode(), details);
      logger.warn(message);
      throw new UncheckedIOException(new IOException(message));
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

  private SubscriptionPurchaseV2 lookupSubscription(final String purchaseToken)
      throws RateLimitExceededException, SubscriptionNotFoundException {
    return executeTokenOperation(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken));
  }

  private ReceiptLevel productIdToLevel(final String productId) {
    final ReceiptLevel level = this.productIdToLevel.get(productId);
    if (level == null) {
      logger.error("productId={} had no associated level", productId);
      // This was a productId a user was able to successfully purchase from our catalog,
      // but we don't know about it. The server's configuration is behind.
      throw new IllegalStateException("no level found for productId " + productId);
    }

View on GitHub (pinned to 100ab61c82)