{"record":{"id":"41bd4750c5e79ff8","repo":"signalapp/Signal-Server","slug":"unexpected-http-status-code-s-from-androidpublish","errorCode":null,"errorMessage":"Unexpected HTTP status code %s from androidpublisher: %s","messagePattern":"Unexpected HTTP status code (.+?) from androidpublisher: (.+?)","errorType":"exception","errorClass":"UncheckedIOException","httpStatus":null,"severity":"error","filePath":"service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java","lineNumber":414,"sourceCode":"          || e.getStatusCode() == Response.Status.GONE.getStatusCode()) {\n        throw new SubscriptionNotFoundException();\n      }\n      if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) {\n        throw new RateLimitExceededException(null);\n      }\n\n      final String details;\n\n      if (e instanceof GoogleJsonResponseException googleJsonResponseException && googleJsonResponseException.getDetails() != null) {\n        details = googleJsonResponseException.getDetails().toString();\n      } else {\n        details = \"\";\n      }\n\n      final String message =\n          String.format(\"Unexpected HTTP status code %s from androidpublisher: %s\", e.getStatusCode(), details);\n      logger.warn(message);\n      throw new UncheckedIOException(new IOException(message));\n    } catch (IOException e) {\n      throw new UncheckedIOException(e);\n    }\n  }\n\n  private SubscriptionPurchaseV2 lookupSubscription(final String purchaseToken)\n      throws RateLimitExceededException, SubscriptionNotFoundException {\n    return executeTokenOperation(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken));\n  }\n\n  private ReceiptLevel productIdToLevel(final String productId) {\n    final ReceiptLevel level = this.productIdToLevel.get(productId);\n    if (level == null) {\n      logger.error(\"productId={} had no associated level\", productId);\n      // This was a productId a user was able to successfully purchase from our catalog,\n      // but we don't know about it. The server's configuration is behind.\n      throw new IllegalStateException(\"no level found for productId \" + productId);\n    }","sourceCodeStart":396,"sourceCodeEnd":432,"githubUrl":"https://github.com/signalapp/Signal-Server/blob/100ab61c82627582c867d19e1c0561ba2781e927/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java#L396-L432","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: any unexpected status becomes a raw UncheckedIOException\nthrow new UncheckedIOException(new IOException(message));\n\n// after: classify statuses\nint status = e.getStatusCode();\nif (status == 429 || status >= 500) {\n  throw new TransientPaymentServiceException(status); // caller retries with backoff\n}\nthrow new PaymentServiceException(\"androidpublisher status \" + status + \": \" + details);","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  receipt = billingManager.getReceiptItem(...);\n} catch (UncheckedIOException e) {\n  Throwable root = ExceptionUtils.getRootCause(e);\n  String msg = root.getMessage(); // \"Unexpected HTTP status code %s from androidpublisher: %s\"\n  if (msg != null && (msg.contains(\"429\") || msg.contains(\"503\"))) {\n    // retry with exponential backoff\n  } else {\n    // surface as permanent payment error\n  }\n}","preventionTips":["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."],"tags":["google-play","billing","http","api-error"],"backgroundTag":"api-error-response","analyzedSha":"100ab61c82627582c867d19e1c0561ba2781e927","analyzedAt":"2026-09-09T13:29:47.883Z","contentChangedAt":"2026-09-09T13:29:47.883Z","schemaVersion":2},"datasetVersion":"2026-09-16T09:17:16.951Z"}