ApiErrorExtractor.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.hadoop.fs.gs;

import com.google.api.client.googleapis.json.GoogleJsonError;
import com.google.api.client.googleapis.json.GoogleJsonError.ErrorInfo;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.http.HttpStatusCodes;
import org.apache.hadoop.thirdparty.com.google.common.collect.ImmutableList;
import org.apache.hadoop.thirdparty.com.google.common.collect.Iterables;
import java.io.IOException;
import java.util.List;
import javax.annotation.Nullable;

/**
 * Translates exceptions from API calls into higher-level meaning, while allowing injectability for
 * testing how API errors are handled.
 */
class ApiErrorExtractor {

  /** Singleton instance of the ApiErrorExtractor. */
  public static final ApiErrorExtractor INSTANCE = new ApiErrorExtractor();

  public static final int STATUS_CODE_RANGE_NOT_SATISFIABLE = 416;

  public static final String GLOBAL_DOMAIN = "global";
  public static final String USAGE_LIMITS_DOMAIN = "usageLimits";

  public static final String RATE_LIMITED_REASON = "rateLimitExceeded";
  public static final String USER_RATE_LIMITED_REASON = "userRateLimitExceeded";

  public static final String QUOTA_EXCEEDED_REASON = "quotaExceeded";

  // These come with "The account for ... has been disabled" message.
  public static final String ACCOUNT_DISABLED_REASON = "accountDisabled";

  // These come with "Project marked for deletion" message.
  public static final String ACCESS_NOT_CONFIGURED_REASON = "accessNotConfigured";

  // These are 400 error codes with "resource 'xyz' is not ready" message.
  // These sometimes happens when create operation is still in-flight but resource
  // representation is already available via get call.
  public static final String RESOURCE_NOT_READY_REASON = "resourceNotReady";

  // HTTP 413 with message "Value for field 'foo' is too large".
  public static final String FIELD_SIZE_TOO_LARGE_REASON = "fieldSizeTooLarge";

  // HTTP 400 message for 'USER_PROJECT_MISSING' error.
  public static final String USER_PROJECT_MISSING_MESSAGE =
      "Bucket is a requester pays bucket but no user project provided.";

  // The debugInfo field present on Errors collection in GoogleJsonException
  // as an unknown key.
  private static final String DEBUG_INFO_FIELD = "debugInfo";

  /**
   * Determines if the given exception indicates intermittent request failure or failure caused by
   * user error.
   */
  public boolean requestFailure(IOException e) {
    HttpResponseException httpException = getHttpResponseException(e);
    return httpException != null
        && (accessDenied(httpException)
            || badRequest(httpException)
            || internalServerError(httpException)
            || rateLimited(httpException)
            || IoExceptionHelper.isSocketError(httpException)
            || unauthorized(httpException));
  }

  /**
   * Determines if the given exception indicates 'access denied'. Recursively checks getCause() if
   * outer exception isn't an instance of the correct class.
   *
   * <p>Warning: this method only checks for access denied status code, however this may include
   * potentially recoverable reason codes such as rate limiting. For alternative, see {@link
   * #accessDeniedNonRecoverable(IOException)}.
   */
  public boolean accessDenied(IOException e) {
    return recursiveCheckForCode(e, HttpStatusCodes.STATUS_CODE_FORBIDDEN);
  }

  /** Determines if the given exception indicates bad request. */
  public boolean badRequest(IOException e) {
    return recursiveCheckForCode(e, HttpStatusCodes.STATUS_CODE_BAD_REQUEST);
  }

  /**
   * Determines if the given exception indicates the request was unauthenticated. This can be caused
   * by attaching invalid credentials to a request.
   */
  public boolean unauthorized(IOException e) {
    return recursiveCheckForCode(e, HttpStatusCodes.STATUS_CODE_UNAUTHORIZED);
  }

  /**
   * Determines if the exception is a non-recoverable access denied code (such as account closed or
   * marked for deletion).
   */
  public boolean accessDeniedNonRecoverable(IOException e) {
    ErrorInfo errorInfo = getErrorInfo(e);
    String reason = errorInfo != null ? errorInfo.getReason() : null;
    return ACCOUNT_DISABLED_REASON.equals(reason) || ACCESS_NOT_CONFIGURED_REASON.equals(reason);
  }

  /** Determines if the exception is a client error. */
  public boolean clientError(IOException e) {
    HttpResponseException httpException = getHttpResponseException(e);
    return httpException != null && getHttpStatusCode(httpException) / 100 == 4;
  }

  /** Determines if the exception is an internal server error. */
  public boolean internalServerError(IOException e) {
    HttpResponseException httpException = getHttpResponseException(e);
    return httpException != null && getHttpStatusCode(httpException) / 100 == 5;
  }

  /**
   * Determines if the given exception indicates 'item already exists'. Recursively checks
   * getCause() if outer exception isn't an instance of the correct class.
   */
  public boolean itemAlreadyExists(IOException e) {
    return recursiveCheckForCode(e, HttpStatusCodes.STATUS_CODE_CONFLICT);
  }

  /**
   * Determines if the given exception indicates 'item not found'. Recursively checks getCause() if
   * outer exception isn't an instance of the correct class.
   */
  public boolean itemNotFound(IOException e) {
    return recursiveCheckForCode(e, HttpStatusCodes.STATUS_CODE_NOT_FOUND);
  }

  /**
   * Determines if the given exception indicates 'field size too large'. Recursively checks
   * getCause() if outer exception isn't an instance of the correct class.
   */
  public boolean fieldSizeTooLarge(IOException e) {
    ErrorInfo errorInfo = getErrorInfo(e);
    return errorInfo != null && FIELD_SIZE_TOO_LARGE_REASON.equals(errorInfo.getReason());
  }

  /**
   * Determines if the given exception indicates 'resource not ready'. Recursively checks getCause()
   * if outer exception isn't an instance of the correct class.
   */
  public boolean resourceNotReady(IOException e) {
    ErrorInfo errorInfo = getErrorInfo(e);
    return errorInfo != null && RESOURCE_NOT_READY_REASON.equals(errorInfo.getReason());
  }

  /**
   * Determines if the given IOException indicates 'precondition not met' Recursively checks
   * getCause() if outer exception isn't an instance of the correct class.
   */
  public boolean preconditionNotMet(IOException e) {
    return recursiveCheckForCode(e, HttpStatusCodes.STATUS_CODE_PRECONDITION_FAILED);
  }

  /**
   * Determines if the given exception indicates 'range not satisfiable'. Recursively checks
   * getCause() if outer exception isn't an instance of the correct class.
   */
  public boolean rangeNotSatisfiable(IOException e) {
    return recursiveCheckForCode(e, STATUS_CODE_RANGE_NOT_SATISFIABLE);
  }

  /**
   * Determines if a given Throwable is caused by a rate limit being applied. Recursively checks
   * getCause() if outer exception isn't an instance of the correct class.
   *
   * @param e The Throwable to check.
   * @return True if the Throwable is a result of rate limiting being applied.
   */
  public boolean rateLimited(IOException e) {
    ErrorInfo errorInfo = getErrorInfo(e);
    if (errorInfo != null) {
      String domain = errorInfo.getDomain();
      boolean isRateLimitedOrGlobalDomain =
          USAGE_LIMITS_DOMAIN.equals(domain) || GLOBAL_DOMAIN.equals(domain);
      String reason = errorInfo.getReason();
      boolean isRateLimitedReason =
          RATE_LIMITED_REASON.equals(reason) || USER_RATE_LIMITED_REASON.equals(reason);
      return isRateLimitedOrGlobalDomain && isRateLimitedReason;
    }
    return false;
  }

  /**
   * Determines if a given Throwable is caused by Quota Exceeded. Recursively checks getCause() if
   * outer exception isn't an instance of the correct class.
   */
  public boolean quotaExceeded(IOException e) {
    ErrorInfo errorInfo = getErrorInfo(e);
    return errorInfo != null && QUOTA_EXCEEDED_REASON.equals(errorInfo.getReason());
  }

  /**
   * Determines if the given exception indicates that 'userProject' is missing in request.
   * Recursively checks getCause() if outer exception isn't an instance of the correct class.
   */
  public boolean userProjectMissing(IOException e) {
    GoogleJsonError jsonError = getJsonError(e);
    return jsonError != null
        && jsonError.getCode() == HttpStatusCodes.STATUS_CODE_BAD_REQUEST
        && USER_PROJECT_MISSING_MESSAGE.equals(jsonError.getMessage());
  }

  /** Extracts the error message. */
  public String getErrorMessage(IOException e) {
    // Prefer to use message from GJRE.
    GoogleJsonError jsonError = getJsonError(e);
    return jsonError == null ? e.getMessage() : jsonError.getMessage();
  }

  /**
   * Converts the exception to a user-presentable error message. Specifically, extracts message
   * field for HTTP 4xx codes, and creates a generic "Internal Server Error" for HTTP 5xx codes.
   *
   * @param e the exception
   * @param action the description of the action being performed at the time of error.
   * @see #toUserPresentableMessage(IOException, String)
   */
  public IOException toUserPresentableException(IOException e, String action) throws IOException {
    throw new IOException(toUserPresentableMessage(e, action), e);
  }

  /**
   * Converts the exception to a user-presentable error message. Specifically, extracts message
   * field for HTTP 4xx codes, and creates a generic "Internal Server Error" for HTTP 5xx codes.
   */
  public String toUserPresentableMessage(IOException e, @Nullable String action) {
    String message = "Internal server error";
    if (clientError(e)) {
      message = getErrorMessage(e);
    }
    return action == null
        ? message
        : String.format("Encountered an error while %s: %s", action, message);
  }

  /** See {@link #toUserPresentableMessage(IOException, String)}. */
  public String toUserPresentableMessage(IOException e) {
    return toUserPresentableMessage(e, null);
  }

  @Nullable
  public String getDebugInfo(IOException e) {
    ErrorInfo errorInfo = getErrorInfo(e);
    return errorInfo != null ? (String) errorInfo.getUnknownKeys().get(DEBUG_INFO_FIELD) : null;
  }

  /**
   * Returns HTTP status code from the given exception.
   *
   * <p>Note: GoogleJsonResponseException.getStatusCode() method is marked final therefore it cannot
   * be mocked using Mockito. We use this helper so that we can override it in tests.
   */
  protected int getHttpStatusCode(HttpResponseException e) {
    return e.getStatusCode();
  }

  /**
   * Get the first ErrorInfo from an IOException if it is an instance of
   * GoogleJsonResponseException, otherwise return null.
   */
  @Nullable
  protected ErrorInfo getErrorInfo(IOException e) {
    GoogleJsonError jsonError = getJsonError(e);
    List<ErrorInfo> errors = jsonError != null ? jsonError.getErrors() : ImmutableList.of();
    return errors != null ? Iterables.getFirst(errors, null) : null;
  }

  /** If the exception is a GoogleJsonResponseException, get the error details, else return null. */
  @Nullable
  protected GoogleJsonError getJsonError(IOException e) {
    GoogleJsonResponseException jsonException = getJsonResponseException(e);
    return jsonException == null ? null : jsonException.getDetails();
  }

  /** Recursively checks getCause() if outer exception isn't an instance of the correct class. */
  protected boolean recursiveCheckForCode(IOException e, int code) {
    HttpResponseException httpException = getHttpResponseException(e);
    return httpException != null && getHttpStatusCode(httpException) == code;
  }

  @Nullable
  public static GoogleJsonResponseException getJsonResponseException(Throwable throwable) {
    Throwable cause = throwable;
    while (cause != null) {
      if (cause instanceof GoogleJsonResponseException) {
        return (GoogleJsonResponseException) cause;
      }
      cause = cause.getCause();
    }
    return null;
  }

  @Nullable
  public static HttpResponseException getHttpResponseException(Throwable throwable) {
    Throwable cause = throwable;
    while (cause != null) {
      if (cause instanceof HttpResponseException) {
        return (HttpResponseException) cause;
      }
      cause = cause.getCause();
    }
    return null;
  }
}