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
- Read the logged message/details to get the exact status code and Google error reason.
- 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.
- For 404: the purchase token is invalid/expired — verify the token and package name.
- For 429/5xx: add retry with exponential backoff around billing operations.
- 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
- Verify service-account credentials and Play API enablement in deployment checks before traffic hits billing endpoints.
- Retry 429/5xx androidpublisher responses with exponential backoff and jitter.
- Validate purchase tokens are from your package name before calling the Publisher API.
- Monitor quota usage on the Google Play developer project.
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
- Empty body not allowed
- Got a non-200 reply from source URI:
- return Response.status(428).build();
- return Response.status(429).build();
- return Response.status(404).build();
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)