diff --git a/NOTICE.txt b/NOTICE.txt
new file mode 100644
index 000000000..b6c5d02b2
--- /dev/null
+++ b/NOTICE.txt
@@ -0,0 +1,5 @@
+Firebase Admin Java SDK
+Copyright 2019 Google Inc.
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/src/main/java/com/google/firebase/internal/DateUtils.java b/src/main/java/com/google/firebase/internal/DateUtils.java
new file mode 100644
index 000000000..6c4eeb7b0
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/DateUtils.java
@@ -0,0 +1,108 @@
+/*
+ * 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 java.text.ParsePosition;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * A utility class for parsing and formatting HTTP dates as used in cookies and
+ * other headers. This class handles dates as defined by RFC 2616 section
+ * 3.3.1 as well as some other common non-standard formats.
+ *
+ *
Most of this class was borrowed from the
+ *
+ * Apache HTTP client in order to avoid a direct dependency on it. We currently
+ * have a transitive dependency on this library (via Google API client), but the API
+ * client team is working towards removing it, so we won't have it in the classpath for long.
+ *
+ *
The original implementation of this class uses
+ * thread locals to cache the {@code SimpleDateFormat} instances. Instead, this implementation
+ * uses static constants and explicit locking to ensure thread safety. This is probably slower,
+ * but also simpler and avoids memory leaks that may result from unreleased thread locals.
+ */
+final class DateUtils {
+
+ /**
+ * Date format pattern used to parse HTTP date headers in RFC 1123 format.
+ */
+ static final String PATTERN_RFC1123 = "EEE, dd MMM yyyy HH:mm:ss zzz";
+
+ /**
+ * Date format pattern used to parse HTTP date headers in RFC 1036 format.
+ */
+ static final String PATTERN_RFC1036 = "EEE, dd-MMM-yy HH:mm:ss zzz";
+
+ /**
+ * Date format pattern used to parse HTTP date headers in ANSI C
+ * {@code asctime()} format.
+ */
+ static final String PATTERN_ASCTIME = "EEE MMM d HH:mm:ss yyyy";
+
+ private static final SimpleDateFormat[] DEFAULT_PATTERNS = new SimpleDateFormat[] {
+ new SimpleDateFormat(PATTERN_RFC1123),
+ new SimpleDateFormat(PATTERN_RFC1036),
+ new SimpleDateFormat(PATTERN_ASCTIME)
+ };
+
+ static final TimeZone GMT = TimeZone.getTimeZone("GMT");
+
+ static {
+ final Calendar calendar = Calendar.getInstance();
+ calendar.setTimeZone(GMT);
+ calendar.set(2000, Calendar.JANUARY, 1, 0, 0, 0);
+ calendar.set(Calendar.MILLISECOND, 0);
+ final Date defaultTwoDigitYearStart = calendar.getTime();
+
+ for (final SimpleDateFormat datePattern : DEFAULT_PATTERNS) {
+ datePattern.set2DigitYearStart(defaultTwoDigitYearStart);
+ }
+ }
+
+ /**
+ * Parses the date value using the given date formats.
+ *
+ * @param dateValue the date value to parse
+ * @return the parsed date or null if input could not be parsed
+ */
+ public static Date parseDate(final String dateValue) {
+ String v = checkNotNull(dateValue);
+ // trim single quotes around date if present
+ // see issue #5279
+ if (v.length() > 1 && v.startsWith("'") && v.endsWith("'")) {
+ v = v.substring(1, v.length() - 1);
+ }
+
+ for (final SimpleDateFormat datePattern : DEFAULT_PATTERNS) {
+ final ParsePosition pos = new ParsePosition(0);
+ synchronized (datePattern) {
+ final Date result = datePattern.parse(v, pos);
+ if (pos.getIndex() != 0) {
+ return result;
+ }
+ }
+ }
+ return null;
+ }
+
+ /** This class should not be instantiated. */
+ private DateUtils() {
+ }
+}
diff --git a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java
index 0cd4e9393..e73f93953 100644
--- a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java
+++ b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java
@@ -19,32 +19,57 @@
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.auth.http.HttpCredentialsAdapter;
-import com.google.auth.oauth2.GoogleCredentials;
+import com.google.common.collect.ImmutableList;
import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
import com.google.firebase.ImplFirebaseTrampolines;
import java.io.IOException;
+import java.util.List;
/**
- * {@code HttpRequestInitializer} for configuring outgoing REST calls. Handles OAuth2 authorization
- * and setting timeout values.
+ * {@code HttpRequestInitializer} for configuring outgoing REST calls. Initializes requests with
+ * OAuth2 credentials, timeout and retry settings.
*/
-public class FirebaseRequestInitializer implements HttpRequestInitializer {
+public final class FirebaseRequestInitializer implements HttpRequestInitializer {
- private final HttpCredentialsAdapter credentialsAdapter;
- private final int connectTimeout;
- private final int readTimeout;
+ private final List initializers;
public FirebaseRequestInitializer(FirebaseApp app) {
- GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(app);
- this.credentialsAdapter = new HttpCredentialsAdapter(credentials);
- this.connectTimeout = app.getOptions().getConnectTimeout();
- this.readTimeout = app.getOptions().getReadTimeout();
+ this(app, null);
+ }
+
+ public FirebaseRequestInitializer(FirebaseApp app, @Nullable RetryConfig retryConfig) {
+ ImmutableList.Builder initializers =
+ ImmutableList.builder()
+ .add(new HttpCredentialsAdapter(ImplFirebaseTrampolines.getCredentials(app)))
+ .add(new TimeoutInitializer(app.getOptions()));
+ if (retryConfig != null) {
+ initializers.add(new RetryInitializer(retryConfig));
+ }
+ this.initializers = initializers.build();
}
@Override
- public void initialize(HttpRequest httpRequest) throws IOException {
- credentialsAdapter.initialize(httpRequest);
- httpRequest.setConnectTimeout(connectTimeout);
- httpRequest.setReadTimeout(readTimeout);
+ public void initialize(HttpRequest request) throws IOException {
+ for (HttpRequestInitializer initializer : initializers) {
+ initializer.initialize(request);
+ }
+ }
+
+ private static class TimeoutInitializer implements HttpRequestInitializer {
+
+ private final int connectTimeoutMillis;
+ private final int readTimeoutMillis;
+
+ TimeoutInitializer(FirebaseOptions options) {
+ this.connectTimeoutMillis = options.getConnectTimeout();
+ this.readTimeoutMillis = options.getReadTimeout();
+ }
+
+ @Override
+ public void initialize(HttpRequest request) {
+ request.setConnectTimeout(connectTimeoutMillis);
+ request.setReadTimeout(readTimeoutMillis);
+ }
}
}
diff --git a/src/main/java/com/google/firebase/internal/RetryConfig.java b/src/main/java/com/google/firebase/internal/RetryConfig.java
new file mode 100644
index 000000000..a17780ed0
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/RetryConfig.java
@@ -0,0 +1,177 @@
+/*
+ * 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 com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.util.BackOff;
+import com.google.api.client.util.ExponentialBackOff;
+import com.google.api.client.util.Sleeper;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Configures when and how HTTP requests should be retried.
+ */
+public final class RetryConfig {
+
+ private static final int INITIAL_INTERVAL_MILLIS = 500;
+
+ private final List retryStatusCodes;
+ private final boolean retryOnIOExceptions;
+ private final int maxRetries;
+ private final Sleeper sleeper;
+ private final ExponentialBackOff.Builder backOffBuilder;
+
+ private RetryConfig(Builder builder) {
+ if (builder.retryStatusCodes != null) {
+ this.retryStatusCodes = ImmutableList.copyOf(builder.retryStatusCodes);
+ } else {
+ this.retryStatusCodes = ImmutableList.of();
+ }
+
+ this.retryOnIOExceptions = builder.retryOnIOExceptions;
+ checkArgument(builder.maxRetries >= 0, "maxRetries must not be negative");
+ this.maxRetries = builder.maxRetries;
+ this.sleeper = checkNotNull(builder.sleeper);
+ this.backOffBuilder = new ExponentialBackOff.Builder()
+ .setInitialIntervalMillis(INITIAL_INTERVAL_MILLIS)
+ .setMaxIntervalMillis(builder.maxIntervalMillis)
+ .setMultiplier(builder.backOffMultiplier)
+ .setRandomizationFactor(0);
+
+ // Force validation of arguments by building the BackOff object
+ this.backOffBuilder.build();
+ }
+
+ List getRetryStatusCodes() {
+ return retryStatusCodes;
+ }
+
+ boolean isRetryOnIOExceptions() {
+ return retryOnIOExceptions;
+ }
+
+ int getMaxRetries() {
+ return maxRetries;
+ }
+
+ int getMaxIntervalMillis() {
+ return backOffBuilder.getMaxIntervalMillis();
+ }
+
+ double getBackOffMultiplier() {
+ return backOffBuilder.getMultiplier();
+ }
+
+ Sleeper getSleeper() {
+ return sleeper;
+ }
+
+ BackOff newBackOff() {
+ return backOffBuilder.build();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+
+ private List retryStatusCodes;
+ private boolean retryOnIOExceptions;
+ private int maxRetries;
+ private int maxIntervalMillis = (int) TimeUnit.MINUTES.toMillis(2);
+ private double backOffMultiplier = 2.0;
+ private Sleeper sleeper = Sleeper.DEFAULT;
+
+ private Builder() { }
+
+ /**
+ * Sets a list of HTTP status codes that should be retried. If null or empty, HTTP requests
+ * will not be retried as long as they result in some HTTP response message.
+ *
+ * @param retryStatusCodes A list of status codes.
+ * @return This builder.
+ */
+ public Builder setRetryStatusCodes(List retryStatusCodes) {
+ this.retryStatusCodes = retryStatusCodes;
+ return this;
+ }
+
+ /**
+ * Sets whether requests should be retried on IOExceptions.
+ *
+ * @param retryOnIOExceptions A boolean indicating whether to retry on IOExceptions.
+ * @return This builder.
+ */
+ public Builder setRetryOnIOExceptions(boolean retryOnIOExceptions) {
+ this.retryOnIOExceptions = retryOnIOExceptions;
+ return this;
+ }
+
+ /**
+ * Maximum number of retry attempts for a request. This is the cumulative total for all retries
+ * regardless of their cause (I/O errors and HTTP error responses).
+ *
+ * @param maxRetries A non-negative integer.
+ * @return This builder.
+ */
+ public Builder setMaxRetries(int maxRetries) {
+ this.maxRetries = maxRetries;
+ return this;
+ }
+
+ /**
+ * Maximum interval to wait before a request should be retried. Must be at least 500
+ * milliseconds. Defaults to 2 minutes.
+ *
+ * @param maxIntervalMillis Interval in milliseconds.
+ * @return This builder.
+ */
+ public Builder setMaxIntervalMillis(int maxIntervalMillis) {
+ this.maxIntervalMillis = maxIntervalMillis;
+ return this;
+ }
+
+ /**
+ * Factor by which the retry interval is multiplied when employing exponential back
+ * off to delay consecutive retries of the same request. Must be at least 1. Defaults
+ * to 2.
+ *
+ * @param backOffMultiplier Multiplication factor for exponential back off.
+ * @return This builder.
+ */
+ public Builder setBackOffMultiplier(double backOffMultiplier) {
+ this.backOffMultiplier = backOffMultiplier;
+ return this;
+ }
+
+ @VisibleForTesting
+ Builder setSleeper(Sleeper sleeper) {
+ this.sleeper = sleeper;
+ return this;
+ }
+
+ public RetryConfig build() {
+ return new RetryConfig(this);
+ }
+ }
+}
diff --git a/src/main/java/com/google/firebase/internal/RetryInitializer.java b/src/main/java/com/google/firebase/internal/RetryInitializer.java
new file mode 100644
index 000000000..fbe0a13ea
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/RetryInitializer.java
@@ -0,0 +1,112 @@
+/*
+ * 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 com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
+import com.google.api.client.http.HttpIOExceptionHandler;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpRequestInitializer;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
+import java.io.IOException;
+
+/**
+ * Configures HTTP requests to be retried. Requests that encounter I/O errors are retried if
+ * {@link RetryConfig#isRetryOnIOExceptions()} is set. Requests failing with unsuccessful HTTP
+ * responses are first referred to the {@code HttpUnsuccessfulResponseHandler} that was originally
+ * set on the request. If the request does not get retried at that level,
+ * {@link RetryUnsuccessfulResponseHandler} is used to schedule additional retries.
+ */
+final class RetryInitializer implements HttpRequestInitializer {
+
+ private final RetryConfig retryConfig;
+
+ RetryInitializer(RetryConfig retryConfig) {
+ this.retryConfig = checkNotNull(retryConfig);
+ }
+
+ @Override
+ public void initialize(HttpRequest request) {
+ request.setNumberOfRetries(retryConfig.getMaxRetries());
+ request.setUnsuccessfulResponseHandler(newUnsuccessfulResponseHandler(request));
+ if (retryConfig.isRetryOnIOExceptions()) {
+ request.setIOExceptionHandler(newIOExceptionHandler());
+ }
+ }
+
+ private HttpUnsuccessfulResponseHandler newUnsuccessfulResponseHandler(HttpRequest request) {
+ RetryUnsuccessfulResponseHandler retryHandler = new RetryUnsuccessfulResponseHandler(
+ retryConfig);
+ return new RetryHandlerDecorator(retryHandler, request);
+ }
+
+ private HttpIOExceptionHandler newIOExceptionHandler() {
+ return new HttpBackOffIOExceptionHandler(retryConfig.newBackOff())
+ .setSleeper(retryConfig.getSleeper());
+ }
+
+ /**
+ * Makes sure that any error handlers already set on the request are executed before the retry
+ * handler is called. This is needed since some initializers (e.g. HttpCredentialsAdapter)
+ * register their own error handlers.
+ */
+ static class RetryHandlerDecorator implements HttpUnsuccessfulResponseHandler {
+
+ private final RetryUnsuccessfulResponseHandler retryHandler;
+ private final HttpUnsuccessfulResponseHandler preRetryHandler;
+
+ private RetryHandlerDecorator(
+ RetryUnsuccessfulResponseHandler retryHandler, HttpRequest request) {
+ this.retryHandler = checkNotNull(retryHandler);
+ HttpUnsuccessfulResponseHandler preRetryHandler = request.getUnsuccessfulResponseHandler();
+ if (preRetryHandler == null) {
+ preRetryHandler = new HttpUnsuccessfulResponseHandler() {
+ @Override
+ public boolean handleResponse(
+ HttpRequest request, HttpResponse response, boolean supportsRetry) {
+ return false;
+ }
+ };
+ }
+ this.preRetryHandler = preRetryHandler;
+ }
+
+ @Override
+ public boolean handleResponse(
+ HttpRequest request,
+ HttpResponse response,
+ boolean supportsRetry) throws IOException {
+ try {
+ boolean retry = preRetryHandler.handleResponse(request, response, supportsRetry);
+ if (!retry) {
+ retry = retryHandler.handleResponse(request, response, supportsRetry);
+ }
+ return retry;
+ } finally {
+ // Pre-retry handler may have reset the unsuccessful response handler on the
+ // request. This changes it back.
+ request.setUnsuccessfulResponseHandler(this);
+ }
+ }
+
+ RetryUnsuccessfulResponseHandler getRetryHandler() {
+ return retryHandler;
+ }
+ }
+}
diff --git a/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java b/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java
new file mode 100644
index 000000000..dd00d2bfb
--- /dev/null
+++ b/src/main/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandler.java
@@ -0,0 +1,112 @@
+/*
+ * 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 com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
+import com.google.api.client.util.BackOff;
+import com.google.api.client.util.BackOffUtils;
+import com.google.api.client.util.Clock;
+import com.google.api.client.util.Sleeper;
+import com.google.common.base.Strings;
+import java.io.IOException;
+import java.util.Date;
+
+/**
+ * An {@code HttpUnsuccessfulResponseHandler} that retries failing requests after an interval. The
+ * interval is determined by checking the Retry-After header on the last response. If that
+ * header is not present, uses exponential back off to delay subsequent retries.
+ */
+final class RetryUnsuccessfulResponseHandler implements HttpUnsuccessfulResponseHandler {
+
+ private final RetryConfig retryConfig;
+ private final BackOff backOff;
+ private final Sleeper sleeper;
+ private final Clock clock;
+
+ RetryUnsuccessfulResponseHandler(RetryConfig retryConfig) {
+ this(retryConfig, Clock.SYSTEM);
+ }
+
+ RetryUnsuccessfulResponseHandler(RetryConfig retryConfig, Clock clock) {
+ this.retryConfig = checkNotNull(retryConfig);
+ this.backOff = retryConfig.newBackOff();
+ this.sleeper = retryConfig.getSleeper();
+ this.clock = checkNotNull(clock);
+ }
+
+ @Override
+ public boolean handleResponse(
+ HttpRequest request, HttpResponse response, boolean supportsRetry) throws IOException {
+
+ if (!supportsRetry) {
+ return false;
+ }
+
+ int statusCode = response.getStatusCode();
+ if (!retryConfig.getRetryStatusCodes().contains(statusCode)) {
+ return false;
+ }
+
+ try {
+ return waitAndRetry(response);
+ } catch (InterruptedException e) {
+ // ignore
+ }
+ return false;
+ }
+
+ RetryConfig getRetryConfig() {
+ return retryConfig;
+ }
+
+ private boolean waitAndRetry(HttpResponse response) throws IOException, InterruptedException {
+ String retryAfterHeader = response.getHeaders().getRetryAfter();
+ if (!Strings.isNullOrEmpty(retryAfterHeader)) {
+ long intervalMillis = parseRetryAfterHeaderIntoMillis(retryAfterHeader.trim());
+ // Retry-after header can specify very long delay intervals (e.g. 24 hours). If we cannot
+ // wait that long, we should not perform any retries at all. In general it is not correct to
+ // retry earlier than what the server has recommended to us.
+ if (intervalMillis > retryConfig.getMaxIntervalMillis()) {
+ return false;
+ }
+
+ if (intervalMillis > 0) {
+ sleeper.sleep(intervalMillis);
+ return true;
+ }
+ }
+
+ return BackOffUtils.next(sleeper, backOff);
+ }
+
+ private long parseRetryAfterHeaderIntoMillis(String retryAfter) {
+ try {
+ return Long.parseLong(retryAfter) * 1000;
+ } catch (NumberFormatException e) {
+ Date date = DateUtils.parseDate(retryAfter);
+ if (date != null) {
+ return date.getTime() - clock.currentTimeMillis();
+ }
+ }
+
+ return -1L;
+ }
+}
diff --git a/src/test/java/com/google/firebase/internal/CountingLowLevelHttpRequest.java b/src/test/java/com/google/firebase/internal/CountingLowLevelHttpRequest.java
new file mode 100644
index 000000000..f2a4b9f5a
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/CountingLowLevelHttpRequest.java
@@ -0,0 +1,74 @@
+/*
+ * 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 com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import java.io.IOException;
+import java.util.Map;
+
+class CountingLowLevelHttpRequest extends MockLowLevelHttpRequest {
+
+ private final LowLevelHttpResponse response;
+ private final IOException exception;
+ private int count;
+
+ private CountingLowLevelHttpRequest(LowLevelHttpResponse response, IOException exception) {
+ this.response = response;
+ this.exception = exception;
+ }
+
+ static CountingLowLevelHttpRequest fromStatus(int status) {
+ return fromStatus(status, null);
+ }
+
+ static CountingLowLevelHttpRequest fromStatus(int status, Map headers) {
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse()
+ .setStatusCode(status)
+ .setZeroContent();
+ if (headers != null) {
+ for (Map.Entry entry : headers.entrySet()) {
+ response.addHeader(entry.getKey(), entry.getValue());
+ }
+ }
+ return fromStatus(response);
+ }
+
+ static CountingLowLevelHttpRequest fromStatus(LowLevelHttpResponse response) {
+ return new CountingLowLevelHttpRequest(checkNotNull(response), null);
+ }
+
+ static CountingLowLevelHttpRequest fromException(IOException exception) {
+ return new CountingLowLevelHttpRequest(null, checkNotNull(exception));
+ }
+
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ count++;
+ if (response != null) {
+ return response;
+ }
+ throw exception;
+ }
+
+ int getCount() {
+ return count;
+ }
+}
diff --git a/src/test/java/com/google/firebase/internal/DateUtilsTest.java b/src/test/java/com/google/firebase/internal/DateUtilsTest.java
new file mode 100644
index 000000000..7c2dfd089
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/DateUtilsTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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 java.util.Calendar;
+import java.util.Date;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Unit tests for the {@link DateUtils}. Adapted from the tests available in the
+ *
+ * Apache HTTP client library.
+ */
+public class DateUtilsTest {
+
+ @Test
+ public void testBasicDateParse() {
+ final Calendar calendar = Calendar.getInstance();
+ calendar.setTimeZone(DateUtils.GMT);
+ calendar.set(2005, Calendar.OCTOBER, 14, 0, 0, 0);
+ calendar.set(Calendar.MILLISECOND, 0);
+ final Date date1 = calendar.getTime();
+
+ Date date2 = DateUtils.parseDate("Fri, 14 Oct 2005 00:00:00 GMT");
+ Assert.assertEquals(date1, date2);
+ date2 = DateUtils.parseDate("Fri, 14 Oct 2005 00:00:00 GMT");
+ Assert.assertEquals(date1, date2);
+ date2 = DateUtils.parseDate("Fri, 14 Oct 2005 00:00:00 GMT");
+ Assert.assertEquals(date1, date2);
+ }
+
+ @Test
+ public void testInvalidInput() {
+ try {
+ DateUtils.parseDate(null);
+ Assert.fail("NullPointerException should have been thrown");
+ } catch (NullPointerException ex) {
+ // expected
+ }
+ }
+
+ @Test
+ public void testTwoDigitYearDateParse() {
+ final Calendar calendar = Calendar.getInstance();
+ calendar.setTimeZone(DateUtils.GMT);
+ calendar.set(2005, Calendar.OCTOBER, 14, 0, 0, 0);
+ calendar.set(Calendar.MILLISECOND, 0);
+ Date date1 = calendar.getTime();
+
+ Date date2 = DateUtils.parseDate("Friday, 14-Oct-05 00:00:00 GMT");
+ Assert.assertEquals(date1, date2);
+ }
+
+ @Test
+ public void testParseQuotedDate() {
+ final Calendar calendar = Calendar.getInstance();
+ calendar.setTimeZone(DateUtils.GMT);
+ calendar.set(2005, Calendar.OCTOBER, 14, 0, 0, 0);
+ calendar.set(Calendar.MILLISECOND, 0);
+ final Date date1 = calendar.getTime();
+
+ final Date date2 = DateUtils.parseDate("'Fri, 14 Oct 2005 00:00:00 GMT'");
+ Assert.assertEquals(date1, date2);
+ }
+}
diff --git a/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java b/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java
index c7919eb97..1f610f5e3 100644
--- a/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java
+++ b/src/test/java/com/google/firebase/internal/FirebaseRequestInitializerTest.java
@@ -17,55 +17,136 @@
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.assertTrue;
-import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
import com.google.api.client.http.HttpRequest;
-import com.google.api.client.http.HttpRequestFactory;
-import com.google.api.client.http.HttpTransport;
-import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.http.HttpResponseException;
+import com.google.auth.http.HttpCredentialsAdapter;
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.testing.TestUtils;
import org.junit.After;
import org.junit.Test;
public class FirebaseRequestInitializerTest {
+ private static final int MAX_RETRIES = 5;
+ private static final int CONNECT_TIMEOUT_MILLIS = 30000;
+ private static final int READ_TIMEOUT_MILLIS = 60000;
+
@After
public void tearDown() {
TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
}
@Test
- public void testDefaultTimeouts() throws Exception {
+ public void testDefaultSettings() throws Exception {
FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
.setCredentials(new MockGoogleCredentials("token"))
.build());
- HttpTransport transport = new MockHttpTransport();
- HttpRequestFactory factory = transport.createRequestFactory(
- new FirebaseRequestInitializer(app));
- HttpRequest request = factory.buildGetRequest(
- new GenericUrl("https://firebase.google.com"));
+ HttpRequest request = TestUtils.createRequest();
+
+ FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app);
+ initializer.initialize(request);
+
+
assertEquals(0, request.getConnectTimeout());
assertEquals(0, request.getReadTimeout());
assertEquals("Bearer token", request.getHeaders().getAuthorization());
+ assertEquals(HttpRequest.DEFAULT_NUMBER_OF_RETRIES, request.getNumberOfRetries());
+ assertNull(request.getIOExceptionHandler());
+ assertTrue(request.getUnsuccessfulResponseHandler() instanceof HttpCredentialsAdapter);
}
@Test
public void testExplicitTimeouts() throws Exception {
FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
.setCredentials(new MockGoogleCredentials("token"))
- .setConnectTimeout(30000)
- .setReadTimeout(60000)
+ .setConnectTimeout(CONNECT_TIMEOUT_MILLIS)
+ .setReadTimeout(READ_TIMEOUT_MILLIS)
.build());
- HttpTransport transport = new MockHttpTransport();
- HttpRequestFactory factory = transport.createRequestFactory(
- new FirebaseRequestInitializer(app));
- HttpRequest request = factory.buildGetRequest(
- new GenericUrl("https://firebase.google.com"));
- assertEquals(30000, request.getConnectTimeout());
- assertEquals(60000, request.getReadTimeout());
+ HttpRequest request = TestUtils.createRequest();
+
+ FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app);
+ initializer.initialize(request);
+
+ assertEquals(CONNECT_TIMEOUT_MILLIS, request.getConnectTimeout());
+ assertEquals(READ_TIMEOUT_MILLIS, request.getReadTimeout());
+ assertEquals("Bearer token", request.getHeaders().getAuthorization());
+ assertEquals(HttpRequest.DEFAULT_NUMBER_OF_RETRIES, request.getNumberOfRetries());
+ assertNull(request.getIOExceptionHandler());
+ assertTrue(request.getUnsuccessfulResponseHandler() instanceof HttpCredentialsAdapter);
+ }
+
+ @Test
+ public void testRetryConfig() throws Exception {
+ FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+ .setCredentials(new MockGoogleCredentials("token"))
+ .build());
+ RetryConfig retryConfig = RetryConfig.builder()
+ .setMaxRetries(MAX_RETRIES)
+ .build();
+ HttpRequest request = TestUtils.createRequest();
+
+ FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app, retryConfig);
+ initializer.initialize(request);
+
+ assertEquals(0, request.getConnectTimeout());
+ assertEquals(0, request.getReadTimeout());
+ assertEquals("Bearer token", request.getHeaders().getAuthorization());
+ assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+ assertNull(request.getIOExceptionHandler());
+ assertNotNull(request.getUnsuccessfulResponseHandler());
+ }
+
+ @Test
+ public void testRetryConfigWithIOExceptionHandling() throws Exception {
+ FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+ .setCredentials(new MockGoogleCredentials("token"))
+ .build());
+ RetryConfig retryConfig = RetryConfig.builder()
+ .setMaxRetries(MAX_RETRIES)
+ .setRetryOnIOExceptions(true)
+ .build();
+ HttpRequest request = TestUtils.createRequest();
+
+ FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app, retryConfig);
+ initializer.initialize(request);
+
+ assertEquals(0, request.getConnectTimeout());
+ assertEquals(0, request.getReadTimeout());
+ assertEquals("Bearer token", request.getHeaders().getAuthorization());
+ assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+ assertTrue(request.getIOExceptionHandler() instanceof HttpBackOffIOExceptionHandler);
+ assertNotNull(request.getUnsuccessfulResponseHandler());
+ }
+
+ @Test
+ public void testCredentialsRetryHandler() throws Exception {
+ FirebaseApp app = FirebaseApp.initializeApp(new FirebaseOptions.Builder()
+ .setCredentials(new MockGoogleCredentials("token"))
+ .build());
+ RetryConfig retryConfig = RetryConfig.builder()
+ .setMaxRetries(MAX_RETRIES)
+ .build();
+ CountingLowLevelHttpRequest countingRequest = CountingLowLevelHttpRequest.fromStatus(401);
+ HttpRequest request = TestUtils.createRequest(countingRequest);
+ FirebaseRequestInitializer initializer = new FirebaseRequestInitializer(app, retryConfig);
+ initializer.initialize(request);
+ request.getHeaders().setAuthorization((String) null);
+
+ try {
+ request.execute();
+ } catch (HttpResponseException e) {
+ assertEquals(401, e.getStatusCode());
+ }
+
assertEquals("Bearer token", request.getHeaders().getAuthorization());
+ assertEquals(MAX_RETRIES + 1, countingRequest.getCount());
}
}
diff --git a/src/test/java/com/google/firebase/internal/RetryConfigTest.java b/src/test/java/com/google/firebase/internal/RetryConfigTest.java
new file mode 100644
index 000000000..7fab5075d
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/RetryConfigTest.java
@@ -0,0 +1,126 @@
+/*
+ * 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.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.client.testing.util.MockSleeper;
+import com.google.api.client.util.BackOff;
+import com.google.api.client.util.ExponentialBackOff;
+import com.google.api.client.util.Sleeper;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import org.junit.Test;
+
+public class RetryConfigTest {
+
+ @Test
+ public void testEmptyBuilder() {
+ RetryConfig config = RetryConfig.builder().build();
+
+ assertTrue(config.getRetryStatusCodes().isEmpty());
+ assertEquals(0, config.getMaxRetries());
+ assertEquals(2 * 60 * 1000, config.getMaxIntervalMillis());
+ assertEquals(2.0, config.getBackOffMultiplier(), 0.01);
+ assertSame(Sleeper.DEFAULT, config.getSleeper());
+
+ ExponentialBackOff backOff = (ExponentialBackOff) config.newBackOff();
+ assertEquals(2 * 60 * 1000, backOff.getMaxIntervalMillis());
+ assertEquals(2.0, backOff.getMultiplier(), 0.01);
+ assertEquals(500, backOff.getInitialIntervalMillis());
+ assertEquals(0.0, backOff.getRandomizationFactor(), 0.01);
+ assertNotSame(backOff, config.newBackOff());
+ }
+
+ @Test
+ public void testBuilderWithAllSettings() {
+ ImmutableList statusCodes = ImmutableList.of(500, 503);
+ Sleeper sleeper = new MockSleeper();
+ RetryConfig config = RetryConfig.builder()
+ .setMaxRetries(4)
+ .setRetryStatusCodes(statusCodes)
+ .setRetryOnIOExceptions(true)
+ .setMaxIntervalMillis(5 * 60 * 1000)
+ .setBackOffMultiplier(1.5)
+ .setSleeper(sleeper)
+ .build();
+
+ assertEquals(2, config.getRetryStatusCodes().size());
+ assertEquals(statusCodes.get(0), config.getRetryStatusCodes().get(0));
+ assertEquals(statusCodes.get(1), config.getRetryStatusCodes().get(1));
+ assertTrue(config.isRetryOnIOExceptions());
+ assertEquals(4, config.getMaxRetries());
+ assertEquals(5 * 60 * 1000, config.getMaxIntervalMillis());
+ assertEquals(1.5, config.getBackOffMultiplier(), 0.01);
+ assertSame(sleeper, config.getSleeper());
+
+ ExponentialBackOff backOff = (ExponentialBackOff) config.newBackOff();
+ assertEquals(500, backOff.getInitialIntervalMillis());
+ assertEquals(5 * 60 * 1000, backOff.getMaxIntervalMillis());
+ assertEquals(1.5, backOff.getMultiplier(), 0.01);
+ assertEquals(0.0, backOff.getRandomizationFactor(), 0.01);
+ assertNotSame(backOff, config.newBackOff());
+ }
+
+ @Test
+ public void testExponentialBackOff() throws IOException {
+ RetryConfig config = RetryConfig.builder()
+ .setMaxIntervalMillis(12000)
+ .build();
+
+ BackOff backOff = config.newBackOff();
+
+ assertEquals(500, backOff.nextBackOffMillis());
+ assertEquals(1000, backOff.nextBackOffMillis());
+ assertEquals(2000, backOff.nextBackOffMillis());
+ assertEquals(4000, backOff.nextBackOffMillis());
+ assertEquals(8000, backOff.nextBackOffMillis());
+ assertEquals(12000, backOff.nextBackOffMillis());
+ assertEquals(12000, backOff.nextBackOffMillis());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testNegativeMaxRetriesNotAllowed() {
+ RetryConfig.builder()
+ .setMaxRetries(-1)
+ .build();
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testMaxIntervalMillisTooSmall() {
+ RetryConfig.builder()
+ .setMaxIntervalMillis(499)
+ .build();
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testBackOffMultiplierTooSmall() {
+ RetryConfig.builder()
+ .setBackOffMultiplier(0.99)
+ .build();
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testSleeperCannotBeNull() {
+ RetryConfig.builder()
+ .setSleeper(null)
+ .build();
+ }
+}
diff --git a/src/test/java/com/google/firebase/internal/RetryInitializerTest.java b/src/test/java/com/google/firebase/internal/RetryInitializerTest.java
new file mode 100644
index 000000000..8732e8bf5
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/RetryInitializerTest.java
@@ -0,0 +1,244 @@
+/*
+ * 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.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.http.HttpBackOffIOExceptionHandler;
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.client.testing.util.MockSleeper;
+import com.google.api.client.util.Sleeper;
+import com.google.auth.http.HttpCredentialsAdapter;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.auth.MockGoogleCredentials;
+import com.google.firebase.internal.RetryInitializer.RetryHandlerDecorator;
+import com.google.firebase.testing.TestUtils;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Test;
+
+public class RetryInitializerTest {
+
+ private static final int MAX_RETRIES = 4;
+
+ @Test
+ public void testEnableRetry() throws IOException {
+ RetryConfig retryConfig = retryOnIOAndServiceUnavailableErrors(new MockSleeper());
+ RetryInitializer initializer = new RetryInitializer(retryConfig);
+ HttpRequest request = TestUtils.createRequest();
+
+ initializer.initialize(request);
+
+ assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+ assertTrue(request.getUnsuccessfulResponseHandler() instanceof RetryHandlerDecorator);
+ RetryUnsuccessfulResponseHandler retryHandler =
+ ((RetryHandlerDecorator) request.getUnsuccessfulResponseHandler()).getRetryHandler();
+ assertSame(retryConfig, retryHandler.getRetryConfig());
+ assertTrue(request.getIOExceptionHandler() instanceof HttpBackOffIOExceptionHandler);
+ }
+
+ @Test
+ public void testRetryOnIOExceptionDisabled() throws IOException {
+ RetryInitializer initializer = new RetryInitializer(RetryConfig.builder()
+ .setMaxRetries(MAX_RETRIES)
+ .setRetryOnIOExceptions(false)
+ .setRetryStatusCodes(ImmutableList.of(503))
+ .build());
+ HttpRequest request = TestUtils.createRequest();
+
+ initializer.initialize(request);
+
+ assertEquals(MAX_RETRIES, request.getNumberOfRetries());
+ assertNotNull(request.getUnsuccessfulResponseHandler());
+ assertNull(request.getIOExceptionHandler());
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testRetryConfigCannotBeNull() {
+ new RetryInitializer(null);
+ }
+
+ @Test
+ public void testRetryOnIOException() throws IOException {
+ MockSleeper sleeper = new MockSleeper();
+ RetryInitializer initializer = new RetryInitializer(
+ retryOnIOAndServiceUnavailableErrors(sleeper));
+
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromException(
+ new IOException("test error"));
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ initializer.initialize(request);
+ final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (IOException e) {
+ assertEquals("test error", e.getMessage());
+ }
+
+ assertEquals(MAX_RETRIES, sleeper.getCount());
+ assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+ assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+ }
+
+ @Test
+ public void testRetryOnHttpError() throws IOException {
+ MockSleeper sleeper = new MockSleeper();
+ RetryInitializer initializer = new RetryInitializer(
+ retryOnIOAndServiceUnavailableErrors(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ initializer.initialize(request);
+ final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(MAX_RETRIES, sleeper.getCount());
+ assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+ assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+ }
+
+ @Test
+ public void testMaxRetriesCountIsCumulative() throws IOException {
+ MockSleeper sleeper = new MockSleeper();
+ RetryInitializer initializer = new RetryInitializer(
+ retryOnIOAndServiceUnavailableErrors(sleeper));
+
+ final AtomicInteger counter = new AtomicInteger(0);
+ MockLowLevelHttpRequest failingRequest = new MockLowLevelHttpRequest(){
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ if (counter.getAndIncrement() < 2) {
+ throw new IOException("test error");
+ } else {
+ return new MockLowLevelHttpResponse().setStatusCode(503).setZeroContent();
+ }
+ }
+ };
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ initializer.initialize(request);
+ final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(MAX_RETRIES, sleeper.getCount());
+ assertEquals(MAX_RETRIES + 1, counter.get());
+ assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+ }
+
+ @Test
+ public void testOtherErrorHandlersCalledBeforeRetry() throws IOException {
+ final AtomicInteger otherErrorHandlerCalls = new AtomicInteger(0);
+ HttpCredentialsAdapter credentials = new HttpCredentialsAdapter(new MockGoogleCredentials()) {
+ @Override
+ public boolean handleResponse(
+ HttpRequest request, HttpResponse response, boolean supportsRetry) {
+ otherErrorHandlerCalls.incrementAndGet();
+ return super.handleResponse(request, response, supportsRetry);
+ }
+ };
+ MockSleeper sleeper = new MockSleeper();
+ RetryInitializer initializer = new RetryInitializer(RetryConfig.builder()
+ .setMaxRetries(MAX_RETRIES)
+ .setRetryStatusCodes(ImmutableList.of(503))
+ .setSleeper(sleeper)
+ .build());
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ credentials.initialize(request);
+ initializer.initialize(request);
+ final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(MAX_RETRIES, sleeper.getCount());
+ assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+ assertEquals(MAX_RETRIES + 1, otherErrorHandlerCalls.get());
+ assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+ }
+
+ @Test
+ public void testRetryHandlerDoesNotGetOverwritten() throws IOException {
+ final AtomicInteger otherErrorHandlerCalls = new AtomicInteger(0);
+ HttpUnsuccessfulResponseHandler credentials = new HttpUnsuccessfulResponseHandler() {
+ @Override
+ public boolean handleResponse(
+ HttpRequest request, HttpResponse response, boolean supportsRetry) throws IOException {
+ otherErrorHandlerCalls.incrementAndGet();
+ request.setUnsuccessfulResponseHandler(this);
+ throw new IOException("test");
+ }
+ };
+ MockSleeper sleeper = new MockSleeper();
+ RetryInitializer initializer = new RetryInitializer(RetryConfig.builder()
+ .setMaxRetries(MAX_RETRIES)
+ .setRetryStatusCodes(ImmutableList.of(503))
+ .setSleeper(sleeper)
+ .build());
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(credentials);
+ initializer.initialize(request);
+ final HttpUnsuccessfulResponseHandler retryHandler = request.getUnsuccessfulResponseHandler();
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (Exception e) {
+ assertEquals("test", e.getMessage());
+ }
+
+ assertEquals(1, otherErrorHandlerCalls.get());
+ assertSame(retryHandler, request.getUnsuccessfulResponseHandler());
+ }
+
+ private RetryConfig retryOnIOAndServiceUnavailableErrors(Sleeper sleeper) {
+ return RetryConfig.builder()
+ .setMaxRetries(MAX_RETRIES)
+ .setRetryStatusCodes(ImmutableList.of(503))
+ .setRetryOnIOExceptions(true)
+ .setSleeper(sleeper)
+ .build();
+ }
+}
diff --git a/src/test/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandlerTest.java b/src/test/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandlerTest.java
new file mode 100644
index 000000000..077e9e52f
--- /dev/null
+++ b/src/test/java/com/google/firebase/internal/RetryUnsuccessfulResponseHandlerTest.java
@@ -0,0 +1,258 @@
+/*
+ * 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.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.http.HttpRequest;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.client.testing.http.FixedClock;
+import com.google.api.client.testing.util.MockSleeper;
+import com.google.api.client.util.Clock;
+import com.google.api.client.util.Sleeper;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Longs;
+import com.google.firebase.testing.TestUtils;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+import org.junit.Test;
+
+public class RetryUnsuccessfulResponseHandlerTest {
+
+ private static final int MAX_RETRIES = 4;
+ private static final RetryConfig.Builder TEST_RETRY_CONFIG = RetryConfig.builder()
+ .setRetryStatusCodes(ImmutableList.of(429, 503))
+ .setMaxIntervalMillis(120 * 1000);
+
+ @Test
+ public void testDoesNotRetryOnUnspecifiedHttpStatus() throws IOException {
+ MultipleCallSleeper sleeper = new MultipleCallSleeper();
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(404);
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(MAX_RETRIES);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(404, e.getStatusCode());
+ }
+
+ assertEquals(0, sleeper.getCount());
+ assertEquals(1, failingRequest.getCount());
+ }
+
+ @Test
+ public void testRetryOnHttpClientErrorWhenSpecified() throws IOException {
+ MultipleCallSleeper sleeper = new MultipleCallSleeper();
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(429);
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(MAX_RETRIES);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(429, e.getStatusCode());
+ }
+
+ assertEquals(MAX_RETRIES, sleeper.getCount());
+ assertArrayEquals(new long[]{500, 1000, 2000, 4000}, sleeper.getDelays());
+ assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+ }
+
+ @Test
+ public void testExponentialBackOffDoesNotExceedMaxInterval() throws IOException {
+ MultipleCallSleeper sleeper = new MultipleCallSleeper();
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(10);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(10, sleeper.getCount());
+ assertArrayEquals(
+ new long[]{500, 1000, 2000, 4000, 8000, 16000, 32000, 64000, 120000, 120000},
+ sleeper.getDelays());
+ assertEquals(11, failingRequest.getCount());
+ }
+
+ @Test
+ public void testRetryAfterGivenAsSeconds() throws IOException {
+ MultipleCallSleeper sleeper = new MultipleCallSleeper();
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+ 503, ImmutableMap.of("retry-after", "2"));
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(MAX_RETRIES);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(MAX_RETRIES, sleeper.getCount());
+ assertArrayEquals(new long[]{2000, 2000, 2000, 2000}, sleeper.getDelays());
+ assertEquals(MAX_RETRIES + 1, failingRequest.getCount());
+ }
+
+ @Test
+ public void testRetryAfterGivenAsDate() throws IOException {
+ SimpleDateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz");
+ dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
+ Date date = new Date(1000);
+ Clock clock = new FixedClock(date.getTime());
+ String retryAfter = dateFormat.format(new Date(date.getTime() + 30000));
+
+ MultipleCallSleeper sleeper = new MultipleCallSleeper();
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper), clock);
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+ 503, ImmutableMap.of("retry-after", retryAfter));
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(4);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(4, sleeper.getCount());
+ assertArrayEquals(new long[]{30000, 30000, 30000, 30000}, sleeper.getDelays());
+ assertEquals(5, failingRequest.getCount());
+ }
+
+ @Test
+ public void testInvalidRetryAfterFailsOverToExpBackOff() throws IOException {
+ MultipleCallSleeper sleeper = new MultipleCallSleeper();
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+ 503, ImmutableMap.of("retry-after", "not valid"));
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(4);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(4, sleeper.getCount());
+ assertArrayEquals(new long[]{500, 1000, 2000, 4000}, sleeper.getDelays());
+ assertEquals(5, failingRequest.getCount());
+ }
+
+ @Test
+ public void testDoesNotRetryWhenRetryAfterIsTooLong() throws IOException {
+ MultipleCallSleeper sleeper = new MultipleCallSleeper();
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(
+ 503, ImmutableMap.of("retry-after", "121"));
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(MAX_RETRIES);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(0, sleeper.getCount());
+ assertEquals(1, failingRequest.getCount());
+ }
+
+ @Test
+ public void testDoesNotRetryAfterInterruption() throws IOException {
+ MockSleeper sleeper = new MockSleeper() {
+ @Override
+ public void sleep(long millis) throws InterruptedException {
+ super.sleep(millis);
+ throw new InterruptedException();
+ }
+ };
+ RetryUnsuccessfulResponseHandler handler = new RetryUnsuccessfulResponseHandler(
+ testRetryConfig(sleeper));
+ CountingLowLevelHttpRequest failingRequest = CountingLowLevelHttpRequest.fromStatus(503);
+ HttpRequest request = TestUtils.createRequest(failingRequest);
+ request.setUnsuccessfulResponseHandler(handler);
+ request.setNumberOfRetries(MAX_RETRIES);
+
+ try {
+ request.execute();
+ fail("No exception thrown for HTTP error");
+ } catch (HttpResponseException e) {
+ assertEquals(503, e.getStatusCode());
+ }
+
+ assertEquals(1, sleeper.getCount());
+ assertEquals(1, failingRequest.getCount());
+ }
+
+ private RetryConfig testRetryConfig(Sleeper sleeper) {
+ return TEST_RETRY_CONFIG.setSleeper(sleeper).build();
+ }
+
+
+ private static class MultipleCallSleeper extends MockSleeper {
+
+ private final List delays = new ArrayList<>();
+
+ @Override
+ public void sleep(long millis) throws InterruptedException {
+ super.sleep(millis);
+ delays.add(millis);
+ }
+
+ long[] getDelays() {
+ return Longs.toArray(delays);
+ }
+ }
+}
diff --git a/src/test/java/com/google/firebase/testing/TestUtils.java b/src/test/java/com/google/firebase/testing/TestUtils.java
index 0dec4db3f..3e33a52ea 100644
--- a/src/test/java/com/google/firebase/testing/TestUtils.java
+++ b/src/test/java/com/google/firebase/testing/TestUtils.java
@@ -19,8 +19,14 @@
import static com.google.common.base.Preconditions.checkNotNull;
import com.google.api.client.googleapis.testing.auth.oauth2.MockTokenServerTransport;
+import com.google.api.client.http.EmptyContent;
+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.HttpTransport;
import com.google.api.client.json.webtoken.JsonWebSignature;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.common.collect.ImmutableMap;
@@ -41,7 +47,8 @@
public class TestUtils {
public static final long TEST_TIMEOUT_MILLIS = 7 * 1000;
- public static final String TEST_ADC_ACCESS_TOKEN = "test-adc-access-token";
+ private static final String TEST_ADC_ACCESS_TOKEN = "test-adc-access-token";
+ private static final GenericUrl TEST_URL = new GenericUrl("https://firebase.google.com");
private static GoogleCredentials defaultCredentials;
@@ -123,4 +130,16 @@ public HttpTransport create() {
});
return defaultCredentials;
}
+
+ public static HttpRequest createRequest() throws IOException {
+ return createRequest(new MockLowLevelHttpRequest());
+ }
+
+ public static HttpRequest createRequest(MockLowLevelHttpRequest request) throws IOException {
+ HttpTransport transport = new MockHttpTransport.Builder()
+ .setLowLevelHttpRequest(request)
+ .build();
+ HttpRequestFactory requestFactory = transport.createRequestFactory();
+ return requestFactory.buildPostRequest(TEST_URL, new EmptyContent());
+ }
}