Skip to content

Enable request limiter for CreateInstallation, GenerateAuthToken request calls. #2003

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 5, 2020
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
import com.google.firebase.installations.remote.FirebaseInstallationServiceClient;
import com.google.firebase.installations.remote.InstallationResponse;
import com.google.firebase.installations.remote.TokenResult;
import com.google.firebase.installations.time.SystemClock;
import com.google.firebase.platforminfo.UserAgentPublisher;
import java.io.IOException;
import java.util.ArrayList;
Expand Down Expand Up @@ -133,7 +132,7 @@ public Thread newThread(Runnable r) {
new FirebaseInstallationServiceClient(
firebaseApp.getApplicationContext(), publisher, heartbeatInfo),
new PersistedInstallation(firebaseApp),
new Utils(SystemClock.getInstance()),
Utils.getInstance(),
new IidStore(firebaseApp),
new RandomFidGenerator());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does RandomFidGenerator needs to be instantiated?
Usually I would assume, because a RandomNumberGenerator has to be injected, but that does not seem to be the case.
Thus, can we use the methods of this class statically?

As a more general note:
For general code design it would be better if there was a FIS model class that would represent FIDs and also carry methods like generating a random FID.
No action item! I just wanted to leave this comment here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(fyi: this was not addressed or acknowledged)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. I will address this in a following PR.

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public enum Status {
* may be corrected by retrying. We recommend exponential backoff when retrying requests.
*/
UNAVAILABLE,
/**
* Firebase servers have received too many requests in a short period of time from the client.
*/
TOO_MANY_REQUESTS,
}

@NonNull private final Status status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import androidx.annotation.Nullable;
import com.google.firebase.installations.local.PersistedInstallationEntry;
import com.google.firebase.installations.time.Clock;
import com.google.firebase.installations.time.SystemClock;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

Expand All @@ -31,11 +32,30 @@ public final class Utils {
public static final long AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS = TimeUnit.HOURS.toSeconds(1);
private static final String APP_ID_IDENTIFICATION_SUBSTRING = ":";
private static final Pattern API_KEY_FORMAT = Pattern.compile("\\AA[\\w-]{38}\\z");
private static Utils singleton;
private final Clock clock;

Utils(Clock clock) {
private Utils(Clock clock) {
this.clock = clock;
}

// Factory method that always returns the same Utils instance.
public static Utils getInstance() {
return getInstance(SystemClock.getInstance());
}

/**
* Returns an Utils instance. {@link Utils#getInstance()} defines the clock used. NOTE: If a Utils
* instance has already been initialized, the parameter will be ignored and the existing instance
* will be returned.
*/
public static Utils getInstance(Clock clock) {
if (singleton == null) {
singleton = new Utils(clock);
}
return singleton;
}

/**
* Checks if the FIS Auth token is expired or going to expire in next 1 hour {@link
* #AUTH_TOKEN_EXPIRATION_BUFFER_IN_SECS}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,15 @@ public class FirebaseInstallationServiceClient {
private static final String SDK_VERSION_PREFIX = "a:";

private static final String FIS_TAG = "Firebase-Installations";
private boolean shouldServerErrorRetry;

@VisibleForTesting
static final String PARSING_EXPIRATION_TIME_ERROR_MESSAGE = "Invalid Expiration Timestamp.";

private final Context context;
private final Provider<UserAgentPublisher> userAgentPublisher;
private final Provider<HeartBeatInfo> heartbeatInfo;
private final RequestLimiter requestLimiter;

public FirebaseInstallationServiceClient(
@NonNull Context context,
Expand All @@ -112,6 +114,7 @@ public FirebaseInstallationServiceClient(
this.context = context;
this.userAgentPublisher = publisher;
this.heartbeatInfo = heartbeatInfo;
this.requestLimiter = new RequestLimiter();
}

/**
Expand Down Expand Up @@ -140,10 +143,16 @@ public InstallationResponse createFirebaseInstallation(
@NonNull String appId,
@Nullable String iidToken)
throws FirebaseInstallationsException {
if (!requestLimiter.isRequestAllowed()) {
throw new FirebaseInstallationsException(
"Firebase Installations Service is unavailable. Please try again later.",
Status.UNAVAILABLE);
}

String resourceName = String.format(CREATE_REQUEST_RESOURCE_NAME_FORMAT, projectID);
int retryCount = 0;
URL url = getFullyQualifiedRequestUri(resourceName);
while (retryCount <= MAX_RETRIES) {
for (int retryCount = 0; retryCount <= MAX_RETRIES; retryCount++) {

HttpURLConnection httpURLConnection = openHttpURLConnection(url, apiKey);

try {
Expand All @@ -158,15 +167,22 @@ public InstallationResponse createFirebaseInstallation(
writeFIDCreateRequestBodyToOutputStream(httpURLConnection, fid, appId);

int httpResponseCode = httpURLConnection.getResponseCode();
requestLimiter.setNextRequestTime(httpResponseCode);

if (httpResponseCode == 200) {
if (isSuccessfulResponseCode(httpResponseCode)) {
return readCreateResponse(httpURLConnection);
}

logFisCommunicationError(httpURLConnection, appId, apiKey, projectID);

if (httpResponseCode == 429 || (httpResponseCode >= 500 && httpResponseCode < 600)) {
retryCount++;
if (httpResponseCode == 429) {
throw new FirebaseInstallationsException(
"Firebase servers have received too many requests from this client in a short "
+ "period of time. Please try again later.",
Status.TOO_MANY_REQUESTS);
}

if (httpResponseCode >= 500 && httpResponseCode < 600) {
continue;
}

Expand All @@ -175,7 +191,7 @@ public InstallationResponse createFirebaseInstallation(
// Return empty installation response with BAD_CONFIG response code after max retries
return InstallationResponse.builder().setResponseCode(ResponseCode.BAD_CONFIG).build();
} catch (AssertionError | IOException ignored) {
retryCount++;
continue;
} finally {
httpURLConnection.disconnect();
}
Expand Down Expand Up @@ -363,11 +379,17 @@ public TokenResult generateAuthToken(
@NonNull String projectID,
@NonNull String refreshToken)
throws FirebaseInstallationsException {
if (!requestLimiter.isRequestAllowed()) {
throw new FirebaseInstallationsException(
"Firebase Installations Service is unavailable. Please try again later.",
Status.UNAVAILABLE);
}

String resourceName =
String.format(GENERATE_AUTH_TOKEN_REQUEST_RESOURCE_NAME_FORMAT, projectID, fid);
int retryCount = 0;
URL url = getFullyQualifiedRequestUri(resourceName);
while (retryCount <= MAX_RETRIES) {
for (int retryCount = 0; retryCount <= MAX_RETRIES; retryCount++) {

HttpURLConnection httpURLConnection = openHttpURLConnection(url, apiKey);
try {
httpURLConnection.setRequestMethod("POST");
Expand All @@ -377,8 +399,9 @@ public TokenResult generateAuthToken(
writeGenerateAuthTokenRequestBodyToOutputStream(httpURLConnection);

int httpResponseCode = httpURLConnection.getResponseCode();
requestLimiter.setNextRequestTime(httpResponseCode);

if (httpResponseCode == 200) {
if (isSuccessfulResponseCode(httpResponseCode)) {
return readGenerateAuthTokenResponse(httpURLConnection);
}

Expand All @@ -388,8 +411,14 @@ public TokenResult generateAuthToken(
return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.AUTH_ERROR).build();
}

if (httpResponseCode == 429 || (httpResponseCode >= 500 && httpResponseCode < 600)) {
retryCount++;
if (httpResponseCode == 429) {
throw new FirebaseInstallationsException(
"Firebase servers have received too many requests from this client in a short "
+ "period of time. Please try again later.",
Status.TOO_MANY_REQUESTS);
}

if (httpResponseCode >= 500 && httpResponseCode < 600) {
continue;
}

Expand All @@ -398,7 +427,7 @@ public TokenResult generateAuthToken(
return TokenResult.builder().setResponseCode(TokenResult.ResponseCode.BAD_CONFIG).build();
// TODO(b/166168291): Remove code duplication and clean up this class.
} catch (AssertionError | IOException ignored) {
retryCount++;
continue;
} finally {
httpURLConnection.disconnect();
}
Expand All @@ -408,6 +437,10 @@ public TokenResult generateAuthToken(
Status.UNAVAILABLE);
}

private static boolean isSuccessfulResponseCode(int responseCode) {
return responseCode >= 200 && responseCode < 300;
}

private static void logBadConfigError() {
Log.e(
FIS_TAG,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@ class RequestLimiter {
this.utils = utils;
}

RequestLimiter() {
// Util class is injected to ease mocking & testing the system time.
this.utils = Utils.getInstance();
}

// Based on the response code, calculates the next request time to communicate with the FIS
// servers.
public synchronized void setNextRequestTime(int responseCode) {
if (isSuccessful(responseCode)) {
if (isSuccessfulOrRequiresNewFidCreation(responseCode)) {
resetBackoffStrategy();
return;
}
Expand Down Expand Up @@ -77,10 +82,14 @@ private static boolean isRetryableError(int responseCode) {
return responseCode == 429 || (responseCode >= 500 && responseCode < 600);
}

// Response codes classified as success for FIS API. Read more on FIS response codes:
// go/fis-api-error-code-classification.
private static boolean isSuccessful(int responseCode) {
return responseCode >= 200 && responseCode < 300;
// 2xx Response codes are classified as success for FIS API. Also, FIS GenerateAuthToken endpoint
// responds with 401 & 404 for auth config errors which requires clients to follow up with a
// request to create a new FID. So, we don't limit the next requests for 401 & 404 response codes
// as well. Read more on FIS response codes: go/fis-api-error-code-classification.
private static boolean isSuccessfulOrRequiresNewFidCreation(int responseCode) {
return ((responseCode >= 200 && responseCode < 300)
|| responseCode == 401
|| responseCode == 404);
}

// Decides whether a network request to FIS servers is allowed to execute.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,7 @@ public void setUp() {
persistedInstallation.clearForTesting();

fakeClock = new FakeClock(5000000L);
utils = new Utils(fakeClock);

utils = Utils.getInstance(fakeClock);
firebaseInstallations =
new FirebaseInstallations(
executor,
Expand Down Expand Up @@ -523,7 +522,9 @@ public void testGetId_expiredAuthTokenThrowsException_statusUpdated() throws Exc
PersistedInstallationEntry.INSTANCE.withRegisteredFid(
TEST_FID_1,
TEST_REFRESH_TOKEN,
utils.currentTimeInSecs(),
utils.currentTimeInSecs()
- TEST_TOKEN_EXPIRATION_TIMESTAMP
+ TimeUnit.MINUTES.toSeconds(30),
TEST_AUTH_TOKEN,
TEST_TOKEN_EXPIRATION_TIMESTAMP));

Expand Down Expand Up @@ -562,7 +563,10 @@ public void testGetId_expiredAuthToken_refreshesAuthToken() throws Exception {
PersistedInstallationEntry.INSTANCE.withRegisteredFid(
TEST_FID_1,
TEST_REFRESH_TOKEN,
utils.currentTimeInSecs(),
// Set expiration time to 30 minutes from now (within refresh period)
utils.currentTimeInSecs()
- TEST_TOKEN_EXPIRATION_TIMESTAMP
+ TimeUnit.MINUTES.toSeconds(30),
TEST_AUTH_TOKEN,
TEST_TOKEN_EXPIRATION_TIMESTAMP));

Expand All @@ -582,9 +586,6 @@ public void testGetId_expiredAuthToken_refreshesAuthToken() throws Exception {
String fid = onCompleteListener.await();
assertWithMessage("getId Task failed").that(fid).isEqualTo(TEST_FID_1);

// Waiting for Task that registers FID on the FIS Servers
executor.awaitTermination(500, TimeUnit.MILLISECONDS);

TestOnCompleteListener<InstallationTokenResult> onCompleteListener2 =
new TestOnCompleteListener<>();
Task<InstallationTokenResult> task = firebaseInstallations.getToken(false);
Expand Down Expand Up @@ -749,7 +750,10 @@ public void testGetAuthToken_expiredAuthToken_fetchedNewTokenFromFIS() throws Ex
PersistedInstallationEntry.INSTANCE.withRegisteredFid(
TEST_FID_1,
TEST_REFRESH_TOKEN,
utils.currentTimeInSecs(),
// Set expiration time to 30 minutes from now (within refresh period)
utils.currentTimeInSecs()
- TEST_TOKEN_EXPIRATION_TIMESTAMP
+ TimeUnit.MINUTES.toSeconds(30),
TEST_AUTH_TOKEN,
TEST_TOKEN_EXPIRATION_TIMESTAMP));

Expand Down Expand Up @@ -780,7 +784,10 @@ public void testGetAuthToken_multipleCallsDoNotForceRefresh_fetchedNewTokenOnce(
PersistedInstallationEntry.INSTANCE.withRegisteredFid(
TEST_FID_1,
TEST_REFRESH_TOKEN,
utils.currentTimeInSecs(),
// Set expiration time to 30 minutes from now (within refresh period)
utils.currentTimeInSecs()
- TEST_TOKEN_EXPIRATION_TIMESTAMP
+ TimeUnit.MINUTES.toSeconds(30),
TEST_AUTH_TOKEN,
TEST_TOKEN_EXPIRATION_TIMESTAMP));

Expand Down