-
Notifications
You must be signed in to change notification settings - Fork 624
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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<>(); | ||
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 |
---|---|---|
|
@@ -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); | ||
} | ||
|
@@ -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: | ||
|
@@ -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); | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would! We actually print the response body out in verbose mode already: https://github.com/firebase/firebase-android-sdk/blob/master/firebase-appdistribution/src/main/java/com/google/firebase/appdistribution/impl/TesterApiHttpClient.java#L183 |
||
// 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); | ||
|
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" | ||
} | ||
} | ||
] | ||
} | ||
} |
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 |
---|---|---|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"); | ||
} | ||
} |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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.