Skip to content

Commit fa5d7b1

Browse files
authored
fix: googphotos http request handling to be self-consistent (#1445)
factors out the common HTTP logic to a middle-tier private func `makeHttpRequest` to get to a _mostly_ noop cleanup/DRYing of our HTTP handling. (motivation: some issues we're debugging). Things that prevent this from being just a true **noop** refactor: - `makePostRequest` used to only respect its users' request for special HTTP headers on the _first_ request, but not on subsequent (token-refresh driven) requests. Now it'll respect it for all requests. - similar to previous point: _rate limiting_ wasn't consistently applied, now it's always applied for every write request, and not just the first. - similarly, check for HTTP 200 was only done on some request paths, now it's always done; it's also now a consistent stack trace that will hopefully be more useful (like the other stack traces we get for bad responses). - unlike the above/extant http-200 check (despite not getting an error), other checks like permission-denied were not always done; now they're done regardless of getting a response or an error
1 parent de733f9 commit fa5d7b1

1 file changed

Lines changed: 121 additions & 73 deletions

File tree

  • extensions/data-transfer/portability-data-transfer-google/src/main/java/org/datatransferproject/datatransfer/google/photos

extensions/data-transfer/portability-data-transfer-google/src/main/java/org/datatransferproject/datatransfer/google/photos/GooglePhotosInterface.java

Lines changed: 121 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.google.api.client.http.ByteArrayContent;
2424
import com.google.api.client.http.GenericUrl;
2525
import com.google.api.client.http.HttpContent;
26+
import com.google.api.client.http.HttpMethods;
2627
import com.google.api.client.http.HttpRequest;
2728
import com.google.api.client.http.HttpRequestFactory;
2829
import com.google.api.client.http.HttpResponse;
@@ -67,11 +68,13 @@
6768

6869
// TODO (#1307): Find a way to consolidate all 3P API interfaces
6970
public class GooglePhotosInterface {
70-
7171
public static final String ERROR_HASH_MISMATCH = "Hash mismatch";
7272
private static final String GOOG_ERROR_HASH_MISMATCH_LEGACY = "Checksum from header does not match received payload content.";
7373
private static final String GOOG_ERROR_HASH_MISMATCH_UNIFIED = "User-provided checksum does not match received payload content.";
7474

75+
private static final String GOOGPHOTOS_ALBUMS_PERMISSION_ERROR = "The caller does not have permission";
76+
private static final String GOOGPHOTOS_PHOTO_PERMISSION_ERROR = "Google Photos is disabled for the user";
77+
7578
private static final String BASE_URL = "https://photoslibrary.googleapis.com/v1/";
7679
private static final int ALBUM_PAGE_SIZE = 20; // TODO
7780
private static final int MEDIA_PAGE_SIZE = 50; // TODO
@@ -196,57 +199,42 @@ public BatchMediaItemResponse createPhotos(NewMediaItemUpload newMediaItemUpload
196199
private <T> T makeGetRequest(String url, Optional<Map<String, String>> parameters, Class<T> clazz)
197200
throws IOException, InvalidTokenException, PermissionDeniedException {
198201
HttpRequestFactory requestFactory = httpTransport.createRequestFactory();
199-
HttpRequest getRequest =
200-
requestFactory.buildGetRequest(
201-
new GenericUrl(url + "?" + generateParamsString(parameters)));
202202

203203
HttpResponse response;
204204
try {
205-
response = getRequest.execute();
206-
} catch (HttpResponseException e) {
207-
response =
208-
handleHttpResponseException(
209-
() ->
205+
response = makeHttpRequest(() ->
210206
requestFactory.buildGetRequest(
211-
new GenericUrl(url + "?" + generateParamsString(parameters))),
212-
e);
207+
new GenericUrl(url + "?" + generateParamsString(parameters))));
208+
} catch (UploadErrorException e) {
209+
throw new IllegalStateException("GET request unexpectedly produced Upload exception", e);
213210
}
214211

215-
Preconditions.checkState(response.getStatusCode() == 200);
216212
String result =
217213
CharStreams.toString(new InputStreamReader(response.getContent(), StandardCharsets.UTF_8));
218214
return objectMapper.readValue(result, clazz);
219215
}
220216

221-
public <T> T makePostRequest(String url, Optional<Map<String, String>> parameters,
222-
Optional<Map<String, String>> extraHeaders, HttpContent httpContent, Class<T> clazz)
217+
public <T> T makePostRequest(
218+
String url,
219+
Optional<Map<String, String>> parameters,
220+
Optional<Map<String, String>> extraHeaders,
221+
HttpContent httpContent,
222+
Class<T> clazz)
223223
throws IOException, InvalidTokenException, PermissionDeniedException, UploadErrorException {
224-
// Wait for write permit before making request
225-
writeRateLimiter.acquire();
226-
227224
HttpRequestFactory requestFactory = httpTransport.createRequestFactory();
228-
HttpRequest postRequest =
225+
HttpResponse response = makeHttpRequest(() -> {
226+
// Wait for write permit before making request
227+
writeRateLimiter.acquire();
228+
229+
HttpRequest postRequest =
229230
requestFactory.buildPostRequest(
230231
new GenericUrl(url + "?" + generateParamsString(parameters)), httpContent);
231-
extraHeaders.ifPresent(stringStringMap -> stringStringMap.forEach(
232-
(key, value) -> postRequest.getHeaders().set(key, value)));
233-
postRequest.setReadTimeout(2 * 60000); // 2 minutes read timeout
234-
HttpResponse response;
235-
236-
try {
237-
response = postRequest.execute();
238-
} catch (HttpResponseException e) {
239-
maybeRethrowAsUploadError(e);
240-
241-
response =
242-
handleHttpResponseException(
243-
() ->
244-
requestFactory.buildPostRequest(
245-
new GenericUrl(url + "?" + generateParamsString(parameters)), httpContent),
246-
e);
247-
}
232+
extraHeaders.ifPresent(stringStringMap -> stringStringMap.forEach(
233+
(key, value) -> postRequest.getHeaders().set(key, value)));
234+
postRequest.setReadTimeout(2 * 60000); // 2 minutes read timeout
235+
return postRequest;
236+
});
248237

249-
Preconditions.checkState(response.getStatusCode() == 200);
250238
String result =
251239
CharStreams.toString(new InputStreamReader(response.getContent(), StandardCharsets.UTF_8));
252240
if (clazz.isAssignableFrom(String.class)) {
@@ -256,55 +244,115 @@ public <T> T makePostRequest(String url, Optional<Map<String, String>> parameter
256244
}
257245
}
258246

259-
/**
260-
* Converting {@link HttpResponseException} to upload-related exceptions. Current this is only
261-
* used for payload hash verifications.
262-
*
263-
* Note that making this a separate method to avoid polluting throw lists.
264-
*/
265-
private void maybeRethrowAsUploadError(HttpResponseException e) throws UploadErrorException {
266-
if (e.getStatusCode() == 400) {
267-
if (e.getContent().contains(GOOG_ERROR_HASH_MISMATCH_LEGACY) || e.getContent()
268-
.contains(GOOG_ERROR_HASH_MISMATCH_UNIFIED)) {
269-
throw new UploadErrorException(ERROR_HASH_MISMATCH, e);
247+
private HttpResponse makeHttpRequest(SupplierWithIO<HttpRequest> httpRequest)
248+
throws IOException, InvalidTokenException, PermissionDeniedException, UploadErrorException {
249+
250+
HttpResponse response = null;
251+
HttpRequest firstReq = httpRequest.getWithIO();
252+
try {
253+
response = firstReq.execute();
254+
} catch (HttpResponseException firstReqException) {
255+
Optional<HttpResponse> maybeTokenRefreshedRetry = Optional.empty();
256+
try {
257+
maybeTokenRefreshedRetry =
258+
maybeRetryWithFreshToken(httpRequest, firstReqException);
259+
} catch (HttpResponseException tokenRefreshedRetryException) {
260+
rethrowForDtpStandards(
261+
tokenRefreshedRetryException.getStatusCode(),
262+
Optional.of(tokenRefreshedRetryException),
263+
Optional.empty() /*maybeResponse*/);
270264
}
271-
// Delegate other 400 errors and non-400 errors to {@link #handleHttpResponseException}.
265+
266+
if (maybeTokenRefreshedRetry.isPresent()) {
267+
response = maybeTokenRefreshedRetry.get();
268+
} else {
269+
rethrowForDtpStandards(
270+
firstReqException.getStatusCode(),
271+
Optional.of(firstReqException),
272+
Optional.empty() /*maybeResponse*/);
273+
}
274+
}
275+
Preconditions.checkNotNull(
276+
response,
277+
"bug? response should be set, else DTP error already thrown, but neither happened?");
278+
279+
if (response.getStatusCode() != 200) {
280+
rethrowForDtpStandards(
281+
response.getStatusCode(),
282+
Optional.empty() /*maybeException*/,
283+
Optional.ofNullable(response));
272284
}
285+
286+
return response;
273287
}
274288

275-
private HttpResponse handleHttpResponseException(
289+
private Optional<HttpResponse> maybeRetryWithFreshToken(
276290
SupplierWithIO<HttpRequest> httpRequest, HttpResponseException e)
277-
throws IOException, InvalidTokenException, PermissionDeniedException {
291+
throws IOException, InvalidTokenException {
278292
// if the response is "unauthorized", refresh the token and try the request again
279293
final int statusCode = e.getStatusCode();
280294

281-
if (statusCode == 401) {
282-
monitor.info(() -> String.format("GooglePhotosInterface: Attempting to refresh authorization token due to HTTP response code=%s, %s\n", statusCode, e));
283-
// if the credential refresh failed, let the error bubble up via the IOException that gets
284-
// thrown
285-
credential = credentialFactory.refreshCredential(credential);
286-
monitor.info(() -> "GooglePhotosInterface: Refreshed authorization token successfully");
295+
if (statusCode != 401) {
296+
return Optional.empty();
297+
}
298+
299+
monitor.info(() -> String.format("GooglePhotosInterface: Attempting to refresh authorization token due to HTTP response code=%s, %s\n", statusCode, e));
300+
// if the credential refresh failed, let the error bubble up via the IOException that gets
301+
// thrown
302+
credential = credentialFactory.refreshCredential(credential);
303+
monitor.info(() -> "GooglePhotosInterface: Refreshed authorization token successfully");
287304

288-
// if the second attempt throws an error, then something else is wrong, and we bubble up the
289-
// response errors
290-
return httpRequest.getWithIO().execute();
305+
// if the second attempt throws an error, then something else is wrong, and we bubble up the
306+
// response errors
307+
return Optional.of(httpRequest.getWithIO().execute());
308+
}
309+
310+
/**
311+
* Tries to throw a DTP-standard exception for a request that's failed, given whatever info we
312+
* have about the failure (HTTP response code, if nothing else) .
313+
*/
314+
// TODO: jzacsh rework this class to interact with HTTP in an easily
315+
// loggable/testable/re-usable way, like we did with MicrosoftApiResponse.
316+
private void rethrowForDtpStandards(
317+
int statusCode,
318+
Optional<HttpResponseException> maybeException,
319+
Optional<HttpResponse> maybeResponse)
320+
throws IOException, InvalidTokenException, PermissionDeniedException, UploadErrorException {
321+
final String emptyServerMessage = "[no server message: have neither response nor exception]";
322+
final Optional<String> serverMessage;
323+
if (maybeException.isPresent()) {
324+
serverMessage = Optional.of(maybeException.get().getContent());
325+
} else if (maybeResponse.isPresent()) {
326+
serverMessage = Optional.of(maybeResponse.get().getStatusMessage());
327+
} else {
328+
serverMessage = Optional.empty();
291329
}
292-
// "The caller does not have permission" is potential error for albums.
293-
// "Google Photos is disabled for the user" is potential error for photos.
330+
294331
if (statusCode == 403 &&
295-
(e.getContent().contains("The caller does not have permission") ||
296-
e.getContent().contains("Google Photos is disabled for the user"))) {
297-
throw new PermissionDeniedException("User permission to google photos was denied", e);
298-
} else {
299-
// something else is wrong, bubble up the error
300-
throw new IOException(
301-
"Bad status code: "
302-
+ e.getStatusCode()
303-
+ " Error: '"
304-
+ e.getStatusMessage()
305-
+ "' Content: "
306-
+ e.getContent());
332+
(serverMessage.orElse("").contains(GOOGPHOTOS_ALBUMS_PERMISSION_ERROR) ||
333+
serverMessage.orElse("").contains(GOOGPHOTOS_PHOTO_PERMISSION_ERROR))) {
334+
throw new PermissionDeniedException("User permission to google photos was denied", maybeException.orElse(null));
307335
}
336+
337+
// Upload-related exceptions; currently this is only used for payload hash verifications.
338+
if (statusCode == 400 &&
339+
(serverMessage.orElse("").contains(GOOG_ERROR_HASH_MISMATCH_LEGACY) ||
340+
serverMessage.orElse("").contains(GOOG_ERROR_HASH_MISMATCH_UNIFIED))) {
341+
Throwable throwableForBadResponse =
342+
new IOException(String.format(
343+
"non-error HTTP response statusCode=%s: %s",
344+
statusCode,
345+
serverMessage.orElse(emptyServerMessage)));
346+
Throwable cause = maybeException.map(e -> (Throwable) e /*downcast*/).orElse(throwableForBadResponse);
347+
throw new UploadErrorException(ERROR_HASH_MISMATCH, cause);
348+
}
349+
350+
// something else is wrong, bubble up the error
351+
throw new IOException(
352+
String.format("Bad HTTP response: status code=%s, Error='%s' Content: %s",
353+
statusCode,
354+
maybeException.map(e -> e.getStatusMessage()).orElse("[no HTTP error]"),
355+
serverMessage.orElse(emptyServerMessage)));
308356
}
309357

310358
private String generateParamsString(Optional<Map<String, String>> params) {

0 commit comments

Comments
 (0)