Repository: firebase/firebase-admin-java
Pull request #266 (2019-04-18 22:09:05)

Enabling automatic retries for FCM API Calls
Enables automatic retries for FCM API calls. Retries are performed on all 500 and 503 responses.

* Maximum 4 retries (up to a total of 5 attempts per request)
* Retry delay determined by the `Retry-After` header when available (but never exceeds 1 minute)
* Otherwise retries delayed by exponential backoff (500ms, 1s, 2s and 4s respectively)

Continuation of #255



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cea3b25..7417de1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 # Unreleased
 
--
+- [fixed] Enabled automatic retries for FCM API calls failing with
+  HTTP 500 and 503 errors.
 
 # v6.8.0
 
diff --git a/src/main/java/com/google/firebase/internal/ApiClientUtils.java b/src/main/java/com/google/firebase/internal/ApiClientUtils.java
index 62e4320..7506ff8 100644
--- a/src/main/java/com/google/firebase/internal/ApiClientUtils.java
+++ b/src/main/java/com/google/firebase/internal/ApiClientUtils.java
@@ -19,6 +19,7 @@ package com.google.firebase.internal;
 import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpResponse;
 import com.google.api.client.http.HttpTransport;
+import com.google.common.collect.ImmutableList;
 import com.google.firebase.FirebaseApp;
 
 import java.io.IOException;
@@ -28,9 +29,23 @@ import java.io.IOException;
  */
 public class ApiClientUtils {
 
+  private static final RetryConfig DEFAULT_RETRY_CONFIG = RetryConfig.builder()
+      .setMaxRetries(4)
+      .setRetryStatusCodes(ImmutableList.of(500, 503))
+      .setMaxIntervalMillis(60 * 1000)
+      .build();
+
+  /**
+   * Creates a new {@code HttpRequestFactory} which provides authorization (OAuth2), timeouts and
+   * automatic retries.
+   *
+   * @param app {@link FirebaseApp} from which to obtain authorization credentials.
+   * @return A new {@code HttpRequestFactory} instance.
+   */
   public static HttpRequestFactory newAuthorizedRequestFactory(FirebaseApp app) {
     HttpTransport transport = app.getOptions().getHttpTransport();
-    return transport.createRequestFactory(new FirebaseRequestInitializer(app));
+    return transport.createRequestFactory(
+        new FirebaseRequestInitializer(app, DEFAULT_RETRY_CONFIG));
   }
 
   public static HttpRequestFactory newUnauthorizedRequestFactory(FirebaseApp app) {


Test patch:
diff --git a/src/test/java/com/google/firebase/internal/ApiClientUtilsTest.java b/src/test/java/com/google/firebase/internal/ApiClientUtilsTest.java
new file mode 100644
index 0000000..78eabaf
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/ApiClientUtilsTest.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2019 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.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.http.GenericUrl;
+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.HttpUnsuccessfulResponseHandler;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.internal.RetryInitializer.RetryHandlerDecorator;
+import java.io.IOException;
+import org.junit.After;
+import org.junit.Test;
+
+public class ApiClientUtilsTest {
+
+  private static final FirebaseOptions TEST_OPTIONS = FirebaseOptions.builder()
+      .setCredentials(new MockGoogleCredentials("test-token"))
+      .build();
+  private static final GenericUrl TEST_URL = new GenericUrl("https://firebase.google.com");
+
+  @After
+  public void tearDown() {
+    TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+  }
+
+  @Test
+  public void testAuthorizedHttpClient() throws IOException {
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS);
+
+    HttpRequestFactory requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app);
+
+    assertTrue(requestFactory.getInitializer() instanceof FirebaseRequestInitializer);
+    HttpRequest request = requestFactory.buildGetRequest(TEST_URL);
+    assertEquals("Bearer test-token", request.getHeaders().getAuthorization());
+    HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+    assertTrue(retryHandler instanceof RetryHandlerDecorator);
+    RetryConfig retryConfig = ((RetryHandlerDecorator) retryHandler).getRetryHandler()
+        .getRetryConfig();
+    assertEquals(4, retryConfig.getMaxRetries());
+    assertEquals(60 * 1000, retryConfig.getMaxIntervalMillis());
+    assertFalse(retryConfig.isRetryOnIOExceptions());
+    assertEquals(retryConfig.getRetryStatusCodes(), ImmutableList.of(500, 503));
+  }
+
+  @Test
+  public void testUnauthorizedHttpClient() throws IOException {
+    FirebaseApp app = FirebaseApp.initializeApp(TEST_OPTIONS);
+
+    HttpRequestFactory requestFactory = ApiClientUtils.newUnauthorizedRequestFactory(app);
+
+    assertNull(requestFactory.getInitializer());
+    HttpRequest request = requestFactory.buildGetRequest(TEST_URL);
+    assertNull(request.getHeaders().getAuthorization());
+    HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+    assertNull(retryHandler);
+  }
+
+  @Test
+  public void testDisconnect() throws IOException {
+    MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse();
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(lowLevelResponse)
+        .build();
+    HttpResponse response = transport.createRequestFactory().buildGetRequest(TEST_URL).execute();
+    assertFalse(lowLevelResponse.isDisconnected());
+
+    ApiClientUtils.disconnectQuietly(response);
+
+    assertTrue(lowLevelResponse.isDisconnected());
+  }
+
+  @Test
+  public void testDisconnectWithErrorSuppression() throws IOException {
+    MockLowLevelHttpResponse lowLevelResponse = new MockLowLevelHttpResponse(){
+      @Override
+      public void disconnect() throws IOException {
+        super.disconnect();
+        throw new IOException("test error");
+      }
+    };
+    MockHttpTransport transport = new MockHttpTransport.Builder()
+        .setLowLevelHttpResponse(lowLevelResponse)
+        .build();
+    HttpResponse response = transport.createRequestFactory().buildGetRequest(TEST_URL).execute();
+    assertFalse(lowLevelResponse.isDisconnected());
+
+    ApiClientUtils.disconnectQuietly(response);
+
+    assertTrue(lowLevelResponse.isDisconnected());
+  }
+}