Skip to content

Handle API disabled errors in firebase-appdistribution #3917

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions firebase-appdistribution-api/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ package com.google.firebase.appdistribution {
}

public enum FirebaseAppDistributionException.Status {
enum_constant public static final com.google.firebase.appdistribution.FirebaseAppDistributionException.Status API_DISABLED;
enum_constant public static final com.google.firebase.appdistribution.FirebaseAppDistributionException.Status AUTHENTICATION_CANCELED;
enum_constant public static final com.google.firebase.appdistribution.FirebaseAppDistributionException.Status AUTHENTICATION_FAILURE;
enum_constant public static final com.google.firebase.appdistribution.FirebaseAppDistributionException.Status DOWNLOAD_FAILURE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ public enum Status {
* com.google.firebase:firebase-appdistribution}.
*/
NOT_IMPLEMENTED,

/**
* The Firebase App Distribution Tester API is disabled for this project.
*
* <p>The developer of this app must enable the API in the Google Cloud Console before using the
* App Distribution SDK. See the <a
* href="https://firebase.google.com/docs/app-distribution/set-up-alerts?platform=android">documentation</a>
* for more information. If you enabled this API recently, wait a few minutes for the action to
* propagate to our systems and retry.
*/
API_DISABLED,
}

@NonNull private final Status status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package com.google.firebase.appdistribution.impl;

class ErrorMessages {

static final String NETWORK_ERROR = "Request failed with unknown network error.";

static final String JSON_PARSING_ERROR =
Expand Down Expand Up @@ -51,5 +52,8 @@ class ErrorMessages {
static final String APK_INSTALLATION_FAILED =
"The APK failed to install or installation was canceled by the tester.";

static final String API_DISABLED =
"The App Distribution Tester API is disabled. It must be enabled in the Google Cloud Console. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.";

private ErrorMessages() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright 2022 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.appdistribution.impl;

import androidx.annotation.Nullable;
import com.google.auto.value.AutoValue;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
* Details about a {@code SERVICE_DISABLED} error returned by the Tester API.
*
* <p>Error structure is described in the <a
* href="https://cloud.google.com/apis/design/errors#error_details">Cloud APIs documentation</a>.
*/
@AutoValue
abstract class TesterApiDisabledErrorDetails {

@AutoValue
abstract static class HelpLink {
abstract String description();

abstract String url();

static HelpLink create(String description, String url) {
return new AutoValue_TesterApiDisabledErrorDetails_HelpLink(description, url);
}
}

abstract List<HelpLink> helpLinks();

String formatLinks() {
StringBuilder stringBuilder = new StringBuilder();
for (HelpLink link : helpLinks()) {
stringBuilder.append(String.format("%s: %s\n", link.description(), link.url()));
}
return stringBuilder.toString();
}

/**
* Try to parse API disabled error details from a response body.
*
* <p>If the response is an API disabled error but there is a failure parsing the help links, it
* will still return the details with any links it could parse before the failure.
*
* @param responseBody
* @return the details, or {@code null} if the response was not in the expected format
*/
@Nullable
static TesterApiDisabledErrorDetails tryParse(String responseBody) {
try {
// Get the error details object
JSONArray details =
new JSONObject(responseBody).getJSONObject("error").getJSONArray("details");
JSONObject errorInfo = getDetailWithType(details, "type.googleapis.com/google.rpc.ErrorInfo");
if (errorInfo.getString("reason").equals("SERVICE_DISABLED")) {
return new AutoValue_TesterApiDisabledErrorDetails(parseHelpLinks(details));
}
} catch (JSONException e) {
// Error was not in expected API disabled error format
}
return null;
}

private static JSONObject getDetailWithType(JSONArray details, String type) throws JSONException {
for (int i = 0; i < details.length(); i++) {
JSONObject detail = details.getJSONObject(i);
if (detail.getString("@type").equals(type)) {
return detail;
}
}
throw new JSONException("No detail present with type: " + type);
}

static List<HelpLink> parseHelpLinks(JSONArray details) {
List<HelpLink> helpLinks = new ArrayList<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is ImmutableList.Builder an option here instead of the mutable variety?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately we'd have to pull in guava. I think it's pretty large which is why the SDKs have avoided pulling in that dependency.

try {
JSONObject help = getDetailWithType(details, "type.googleapis.com/google.rpc.Help");
JSONArray linksJson = help.getJSONArray("links");
for (int i = 0; i < linksJson.length(); i++) {
helpLinks.add(parseHelpLink(linksJson.getJSONObject(i)));
}
} catch (JSONException e) {
// If we have an issue parsing the links, we don't want to fail the entire error parsing, so
// go ahead and return what we have
}
return helpLinks;
}

private static HelpLink parseHelpLink(JSONObject json) throws JSONException {
String description = json.getString("description");
String url = json.getString("url");
return HelpLink.create(description, url);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ private static JSONObject readResponse(String tag, HttpsURLConnection connection
String responseBody = readResponseBody(connection);
LogWrapper.getInstance().v(tag, String.format("Response (%d): %s", responseCode, responseBody));
if (!isResponseSuccess(responseCode)) {
throw getExceptionForHttpResponse(tag, responseCode);
throw getExceptionForHttpResponse(tag, responseCode, responseBody);
}
return parseJson(tag, responseBody);
}
Expand Down Expand Up @@ -234,18 +234,15 @@ private HttpsURLConnection openHttpsUrlConnection(String url, String authToken)
}

private static FirebaseAppDistributionException getExceptionForHttpResponse(
String tag, int responseCode) {
String tag, int responseCode, String responseBody) {
switch (responseCode) {
case 400:
return getException(tag, "Bad request", Status.UNKNOWN);
case 401:
return getException(tag, ErrorMessages.AUTHENTICATION_ERROR, Status.AUTHENTICATION_FAILURE);
case 403:
return getException(tag, ErrorMessages.AUTHORIZATION_ERROR, Status.AUTHENTICATION_FAILURE);
return getExceptionFor403(tag, responseBody);
case 404:
// TODO(lkellogg): Change this to a different status once 404s no longer indicate missing
// access (the backend should return 403s for those cases, including when the resource
// doesn't exist but the tester doesn't have the access to see that information)
return getException(tag, ErrorMessages.NOT_FOUND_ERROR, Status.AUTHENTICATION_FAILURE);
case 408:
case 504:
Expand All @@ -255,6 +252,22 @@ private static FirebaseAppDistributionException getExceptionForHttpResponse(
}
}

private static FirebaseAppDistributionException getExceptionFor403(
String tag, String responseBody) {
// Check if this is an API disabled error
TesterApiDisabledErrorDetails apiDisabledErrorDetails =
TesterApiDisabledErrorDetails.tryParse(responseBody);
if (apiDisabledErrorDetails != null) {
String messageWithHelpLinks =
String.format(
"%s\n\n%s", ErrorMessages.API_DISABLED, apiDisabledErrorDetails.formatLinks());
return getException(tag, messageWithHelpLinks, Status.API_DISABLED);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth logging the responseBody in this case (for example, so the developer could print the logs in --verbose or --debug somehow?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Otherwise return a basic 403 exception
return getException(tag, ErrorMessages.AUTHORIZATION_ERROR, Status.AUTHENTICATION_FAILURE);
}

private static FirebaseAppDistributionException getException(
String tag, String message, Status status) {
return new FirebaseAppDistributionException(tagMessage(tag, message), status);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"error": {
"code": 403,
"message": "Firebase App Testers API has not been used in project 123456789 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
"status": "PERMISSION_DENIED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.Help",
"links": [
{
"description": "One link",
"url": "http://google.com"
},
{
"description": "Another link",
"url": "http://gmail.com"
},
{
"bad": "link"
},
{
"description": "One more link",
"url": "http://somethingelse.com"
}
]
},
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "SERVICE_DISABLED",
"domain": "googleapis.com",
"metadata": {
"consumer": "projects/123456789",
"service": "firebaseapptesters.googleapis.com"
}
}
]
}
}
27 changes: 27 additions & 0 deletions firebase-appdistribution/src/test/assets/apiDisabledResponse.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"error": {
"code": 403,
"message": "Firebase App Testers API has not been used in project 123456789 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
"status": "PERMISSION_DENIED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.Help",
"links": [
{
"description": "Google developers console API activation",
"url": "https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789"
}
]
},
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "SERVICE_DISABLED",
"domain": "googleapis.com",
"metadata": {
"consumer": "projects/123456789",
"service": "firebaseapptesters.googleapis.com"
}
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,12 @@ private static <T> Answer<Task<T>> applyToForegroundActivityTaskAnswer(Activity
};
}

static InputStream getTestFileInputStream(String fileName) throws IOException {
return getContext().getResources().getAssets().open(fileName);
}

static String readTestFile(String fileName) throws IOException {
final InputStream jsonInputStream = getContext().getResources().getAssets().open(fileName);
final InputStream jsonInputStream = getTestFileInputStream(fileName);
return streamToString(jsonInputStream);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2022 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.appdistribution.impl;

import static com.google.common.truth.Truth.assertThat;

import com.google.firebase.appdistribution.impl.TesterApiDisabledErrorDetails.HelpLink;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;

@RunWith(RobolectricTestRunner.class)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great coverage!

public class TesterApiDisabledErrorDetailsTest {

@Test
public void tryParse_success() throws IOException {
String responseBody = TestUtils.readTestFile("apiDisabledResponse.json");

TesterApiDisabledErrorDetails details = TesterApiDisabledErrorDetails.tryParse(responseBody);

assertThat(details.helpLinks())
.containsExactly(
HelpLink.create(
"Google developers console API activation",
"https://console.developers.google.com/apis/api/firebaseapptesters.googleapis.com/overview?project=123456789"));
}

@Test
public void tryParse_badResponseBody_returnsNull() {
String responseBody = "not json";

TesterApiDisabledErrorDetails details = TesterApiDisabledErrorDetails.tryParse(responseBody);

assertThat(details).isNull();
}

@Test
public void tryParse_errorParsingLinks_stillReturnsDetails() throws IOException {
String responseBody = TestUtils.readTestFile("apiDisabledBadLinkResponse.json");

TesterApiDisabledErrorDetails details = TesterApiDisabledErrorDetails.tryParse(responseBody);

assertThat(details.helpLinks())
.containsExactly(
HelpLink.create("One link", "http://google.com"),
HelpLink.create("Another link", "http://gmail.com"));
}

@Test
public void formatLinks_success() {
List<HelpLink> helpLinks = new ArrayList<>();
helpLinks.add(HelpLink.create("One link", "http://google.com"));
helpLinks.add(HelpLink.create("Another link", "http://gmail.com"));
TesterApiDisabledErrorDetails details = new AutoValue_TesterApiDisabledErrorDetails(helpLinks);

String formattedLinks = details.formatLinks();

assertThat(formattedLinks)
.isEqualTo("One link: http://google.com\nAnother link: http://gmail.com\n");
}
}
Loading