Repository: firebase/firebase-admin-java
Pull request #353 (2020-01-24 21:28:42)

Added ErrorHandlingHttpClient API
Implemented an abstraction for making HTTP calls while handling all the errors that may occur along the way, converting those exceptions into instances of `FirebaseException`.

1. Added `HttpErrorHandler` interface which can be implemented to control how different exceptions are mapped to `FirebaseException`.
2. Added 2 abstract implementations of the above interface to handle raw HTTP error responses, and GCP OnePlatform error responses.

Additionally I'm also publicly exposing the `FirebaseException.getErrorCode()` but use the name `getErrorCodeNew` for the moment. We cannot use the name `getErrorCode` until all the subclasses have been refactored.


Patch:
diff --git a/src/main/java/com/google/firebase/FirebaseException.java b/src/main/java/com/google/firebase/FirebaseException.java
index f15c042..ca1526e 100644
--- a/src/main/java/com/google/firebase/FirebaseException.java
+++ b/src/main/java/com/google/firebase/FirebaseException.java
@@ -57,8 +57,8 @@ public class FirebaseException extends Exception {
    *
    * @return A Firebase error code.
    */
-  // TODO: Expose this method publicly
-  ErrorCode getErrorCode() {
+  // TODO: Rename this method to getErrorCode when the child classes are refactored.
+  public ErrorCode getErrorCodeNew() {
     return errorCode;
   }
 
diff --git a/src/main/java/com/google/firebase/IncomingHttpResponse.java b/src/main/java/com/google/firebase/IncomingHttpResponse.java
index 02bdcb7..ce205ad 100644
--- a/src/main/java/com/google/firebase/IncomingHttpResponse.java
+++ b/src/main/java/com/google/firebase/IncomingHttpResponse.java
@@ -35,7 +35,15 @@ public final class IncomingHttpResponse {
   private final Map<String, Object> headers;
   private final OutgoingHttpRequest request;
 
-  IncomingHttpResponse(HttpResponse response, @Nullable String content) {
+  /**
+   * Creates an IncomingHttpResponse from a successful HttpResponse and the content read from it.
+   * The caller is expected to read the content from the response, and handle any errors that
+   * may occur while reading.
+   *
+   * @param response A successful HttpResponse.
+   * @param content Content read from the response.
+   */
+  public IncomingHttpResponse(HttpResponse response, @Nullable String content) {
     checkNotNull(response, "response must not be null");
     this.statusCode = response.getStatusCode();
     this.content = content;
@@ -43,7 +51,13 @@ public final class IncomingHttpResponse {
     this.request = new OutgoingHttpRequest(response.getRequest());
   }
 
-  IncomingHttpResponse(HttpResponseException e, HttpRequest request) {
+  /**
+   * Creates an IncomingHttpResponse from an HTTP error response.
+   *
+   * @param e HttpResponseException representing the HTTP error response.
+   * @param request The HttpRequest that resulted in the error.
+   */
+  public IncomingHttpResponse(HttpResponseException e, HttpRequest request) {
     this(e, new OutgoingHttpRequest(request));
   }
 
diff --git a/src/main/java/com/google/firebase/internal/AbstractHttpErrorHandler.java b/src/main/java/com/google/firebase/internal/AbstractHttpErrorHandler.java
new file mode 100644
index 0000000..7af15ff
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/AbstractHttpErrorHandler.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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.internal;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpStatusCodes;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.ErrorCode;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.IncomingHttpResponse;
+import java.util.Map;
+
+/**
+ * An abstract HttpErrorHandler implementation that maps HTTP status codes to Firebase error codes.
+ */
+public abstract class AbstractHttpErrorHandler<T extends FirebaseException>
+    implements HttpErrorHandler<T> {
+
+  private static final Map<Integer, ErrorCode> HTTP_ERROR_CODES =
+      ImmutableMap.<Integer, ErrorCode>builder()
+          .put(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, ErrorCode.INVALID_ARGUMENT)
+          .put(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED, ErrorCode.UNAUTHENTICATED)
+          .put(HttpStatusCodes.STATUS_CODE_FORBIDDEN, ErrorCode.PERMISSION_DENIED)
+          .put(HttpStatusCodes.STATUS_CODE_NOT_FOUND, ErrorCode.NOT_FOUND)
+          .put(HttpStatusCodes.STATUS_CODE_CONFLICT, ErrorCode.CONFLICT)
+          .put(429, ErrorCode.RESOURCE_EXHAUSTED)
+          .put(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, ErrorCode.INTERNAL)
+          .put(HttpStatusCodes.STATUS_CODE_SERVICE_UNAVAILABLE, ErrorCode.UNAVAILABLE)
+          .build();
+
+  @Override
+  public final T handleHttpResponseException(
+      HttpResponseException e, IncomingHttpResponse response) {
+    ErrorParams params = getErrorParams(e, response);
+    return this.createException(params);
+  }
+
+  protected ErrorParams getErrorParams(HttpResponseException e, IncomingHttpResponse response) {
+    ErrorCode code = HTTP_ERROR_CODES.get(e.getStatusCode());
+    if (code == null) {
+      code = ErrorCode.UNKNOWN;
+    }
+
+    String message = String.format("Unexpected HTTP response with status: %d\n%s",
+        e.getStatusCode(), e.getContent());
+    return new ErrorParams(code, message, e, response);
+  }
+
+  protected abstract T createException(ErrorParams params);
+
+  public static final class ErrorParams {
+
+    private final ErrorCode errorCode;
+    private final String message;
+    private final HttpResponseException exception;
+    private final IncomingHttpResponse response;
+
+    public ErrorParams(
+        ErrorCode errorCode,
+        String message,
+        @Nullable HttpResponseException e,
+        @Nullable IncomingHttpResponse response) {
+
+      this.errorCode = checkNotNull(errorCode, "errorCode must not be null");
+      checkArgument(!Strings.isNullOrEmpty(message), "message must not be null or empty");
+      this.message = message;
+      this.exception = e;
+      this.response = response;
+    }
+
+    public ErrorCode getErrorCode() {
+      return errorCode;
+    }
+
+    public String getMessage() {
+      return message;
+    }
+
+    @Nullable
+    public HttpResponseException getException() {
+      return exception;
+    }
+
+    @Nullable
+    public IncomingHttpResponse getResponse() {
+      return response;
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java b/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java
new file mode 100644
index 0000000..8fb66d7
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.util.Key;
+import com.google.common.base.Strings;
+import com.google.firebase.ErrorCode;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.IncomingHttpResponse;
+import java.io.IOException;
+
+/**
+ * An abstract HttpErrorHandler that handles Google Cloud error responses. Format of these
+ * error responses are defined at https://cloud.google.com/apis/design/errors.
+ */
+public abstract class AbstractPlatformErrorHandler<T extends FirebaseException>
+    extends AbstractHttpErrorHandler<T> {
+
+  protected final JsonFactory jsonFactory;
+
+  public AbstractPlatformErrorHandler(JsonFactory jsonFactory) {
+    this.jsonFactory = checkNotNull(jsonFactory, "jsonFactory must not be null");
+  }
+
+  @Override
+  protected final ErrorParams getErrorParams(
+      HttpResponseException e, IncomingHttpResponse response) {
+    ErrorParams defaults = super.getErrorParams(e, response);
+    PlatformErrorResponse parsedError = this.parseErrorResponse(e.getContent());
+
+    ErrorCode code = defaults.getErrorCode();
+    String status = parsedError.getStatus();
+    if (!Strings.isNullOrEmpty(status)) {
+      code = Enum.valueOf(ErrorCode.class, parsedError.getStatus());
+    }
+
+    String message = parsedError.getMessage();
+    if (Strings.isNullOrEmpty(message)) {
+      message = defaults.getMessage();
+    }
+
+    return new ErrorParams(code, message, e, response);
+  }
+
+  private PlatformErrorResponse parseErrorResponse(String content) {
+    PlatformErrorResponse response = new PlatformErrorResponse();
+    if (!Strings.isNullOrEmpty(content)) {
+      try {
+        jsonFactory.createJsonParser(content).parseAndClose(response);
+      } catch (IOException e) {
+        // Ignore any error that may occur while parsing the error response. The server
+        // may have responded with a non-json payload. Return an empty return value, and
+        // let the base class logic come into play.
+      }
+    }
+
+    return response;
+  }
+
+  public static class PlatformErrorResponse {
+    @Key("error")
+    private PlatformError error;
+
+    String getStatus() {
+      return error != null ? error.status : null;
+    }
+
+    String getMessage() {
+      return error != null ? error.message : null;
+    }
+  }
+
+  public static class PlatformError {
+    @Key("status")
+    private String status;
+
+    @Key("message")
+    private String message;
+  }
+}
diff --git a/src/main/java/com/google/firebase/internal/ApiClientUtils.java b/src/main/java/com/google/firebase/internal/ApiClientUtils.java
index 7506ff8..f8ae0e1 100644
--- a/src/main/java/com/google/firebase/internal/ApiClientUtils.java
+++ b/src/main/java/com/google/firebase/internal/ApiClientUtils.java
@@ -43,9 +43,22 @@ public class ApiClientUtils {
    * @return A new {@code HttpRequestFactory} instance.
    */
   public static HttpRequestFactory newAuthorizedRequestFactory(FirebaseApp app) {
+    return newAuthorizedRequestFactory(app, DEFAULT_RETRY_CONFIG);
+  }
+
+  /**
+   * Creates a new {@code HttpRequestFactory} which provides authorization (OAuth2), timeouts and
+   * automatic retries.
+   *
+   * @param app {@link FirebaseApp} from which to obtain authorization credentials.
+   * @param retryConfig {@link RetryConfig} which specifies how and when to retry errors.
+   * @return A new {@code HttpRequestFactory} instance.
+   */
+  public static HttpRequestFactory newAuthorizedRequestFactory(
+      FirebaseApp app, @Nullable RetryConfig retryConfig) {
     HttpTransport transport = app.getOptions().getHttpTransport();
     return transport.createRequestFactory(
-        new FirebaseRequestInitializer(app, DEFAULT_RETRY_CONFIG));
+        new FirebaseRequestInitializer(app, retryConfig));
   }
 
   public static HttpRequestFactory newUnauthorizedRequestFactory(FirebaseApp app) {
diff --git a/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java b/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java
new file mode 100644
index 0000000..ae7c8a6
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.JsonParser;
+import com.google.common.io.CharStreams;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.IncomingHttpResponse;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
+/**
+ * An HTTP client implementation that handles any errors that may occur during HTTP calls, and
+ * converts them into an instance of FirebaseException.
+ */
+public final class ErrorHandlingHttpClient<T extends FirebaseException> {
+
+  private final HttpRequestFactory requestFactory;
+  private final JsonFactory jsonFactory;
+  private final HttpErrorHandler<T> errorHandler;
+
+  public ErrorHandlingHttpClient(
+      FirebaseApp app, HttpErrorHandler<T> errorHandler, RetryConfig retryConfig) {
+    this(
+        ApiClientUtils.newAuthorizedRequestFactory(app, retryConfig),
+        app.getOptions().getJsonFactory(),
+        errorHandler);
+  }
+
+  public ErrorHandlingHttpClient(
+      HttpRequestFactory requestFactory,
+      JsonFactory jsonFactory,
+      HttpErrorHandler<T> errorHandler) {
+    this.requestFactory = checkNotNull(requestFactory, "requestFactory must not be null");
+    this.jsonFactory = checkNotNull(jsonFactory, "jsonFactory must not be null");
+    this.errorHandler = checkNotNull(errorHandler, "errorHandler must not be null");
+  }
+
+  /**
+   * Sends the given HTTP request to the target endpoint, and parses the response while handling
+   * any errors that may occur along the way.
+   *
+   * @param requestInfo Outgoing request configuration.
+   * @param responseType Class to parse the response into.
+   * @param <V> Parsed response type.
+   * @return Parsed response object.
+   * @throws T If any error occurs while making the request.
+   */
+  public <V> V sendAndParse(HttpRequestInfo requestInfo, Class<V> responseType) throws T {
+    IncomingHttpResponse response = send(requestInfo);
+    return parse(response, responseType);
+  }
+
+  private IncomingHttpResponse send(HttpRequestInfo requestInfo) throws T {
+    HttpRequest request = createHttpRequest(requestInfo);
+
+    HttpResponse response = null;
+    try {
+      response = request.execute();
+      // Read and buffer the content. Otherwise if a parse error occurs later,
+      // we lose the content stream.
+      String content = CharStreams.toString(
+          new InputStreamReader(response.getContent(), response.getContentCharset()));
+      return new IncomingHttpResponse(response, content);
+    } catch (HttpResponseException e) {
+      throw errorHandler.handleHttpResponseException(e, new IncomingHttpResponse(e, request));
+    } catch (IOException e) {
+      throw errorHandler.handleIOException(e);
+    } finally {
+      ApiClientUtils.disconnectQuietly(response);
+    }
+  }
+
+  private <V> V parse(IncomingHttpResponse response, Class<V> responseType) throws T {
+    try {
+      JsonParser parser = jsonFactory.createJsonParser(response.getContent());
+      return parser.parseAndClose(responseType);
+    } catch (IOException e) {
+      throw errorHandler.handleParseException(e, response);
+    }
+  }
+
+  private HttpRequest createHttpRequest(HttpRequestInfo requestInfo) throws T {
+    try {
+      return requestInfo.newHttpRequest(requestFactory)
+          .setParser(new JsonObjectParser(jsonFactory));
+    } catch (IOException e) {
+      // Handle request initialization errors (credential loading and other config errors)
+      throw errorHandler.handleIOException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/google/firebase/internal/HttpErrorHandler.java b/src/main/java/com/google/firebase/internal/HttpErrorHandler.java
new file mode 100644
index 0000000..988b45a
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/HttpErrorHandler.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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.internal;
+
+import com.google.api.client.http.HttpResponseException;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.IncomingHttpResponse;
+import java.io.IOException;
+
+/**
+ * An interface for handling all sorts of exceptions that may occur while making an HTTP call and
+ * converting them into some instance of FirebaseException.
+ */
+public interface HttpErrorHandler<T extends FirebaseException> {
+
+  /**
+   * Handle any low-level transport and initialization errors.
+   */
+  T handleIOException(IOException e);
+
+  /**
+   * Handle HTTP response exceptions (caused by HTTP error responses).
+   */
+  T handleHttpResponseException(HttpResponseException e, IncomingHttpResponse response);
+
+  /**
+   * Handle any errors that may occur while parsing the response payload.
+   */
+  T handleParseException(IOException e, IncomingHttpResponse response);
+}
diff --git a/src/main/java/com/google/firebase/internal/HttpRequestInfo.java b/src/main/java/com/google/firebase/internal/HttpRequestInfo.java
new file mode 100644
index 0000000..d19bab5
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/HttpRequestInfo.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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.internal;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpContent;
+import com.google.api.client.http.HttpMethods;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestFactory;
+import com.google.api.client.http.HttpResponseInterceptor;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Internal API for configuring outgoing HTTP requests. To be used with the
+ * {@link ErrorHandlingHttpClient} class.
+ */
+public final class HttpRequestInfo {
+
+  private final String method;
+  private final GenericUrl url;
+  private final HttpContent content;
+  private final Map<String, String> headers = new HashMap<>();
+  private HttpResponseInterceptor interceptor;
+
+  private HttpRequestInfo(String method, String url, HttpContent content) {
+    this.method = method;
+    this.url = new GenericUrl(url);
+    this.content = content;
+  }
+
+  public HttpRequestInfo addHeader(String name, String value) {
+    this.headers.put(name, value);
+    return this;
+  }
+
+  public HttpRequestInfo addAllHeaders(Map<String, String> headers) {
+    this.headers.putAll(headers);
+    return this;
+  }
+
+  public HttpRequestInfo setResponseInterceptor(HttpResponseInterceptor interceptor) {
+    this.interceptor = interceptor;
+    return this;
+  }
+
+  public static HttpRequestInfo buildGetRequest(String url) {
+    return new HttpRequestInfo(HttpMethods.GET, url, null);
+  }
+
+  public static HttpRequestInfo buildPostRequest(String url, HttpContent content) {
+    return new HttpRequestInfo(HttpMethods.POST, url, content);
+  }
+
+  HttpRequest newHttpRequest(HttpRequestFactory factory) throws IOException {
+    HttpRequest request = factory.buildRequest(method, url, content);
+    for (Map.Entry<String, String> entry : headers.entrySet()) {
+      request.getHeaders().set(entry.getKey(), entry.getValue());
+    }
+    request.setResponseInterceptor(interceptor);
+
+    return request;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/FirebaseExceptionTest.java b/src/test/java/com/google/firebase/FirebaseExceptionTest.java
index 46d3cf0..81e6104 100644
--- a/src/test/java/com/google/firebase/FirebaseExceptionTest.java
+++ b/src/test/java/com/google/firebase/FirebaseExceptionTest.java
@@ -67,7 +67,7 @@ public class FirebaseExceptionTest {
         null,
         null);
 
-    assertEquals(ErrorCode.INTERNAL, exception.getErrorCode());
+    assertEquals(ErrorCode.INTERNAL, exception.getErrorCodeNew());
     assertEquals("Test error", exception.getMessage());
     assertNull(exception.getHttpResponse());
     assertNull(exception.getCause());
@@ -86,7 +86,7 @@ public class FirebaseExceptionTest {
         response,
         null);
 
-    assertEquals(ErrorCode.INTERNAL, exception.getErrorCode());
+    assertEquals(ErrorCode.INTERNAL, exception.getErrorCodeNew());
     assertEquals("Test error", exception.getMessage());
     assertSame(response, exception.getHttpResponse());
     assertNull(exception.getCause());
@@ -102,7 +102,7 @@ public class FirebaseExceptionTest {
         null,
         cause);
 
-    assertEquals(ErrorCode.INTERNAL, exception.getErrorCode());
+    assertEquals(ErrorCode.INTERNAL, exception.getErrorCodeNew());
     assertEquals("Test error", exception.getMessage());
     assertNull(exception.getHttpResponse());
     assertSame(cause, exception.getCause());
@@ -112,7 +112,7 @@ public class FirebaseExceptionTest {
   public void testFirebaseExceptionLegacyConstructor() {
     FirebaseException exception = new FirebaseException("Test error");
 
-    assertEquals(ErrorCode.UNKNOWN, exception.getErrorCode());
+    assertEquals(ErrorCode.UNKNOWN, exception.getErrorCodeNew());
     assertEquals("Test error", exception.getMessage());
     assertNull(exception.getHttpResponse());
     assertNull(exception.getCause());
diff --git a/src/test/java/com/google/firebase/internal/AbstractPlatformErrorHandlerTest.java b/src/test/java/com/google/firebase/internal/AbstractPlatformErrorHandlerTest.java
new file mode 100644
index 0000000..d858a23
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/AbstractPlatformErrorHandlerTest.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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.internal;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.HttpStatusCodes;
+import com.google.api.client.http.LowLevelHttpRequest;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.client.util.GenericData;
+import com.google.firebase.ErrorCode;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.IncomingHttpResponse;
+import java.io.IOException;
+import org.junit.Test;
+
+public class AbstractPlatformErrorHandlerTest {
+
+  private static final HttpRequestInfo TEST_REQUEST = HttpRequestInfo.buildGetRequest(
+      "https://firebase.google.com");
+
+  @Test
+  public void testPlatformError() {
+    String payload = "{\"error\": {\"status\": \"UNAVAILABLE\", \"message\": \"Test error\"}}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.UNAVAILABLE, e.getErrorCodeNew());
+      assertEquals("Test error", e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_SERVER_ERROR, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testNonJsonError() {
+    String payload = "not json";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.INTERNAL, e.getErrorCodeNew());
+      assertEquals("Unexpected HTTP response with status: 500\nnot json", e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_SERVER_ERROR, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testPlatformErrorWithoutCode() {
+    String payload = "{\"error\": {\"message\": \"Test error\"}}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.INTERNAL, e.getErrorCodeNew());
+      assertEquals("Test error", e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_SERVER_ERROR, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testPlatformErrorWithoutMessage() {
+    String payload = "{\"error\": {\"status\": \"INVALID_ARGUMENT\"}}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.INVALID_ARGUMENT, e.getErrorCodeNew());
+      assertEquals("Unexpected HTTP response with status: 500\n" + payload, e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_SERVER_ERROR, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testPlatformErrorWithoutCodeOrMessage() {
+    String payload = "{}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.INTERNAL, e.getErrorCodeNew());
+      assertEquals("Unexpected HTTP response with status: 500\n" + payload, e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_SERVER_ERROR, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testNetworkError() {
+    final IOException exception = new IOException("Test");
+    MockHttpTransport transport = new MockHttpTransport(){
+      @Override
+      public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
+        throw exception;
+      }
+    };
+    ErrorHandlingHttpClient<FirebaseException> client = new ErrorHandlingHttpClient<>(
+        transport.createRequestFactory(),
+        Utils.getDefaultJsonFactory(),
+        new TestPlatformErrorHandler());
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.UNKNOWN, e.getErrorCodeNew());
+      assertEquals("IO error", e.getMessage());
+      assertNull(e.getHttpResponse());
+      assertSame(exception, e.getCause());
+    }
+  }
+
+  @Test
+  public void testParseError() {
+    String payload = "not json";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.UNKNOWN, e.getErrorCodeNew());
+      assertEquals("Parse error", e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_OK, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testUnknownHttpError() {
+    String payload = "{\"error\": {\"message\": \"Test error\"}}";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(512)
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.UNKNOWN, e.getErrorCodeNew());
+      assertEquals("Test error", e.getMessage());
+      assertHttpResponse(e, 512, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  private ErrorHandlingHttpClient<FirebaseException> createHttpClient(
+      MockLowLevelHttpResponse response) {
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(response)
+        .build();
+    return new ErrorHandlingHttpClient<>(
+        transport.createRequestFactory(),
+        Utils.getDefaultJsonFactory(),
+        new TestPlatformErrorHandler());
+  }
+
+  private void assertHttpResponse(FirebaseException e, int statusCode, String content) {
+    IncomingHttpResponse httpResponse = e.getHttpResponse();
+    assertNotNull(httpResponse);
+    assertEquals(statusCode, httpResponse.getStatusCode());
+    assertEquals(content, httpResponse.getContent());
+    assertEquals("GET", httpResponse.getRequest().getMethod());
+  }
+
+  private static class TestPlatformErrorHandler extends
+      AbstractPlatformErrorHandler<FirebaseException> {
+
+    TestPlatformErrorHandler() {
+      super(Utils.getDefaultJsonFactory());
+    }
+
+    @Override
+    protected FirebaseException createException(ErrorParams params) {
+      return new FirebaseException(params.getErrorCode(), params.getMessage(),
+          params.getResponse(), params.getException());
+    }
+
+    @Override
+    public FirebaseException handleIOException(IOException e) {
+      return new FirebaseException(ErrorCode.UNKNOWN, "IO error", null, e);
+    }
+
+    @Override
+    public FirebaseException handleParseException(IOException e, IncomingHttpResponse response) {
+      return new FirebaseException(ErrorCode.UNKNOWN, "Parse error", response, e);
+    }
+  }
+}
diff --git a/src/test/java/com/google/firebase/internal/ErrorHandlingHttpClientTest.java b/src/test/java/com/google/firebase/internal/ErrorHandlingHttpClientTest.java
new file mode 100644
index 0000000..504ee5c
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/ErrorHandlingHttpClientTest.java
@@ -0,0 +1,291 @@
+/*
+ * Copyright 2020 Google Inc.
+ *
+ * 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.internal;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.http.HttpMethods;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpStatusCodes;
+import com.google.api.client.http.LowLevelHttpRequest;
+import com.google.api.client.http.json.JsonHttpContent;
+import com.google.api.client.json.JsonFactory;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.client.testing.util.MockSleeper;
+import com.google.api.client.util.GenericData;
+import com.google.auth.oauth2.AccessToken;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.firebase.ErrorCode;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseException;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.IncomingHttpResponse;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.testing.TestResponseInterceptor;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import org.junit.Test;
+
+public class ErrorHandlingHttpClientTest {
+
+  private static final JsonFactory DEFAULT_JSON_FACTORY = Utils.getDefaultJsonFactory();
+
+  private static final HttpRequestInfo TEST_REQUEST = HttpRequestInfo.buildGetRequest(
+      "https://firebase.google.com");
+
+  @Test(expected = NullPointerException.class)
+  public void testNullRequestFactory() {
+    new ErrorHandlingHttpClient<>(
+        null,
+        DEFAULT_JSON_FACTORY,
+        new TestHttpErrorHandler());
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testNullJsonFactory() {
+    new ErrorHandlingHttpClient<>(
+        Utils.getDefaultTransport().createRequestFactory(),
+        null,
+        new TestHttpErrorHandler());
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testNullErrorHandler() {
+    new ErrorHandlingHttpClient<>(
+        Utils.getDefaultTransport().createRequestFactory(),
+        DEFAULT_JSON_FACTORY,
+        null);
+  }
+
+  @Test
+  public void testSuccessfulRequest() throws FirebaseException {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent("{\"foo\": \"bar\"}");
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    GenericData body = client.sendAndParse(TEST_REQUEST, GenericData.class);
+
+    assertEquals(1, body.size());
+    assertEquals("bar", body.get("foo"));
+  }
+
+  @Test
+  public void testSuccessfulRequestWithHeadersAndBody() throws FirebaseException, IOException {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent("{\"foo\": \"bar\"}");
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    HttpRequestInfo request = HttpRequestInfo.buildPostRequest(
+        "https://firebase.google.com",
+        new JsonHttpContent(DEFAULT_JSON_FACTORY, ImmutableMap.of("key", "value")));
+    TestResponseInterceptor interceptor = new TestResponseInterceptor();
+    request.addHeader("h1", "v1")
+        .addAllHeaders(ImmutableMap.of("h2", "v2", "h3", "v3"))
+        .setResponseInterceptor(interceptor);
+    GenericData body = client.sendAndParse(request, GenericData.class);
+
+    assertEquals(1, body.size());
+    assertEquals("bar", body.get("foo"));
+    HttpRequest last = interceptor.getLastRequest();
+    assertEquals(HttpMethods.POST, last.getRequestMethod());
+    assertEquals("v1", last.getHeaders().get("h1"));
+    assertEquals("v2", last.getHeaders().get("h2"));
+    assertEquals("v3", last.getHeaders().get("h3"));
+
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    last.getContent().writeTo(out);
+    assertEquals("{\"key\":\"value\"}", out.toString());
+  }
+
+  @Test
+  public void testNetworkError() {
+    final IOException exception = new IOException("Test");
+    MockHttpTransport transport = new MockHttpTransport(){
+      @Override
+      public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
+        throw exception;
+      }
+    };
+    ErrorHandlingHttpClient<FirebaseException> client = new ErrorHandlingHttpClient<>(
+        transport.createRequestFactory(),
+        DEFAULT_JSON_FACTORY,
+        new TestHttpErrorHandler());
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.UNKNOWN, e.getErrorCodeNew());
+      assertEquals("IO error: Test", e.getMessage());
+      assertNull(e.getHttpResponse());
+      assertSame(exception, e.getCause());
+    }
+  }
+
+  @Test
+  public void testErrorResponse() {
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setStatusCode(HttpStatusCodes.STATUS_CODE_SERVER_ERROR)
+        .addHeader("Custom-Header", "value")
+        .setContent("{}");
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.INTERNAL, e.getErrorCodeNew());
+      assertEquals("Example error message: {}", e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_SERVER_ERROR, "{}");
+      IncomingHttpResponse httpResponse = e.getHttpResponse();
+      assertEquals(1, httpResponse.getHeaders().size());
+      assertEquals(ImmutableList.of("value"), httpResponse.getHeaders().get("custom-header"));
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testParseError() {
+    String payload = "not json";
+    MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+        .setContent(payload);
+    ErrorHandlingHttpClient<FirebaseException> client = createHttpClient(response);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.UNKNOWN, e.getErrorCodeNew());
+      assertEquals("Parse error", e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_OK, payload);
+      assertNotNull(e.getCause());
+    }
+  }
+
+  @Test
+  public void testRetryOnError() {
+    CountingLowLevelHttpRequest request = CountingLowLevelHttpRequest.fromStatus(503);
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpRequest(request)
+        .build();
+
+    FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials("token"))
+        .setHttpTransport(transport)
+        .build());
+    RetryConfig retryConfig = RetryConfig.builder()
+        .setMaxRetries(4)
+        .setRetryStatusCodes(ImmutableList.of(503))
+        .setSleeper(new MockSleeper())
+        .build();
+    ErrorHandlingHttpClient<FirebaseException> client = new ErrorHandlingHttpClient<>(
+        app, new TestHttpErrorHandler(), retryConfig);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.INTERNAL, e.getErrorCodeNew());
+      assertEquals("Example error message: null", e.getMessage());
+      assertHttpResponse(e, HttpStatusCodes.STATUS_CODE_SERVICE_UNAVAILABLE, null);
+      assertNotNull(e.getCause());
+
+      assertEquals(5, request.getCount());
+    } finally {
+      app.delete();
+    }
+  }
+
+  @Test
+  public void testRequestInitializationError() {
+    CountingLowLevelHttpRequest request = CountingLowLevelHttpRequest.fromStatus(503);
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpRequest(request)
+        .build();
+
+    FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+        .setCredentials(new MockGoogleCredentials() {
+          @Override
+          public AccessToken refreshAccessToken() throws IOException {
+            throw new IOException("Failed to fetch credentials");
+          }
+        })
+        .setHttpTransport(transport)
+        .build());
+    ErrorHandlingHttpClient<FirebaseException> client = new ErrorHandlingHttpClient<>(
+        app, new TestHttpErrorHandler(), null);
+
+    try {
+      client.sendAndParse(TEST_REQUEST, GenericData.class);
+      fail("No exception thrown for HTTP error response");
+    } catch (FirebaseException e) {
+      assertEquals(ErrorCode.UNKNOWN, e.getErrorCodeNew());
+      assertEquals("IO error: Failed to fetch credentials", e.getMessage());
+      assertNull(e.getHttpResponse());
+      assertNotNull(e.getCause());
+    } finally {
+      app.delete();
+    }
+  }
+
+  private ErrorHandlingHttpClient<FirebaseException> createHttpClient(
+      MockLowLevelHttpResponse response) {
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(response)
+        .build();
+    return new ErrorHandlingHttpClient<>(
+        transport.createRequestFactory(),
+        DEFAULT_JSON_FACTORY,
+        new TestHttpErrorHandler());
+  }
+
+  private void assertHttpResponse(FirebaseException e, int statusCode, String content) {
+    IncomingHttpResponse httpResponse = e.getHttpResponse();
+    assertNotNull(httpResponse);
+    assertEquals(statusCode, httpResponse.getStatusCode());
+    assertEquals(content, httpResponse.getContent());
+    assertEquals("GET", httpResponse.getRequest().getMethod());
+  }
+
+  private static class TestHttpErrorHandler implements HttpErrorHandler<FirebaseException> {
+    @Override
+    public FirebaseException handleIOException(IOException e) {
+      return new FirebaseException(
+          ErrorCode.UNKNOWN, "IO error: " + e.getMessage(), null, e);
+    }
+
+    @Override
+    public FirebaseException handleHttpResponseException(
+        HttpResponseException e, IncomingHttpResponse response) {
+      return new FirebaseException(
+          ErrorCode.INTERNAL, "Example error message: " + e.getContent(), response, e);
+    }
+
+    @Override
+    public FirebaseException handleParseException(IOException e, IncomingHttpResponse response) {
+      return new FirebaseException(ErrorCode.UNKNOWN, "Parse error", response, e);
+    }
+  }
+}