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()); + } }