com.nike.backstopper.handler.ApiExceptionHandlerBase Maven / Gradle / Ivy
Show all versions of backstopper-core Show documentation
package com.nike.backstopper.handler;
import com.nike.backstopper.apierror.ApiError;
import com.nike.backstopper.apierror.SortedApiErrorSet;
import com.nike.backstopper.apierror.projectspecificinfo.ProjectApiErrors;
import com.nike.backstopper.exception.ApiException;
import com.nike.backstopper.exception.StackTraceLoggingBehavior;
import com.nike.backstopper.exception.WrapperException;
import com.nike.backstopper.exception.network.NetworkExceptionBase;
import com.nike.backstopper.handler.listener.ApiExceptionHandlerListener;
import com.nike.backstopper.handler.listener.ApiExceptionHandlerListenerResult;
import com.nike.backstopper.model.DefaultErrorContractDTO;
import com.nike.internal.util.Pair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import static com.nike.backstopper.exception.StackTraceLoggingBehavior.FORCE_NO_STACK_TRACE;
import static com.nike.backstopper.exception.StackTraceLoggingBehavior.FORCE_STACK_TRACE;
/**
* The base class for a main API exception handler. Generally speaking there will be different extension classes for
* different frameworks that define the {@link T} type and implement the abstract
* {@link #prepareFrameworkRepresentation(DefaultErrorContractDTO, int, Collection, Throwable, RequestInfoForLogging)}
* method in a way that makes sense for the framework. Frameworks can often define sane defaults for the
* list of {@link ApiExceptionHandlerListener}s and {@link ApiExceptionHandlerUtils} that the constructor requires
* (while still leaving individual projects the capability for overriding the default values), leaving the definition
* of the {@link ProjectApiErrors} constructor argument the only thing a project needs to do for a complete API
* exception handler.
*
* In any case by implementing the abstract method, inserting an implementation of this class into the appropriate
* place to handle exceptions for a project, calling {@link #maybeHandleException(Throwable, RequestInfoForLogging)}
* when an exception occurs and using the result to build the response for the caller, this class can serve to
* completely solve the error handling requirements for a project.
*
*
If an exception is handled it will be logged along with all the relevant request data and debugging info
* available.
*
*
NOTE: An implementation of {@link UnhandledExceptionHandlerBase} should be used as a catch-all for your project
* in case the implementation of this class fails to catch and handle an exception for any reason.
*
* @param The type of object that the framework requires to be returned as the response (or the type that you simply
* want to convert the error contract to for whatever reason). For some frameworks this may just be the raw
* {@link DefaultErrorContractDTO} that will be serialized to the output format directly (e.g. JSON) to
* satisfy the error contract. Other frameworks may require some kind of wrapper object or other
* representation. This can be the final response object, or some kind of intermediate object that is further
* transformed outside the bounds of this class - that decision is up to the implementor. The recommended
* pattern is to have this type be an object representing the response body only, and the framework transforms
* the resulting {@link ErrorResponseInfo} to the final response for the caller outside the bounds of this
* class.
*
* @author Nic Munroe
*/
@SuppressWarnings("WeakerAccess")
public abstract class ApiExceptionHandlerBase {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
protected final ProjectApiErrors projectApiErrors;
protected final List apiExceptionHandlerListenerList;
protected final ApiExceptionHandlerUtils utils;
/**
* Creates a new instance with the given arguments.
*
* @param projectApiErrors The {@link ProjectApiErrors} used for this project - cannot be null.
* @param apiExceptionHandlerListenerList
* The list of {@link ApiExceptionHandlerListener}s that will be used for this project to analyze
* exceptions and see if they should be handled (and how they should be handled if so). These will be
* executed in list order. This cannot be null (pass in an empty list if you really don't have any
* listeners for your project, however this should never be the case in practice - you should always
* include {@link com.nike.backstopper.handler.listener.impl.GenericApiExceptionHandlerListener}
* at the very least).
* @param utils The {@link ApiExceptionHandlerUtils} that should be used by this instance. You can pass in
* {@link ApiExceptionHandlerUtils#DEFAULT_IMPL} if you don't need custom logic. Cannot be null.
*/
public ApiExceptionHandlerBase(ProjectApiErrors projectApiErrors,
List apiExceptionHandlerListenerList,
ApiExceptionHandlerUtils utils) {
if (projectApiErrors == null)
throw new IllegalArgumentException("projectApiErrors cannot be null.");
if (apiExceptionHandlerListenerList == null)
throw new IllegalArgumentException("apiExceptionHandlerListenerList cannot be null.");
if (utils == null)
throw new IllegalArgumentException("apiExceptionHandlerUtils cannot be null.");
this.projectApiErrors = projectApiErrors;
this.apiExceptionHandlerListenerList = apiExceptionHandlerListenerList;
this.utils = utils;
}
/**
* The default set of exception classes that should be considered "wrapper" exceptions. This is returned by
* {@link #getWrapperExceptionClassNames()} by default unless you override that method. See
* {@link #getWrapperExceptionClassNames()} and {@link #unwrapAndFindCoreException(Throwable)} for more details.
*/
public final Set DEFAULT_WRAPPER_EXCEPTION_CLASS_NAMES = new HashSet<>(Arrays.asList(
WrapperException.class.getName(),
ExecutionException.class.getName(),
"java.util.concurrent.CompletionException"
));
/**
* @param errorContractDTO The default internal representation model DTO of the error contract that should be
* returned to the client.
* @param httpStatusCode The calculated HTTP status code that the {@code errorContractDTO} represents and should be
* returned in the response to the caller.
* @param rawFilteredApiErrors
* The collection of raw {@link ApiError}s that were used to create {@code errorContractDTO} - most of the
* time this can be ignored, however it is supplied here in case there's some relevant info you need for
* generating the framework response that is not contained in {@code errorContractDTO}.
* @param originalException The original exception that was handled - most of the time this can be ignored, however
* it is supplied here in case there's some relevant info you need for generating the
* framework response that is not contained in {@code errorContractDTO}.
* @param request The request info that was passed in and used for logging - most of the time this can be ignored,
* however it is supplied here in case there's some relevant info you need for generating the
* framework response that is not contained in {@code errorContractDTO}.
* @return The object required by the framework to represent the error contract to send to the caller. This may
* simply be the given {@link DefaultErrorContractDTO} if the framework is able to use the object directly
* to convert to the necessary serialized representation (e.g. JSON via Jackson), or it might be a wrapper
* object or some other representation required by the framework as long as it will ultimately appear to
* the client as the desired final error contract.
*/
protected abstract T prepareFrameworkRepresentation(
DefaultErrorContractDTO errorContractDTO, int httpStatusCode, Collection rawFilteredApiErrors,
Throwable originalException, RequestInfoForLogging request);
/**
* @param frameworkRepresentation The framework representation generated by
* {@link #prepareFrameworkRepresentation(DefaultErrorContractDTO, int, Collection, Throwable,
* RequestInfoForLogging)}.
* @param errorContractDTO The default internal representation model DTO of the error contract that was generated
* based on {@code rawFilteredApiErrors}.
* @param httpStatusCode The calculated HTTP status code that the {@code errorContractDTO} represents and should be
* returned with the error contract.
* @param rawFilteredApiErrors The collection of raw {@link ApiError}s that were used to create
* {@code errorContractDTO}.
* @param originalException The original exception that was handled.
* @param request The request info that was passed in and used for logging.
* @return A map of the desired extra headers that should be included in the
* {@link ErrorResponseInfo#headersToAddToResponse} map returned by
* {@link #maybeHandleException(Throwable, RequestInfoForLogging)}, or null if you don't have any extra
* headers you want added. The error_uid header will automatically be included in
* {@link ErrorResponseInfo#headersToAddToResponse} so you should not attempt to add that here.
*/
protected Map> extraHeadersForResponse(
T frameworkRepresentation, DefaultErrorContractDTO errorContractDTO, int httpStatusCode,
Collection rawFilteredApiErrors, Throwable originalException, RequestInfoForLogging request
) {
return null;
}
/**
* @param ex The exception that this class may or may not want to handle.
* @param request The incoming request.
* @return The object that the framework or project will translate into the error response for the client, or null
* if this class did not want to handle the exception (and in that case an implementation of
* {@link UnhandledExceptionHandlerBase} should be used as a failsafe to handle it).
*
* @throws UnexpectedMajorExceptionHandlingError This will be thrown if an unexpected error occurred while trying to
* execute this method. This should never be thrown under normal circumstances and likely indicates a bug
* in this class that needs to be fixed. Callers should still catch this exception and do something
* appropriate if it is thrown (usually passing the original exception on to the project's implementation
* of {@link UnhandledExceptionHandlerBase}).
*/
public ErrorResponseInfo maybeHandleException(Throwable ex, RequestInfoForLogging request)
throws UnexpectedMajorExceptionHandlingError
{
try {
ApiExceptionHandlerListenerResult result = shouldHandleApiException(ex);
if (result.shouldHandleResponse)
return doHandleApiException(result.errors, result.extraDetailsForLogging, result.extraResponseHeaders,
ex, request);
}
catch(Exception ohNoException) {
throw new UnexpectedMajorExceptionHandlingError(
"Unexpected major error in " + this.getClass().getName() + ". We had an inner exception while trying "
+ "to handle the original controller exception. This needs to be fixed ASAP. "
+ "major_error_in_api_exception_handler=true",
ohNoException
);
}
// Any other exceptions should be handled by an UnhandledExceptionHandlerBase implementation
return null;
}
/**
* @return An {@link ApiExceptionHandlerListenerResult} indicating whether we should handle the given exception.
* If {@link ApiExceptionHandlerListenerResult#shouldHandleResponse} is true then
* {@link ApiExceptionHandlerListenerResult#errors} and
* {@link ApiExceptionHandlerListenerResult#extraDetailsForLogging} must be filled in appropriately and
* ready for passing in to
* {@link #doHandleApiException(SortedApiErrorSet, List, List, Throwable, RequestInfoForLogging)}. If it is
* false then the given exception will be ignored by this class (and should therefore ultimately be handled
* by this project's implementation of {@link UnhandledExceptionHandlerBase}).
*/
protected ApiExceptionHandlerListenerResult shouldHandleApiException(Throwable ex) {
// The original exception might be a "wrapper" exception. If so, unwrap it so we can send the core exception
// through our list of listeners.
Throwable coreEx = unwrapAndFindCoreException(ex);
// Run through each listener looking for one that wants to handle the core exception.
for (ApiExceptionHandlerListener listener : apiExceptionHandlerListenerList) {
ApiExceptionHandlerListenerResult result = listener.shouldHandleException(coreEx);
if (result.shouldHandleResponse)
return result;
}
// We didn't have any handler that wanted to deal with this exception, so return an "ignore it" response.
return ApiExceptionHandlerListenerResult.ignoreResponse();
}
/**
* "Unwraps" the given exception by digging through the {@link Throwable#getCause()} chain until a non-wrapper
* exception type is found. Uses {@link #getWrapperExceptionClassNames()} as the set of exception classes that are
* considered wrappers.
*
* @param error The exception that may (or may not) need to be "unwrapped".
* @return The root/core cause exception that is not a wrapper exception - the passed-in exception will be returned
* as-is if it is not a wrapper exception or if it has no cause.
*/
protected Throwable unwrapAndFindCoreException(Throwable error) {
if (error == null || error.getCause() == null || error.getCause() == error)
return error;
// At this point there must be a non-null cause, and it is not a reference to itself. See if it's a wrapper.
if (getWrapperExceptionClassNames().contains(error.getClass().getName())) {
// This is a wrapper. Extract the cause.
error = error.getCause();
// Recursively unwrap until we get something that is not unwrappable
error = unwrapAndFindCoreException(error);
}
return error;
}
/**
* @return The set of exception classes that should be considered "wrapper" exceptions, used by
* {@link #unwrapAndFindCoreException(Throwable)}. Returns {@link #DEFAULT_WRAPPER_EXCEPTION_CLASS_NAMES}
* by default - override this if you have your own exception classes you want considered wrapper
* exceptions.
*/
protected Set getWrapperExceptionClassNames() {
return DEFAULT_WRAPPER_EXCEPTION_CLASS_NAMES;
}
/**
* Helper method for {@link #maybeHandleException(Throwable, RequestInfoForLogging)} that handles the nitty gritty
* of logging the appropriate request info, converting the errors into an {@link DefaultErrorContractDTO}, and using
* {@link #prepareFrameworkRepresentation(DefaultErrorContractDTO, int, Collection, Throwable,
* RequestInfoForLogging)} to generate the appropriate response for the client.
* @param clientErrors The ApiErrors that the originalException converted into.
* @param extraDetailsForLogging Any extra details that should be logged along with the usual request/error info.
* @param extraResponseHeaders Any extra response headers that should be sent to the caller in the HTTP response.
* @param originalException The original exception that we are handling.
* @param request The incoming request.
*/
protected ErrorResponseInfo doHandleApiException(
SortedApiErrorSet clientErrors, List> extraDetailsForLogging,
List>> extraResponseHeaders, Throwable originalException,
RequestInfoForLogging request
) {
Throwable coreException = unwrapAndFindCoreException(originalException);
// Add connection type to our extra logging data if appropriate. This particular log message is here so it can
// be done in one spot rather than trying to track down all the different places we're handling
// NetworkExceptionBase subclasses (and possibly missing some by accident).
if (coreException instanceof NetworkExceptionBase) {
NetworkExceptionBase neb = ((NetworkExceptionBase)coreException);
extraDetailsForLogging.add(Pair.of("connection_type", neb.getConnectionType()));
}
// We may need to drop some of our client errors if we have a mix of http status codes (see javadocs on
// ProjectApiError's determineHighestPriorityHttpStatusCode and getSublistContainingOnlyHttpStatusCode
// methods).
Integer highestPriorityStatusCode = projectApiErrors.determineHighestPriorityHttpStatusCode(clientErrors);
Collection filteredClientErrors =
projectApiErrors.getSublistContainingOnlyHttpStatusCode(clientErrors, highestPriorityStatusCode);
// Bulletproof against somehow getting a completely empty collection of client errors. This should never happen
// but if it does we want a reasonable response.
if (filteredClientErrors == null || filteredClientErrors.size() == 0) {
ApiError genericServiceError = projectApiErrors.getGenericServiceError();
UUID trackingUuid = UUID.randomUUID();
String trackingLogKey = "bad_handler_logic_tracking_uuid";
extraDetailsForLogging.add(Pair.of(trackingLogKey, trackingUuid.toString()));
logger.error(String.format(
"Found a situation where we ended up with 0 ApiErrors to return to the client. This should not happen "
+ "and likely indicates a logic error in ApiExceptionHandlerBase, or a ProjectApiErrors that isn't "
+ "setup properly. Defaulting to " + genericServiceError.getName() + " for now, but this should be "
+ "investigated and fixed. Search for %s=%s in the logs to find the log message that contains the "
+ "details of the request along with the full stack trace of the original exception. "
+ "unfiltered_api_errors=%s",
trackingLogKey, trackingUuid.toString(), utils.concatenateErrorCollection(clientErrors)
));
filteredClientErrors = Collections.singletonList(genericServiceError);
highestPriorityStatusCode = genericServiceError.getHttpStatusCode();
}
// Log all the relevant error/debugging info.
StringBuilder logMessage = new StringBuilder();
logMessage.append("ApiExceptionHandlerBase handled exception occurred: ");
String errorId = utils.buildErrorMessageForLogs(
logMessage, request, filteredClientErrors, highestPriorityStatusCode, coreException, extraDetailsForLogging
);
// Don't log the stack trace on 4xx validation exceptions, but do log it on anything else.
if (shouldLogStackTrace(
highestPriorityStatusCode, filteredClientErrors, originalException, coreException, request
)) {
logger.error(logMessage.toString(), originalException);
}
else {
logger.warn(logMessage.toString());
}
// Generate our internal default representation of the error contract (the DefaultErrorContractDTO), and
// translate it into the representation required by the framework.
DefaultErrorContractDTO errorContractDTO = new DefaultErrorContractDTO(errorId, filteredClientErrors);
T frameworkRepresentation = prepareFrameworkRepresentation(
errorContractDTO, highestPriorityStatusCode, filteredClientErrors, originalException, request
);
// Setup the final additional response headers that should be sent back to the caller.
Map> finalHeadersForResponse = new HashMap<>();
// Start with any extra headers that came into this method.
if (extraResponseHeaders != null) {
for (Pair> addMe : extraResponseHeaders) {
finalHeadersForResponse.put(addMe.getLeft(), addMe.getRight());
}
}
// Then add any from the extraHeadersForResponse() method from this class.
Map> evenMoreExtraHeadersForResponse = extraHeadersForResponse(
frameworkRepresentation, errorContractDTO, highestPriorityStatusCode, filteredClientErrors,
originalException, request
);
if (evenMoreExtraHeadersForResponse != null)
finalHeadersForResponse.putAll(evenMoreExtraHeadersForResponse);
// Always add the error_uid header that matches the errorId that was generated.
finalHeadersForResponse.put("error_uid", Collections.singletonList(errorId));
// Finally, return the ErrorResponseInfo with the status code, framework response, and headers for the response.
return new ErrorResponseInfo<>(highestPriorityStatusCode, frameworkRepresentation, finalHeadersForResponse);
}
/**
* @param statusCode The HTTP status code associated with this error.
* @param filteredClientErrors The filtered collection of {@link ApiError}s associated with this error.
* @param originalException The original error.
* @param coreException The core exception after the original was passed through
* {@link #unwrapAndFindCoreException(Throwable)}. This may be the same object as
* originalException.
* @param request The request info.
*
* @return true if the given originalException should be logged at error log level with a stack trace, false if it
* should be logged at warn log level with only the class type and message. By default this method
* will return false for 4xx HTTP status code errors, true for everything else. This method honors
* the case where coreException is an {@link ApiException} and {@link
* ApiException#getStackTraceLoggingBehavior()} asks to force the stack trace logging on or off.
* Override this method if you need different behavior.
*/
@SuppressWarnings("UnusedParameters")
protected boolean shouldLogStackTrace(
int statusCode, Collection filteredClientErrors, Throwable originalException,
Throwable coreException, RequestInfoForLogging request
) {
if (coreException instanceof ApiException) {
// See if this ApiException is explicitly requesting stack trace logging to be forced on or off.
StackTraceLoggingBehavior stackTraceLoggingBehavior =
((ApiException)coreException).getStackTraceLoggingBehavior();
if (stackTraceLoggingBehavior == FORCE_STACK_TRACE) {
return true;
}
else if (stackTraceLoggingBehavior == FORCE_NO_STACK_TRACE) {
return false;
}
// If we reach here then stackTraceLoggingBehavior is null or DEFER_TO_DEFAULT_BEHAVIOR.
// In either case, we want the default logic to be used to determine whether the stack trace is logged.
}
// By default, 4xx should *not* log stack trace. Everything else should.
return statusCode < 400 || statusCode >= 500;
}
}