Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
/*
* Copyright 2026 Google LLC
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google LLC nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.google.api.gax.tracing;

import com.google.api.gax.rpc.ApiException;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import java.util.Set;
import javax.annotation.Nullable;

public class ErrorTypeUtil {

enum ErrorType {
CLIENT_TIMEOUT,
CLIENT_CONNECTION_ERROR,
CLIENT_REQUEST_ERROR,
CLIENT_REQUEST_BODY_ERROR,
CLIENT_RESPONSE_DECODE_ERROR,
CLIENT_REDIRECT_ERROR,
CLIENT_AUTHENTICATION_ERROR,
CLIENT_UNKNOWN_ERROR,
INTERNAL;

@Override
public String toString() {
return name();
}
}

private static final Set<String> JSON_DECODING_EXCEPTION_CLASS_NAMES =
ImmutableSet.of(
"com.google.gson.JsonSyntaxException",
"com.google.gson.JsonParseException",
"com.fasterxml.jackson.databind.JsonMappingException",
"com.fasterxml.jackson.core.JsonParseException");

private static final Set<String> AUTHENTICATION_EXCEPTION_CLASS_NAMES =
ImmutableSet.of("com.google.auth.oauth2.GoogleAuthException");

private static final Set<String> CLIENT_TIMEOUT_EXCEPTION_CLASS_NAMES =
ImmutableSet.of(
"java.net.SocketTimeoutException", "com.google.api.gax.rpc.WatchdogTimeoutException");
private static final Set<String> CLIENT_CONNECTION_EXCEPTIONS =
ImmutableSet.of(
"java.net.ConnectException",
"java.net.UnknownHostException",
"javax.net.ssl.SSLHandshakeException",
"java.nio.channels.UnresolvedAddressException");

/**
* Extracts a low-cardinality string representing the specific classification of the error to be
* used in the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE} attribute.
*
* <p>This value is determined based on the following priority:
*
* <ol>
* <li><b>{@code google.rpc.ErrorInfo.reason}:</b> If the error response from the service
* includes {@code google.rpc.ErrorInfo} details, the reason field (e.g.,
* "RATE_LIMIT_EXCEEDED", "SERVICE_DISABLED") will be used. This offers the most precise
* error cause.
* <li><b>Specific Server Error Code:</b> If no {@code ErrorInfo.reason} is available, but a
* server error code was received:
* <ul>
* <li>For HTTP: The HTTP status code (e.g., "403", "503").
* <li>For gRPC: The gRPC status code name (e.g., "PERMISSION_DENIED", "UNAVAILABLE").
* </ul>
* <li><b>Client-Side Network/Operational Errors:</b> For errors occurring within the client
* library or network stack, mapping to specific enum representations from {@link
* ErrorType}:
* <ul>
* <li>{@code CLIENT_TIMEOUT}: A client-configured timeout was reached.
* <li>{@code CLIENT_CONNECTION_ERROR}: Failure to establish the network connection (DNS,
* TCP, TLS).
* <li>{@code CLIENT_REQUEST_ERROR}: Client-side issue forming or sending the request.
* <li>{@code CLIENT_REQUEST_BODY_ERROR}: Error streaming the request body.
* <li>{@code CLIENT_RESPONSE_DECODE_ERROR}: Client-side error decoding the response body.
* <li>{@code CLIENT_REDIRECT_ERROR}: Problem handling HTTP redirects.
* <li>{@code CLIENT_AUTHENTICATION_ERROR}: Error during credential acquisition or
* application.
* <li>{@code CLIENT_UNKNOWN_ERROR}: For all other errors unknown to the client.
* </ul>
* <li><b>Language-specific error type:</b> The class or struct name of the exception or error
* if available. This must be low-cardinality, meaning it returns the short name of the
* exception class (e.g. {@code "IllegalStateException"}) rather than its message.
* <li><b>Internal Fallback:</b> If the error doesn't fit any of the above categories, {@code
* "INTERNAL"} will be used, indicating an unexpected issue within the client library's own
* logic.
* </ol>
*
* @param error the Throwable from which to extract the error type string.
* @return a low-cardinality string representing the specific error type, or {@code null} if the
* provided error is {@code null}.
*/
public static String extractErrorType(@Nullable Throwable error) {
if (error == null) {
// No information about the error; we default to INTERNAL.
return ErrorType.INTERNAL.toString();
}

// 1. & 2. Extract error info reason or server status code
if (error instanceof ApiException) {
String errorType = extractFromApiException((ApiException) error);
if (errorType != null) {
return errorType;
}
}

// 3. Attempt client side error
String clientError = getClientSideError(error);
if (clientError != null) {
return clientError;
}

// 4. Language-specific error type fallback
String exceptionName = error.getClass().getSimpleName();
if (!Strings.isNullOrEmpty(exceptionName)) {
return exceptionName;
}

// 5. Internal Fallback
return ErrorType.INTERNAL.toString();
}

/**
* Extracts the error type from an ApiException. This method prioritizes the ErrorInfo reason,
* then the transport-specific status code (HTTP or gRPC).
*
* @param apiException The ApiException to extract the error type from.
* @return A string representing the error type, or null if no specific type can be determined.
*/
@Nullable
private static String extractFromApiException(ApiException apiException) {
// 1. Check for ErrorInfo.reason
String reason = apiException.getReason();
if (!Strings.isNullOrEmpty(reason)) {
return reason;
}

// 2. Specific Server Error Code
if (apiException.getStatusCode() != null) {
Object transportCode = apiException.getStatusCode().getTransportCode();
if (transportCode instanceof Integer) {
// HTTP Status Code
return String.valueOf(transportCode);
} else if (apiException.getStatusCode().getCode() != null) {
// gRPC Status Code name
return apiException.getStatusCode().getCode().name();
}
}
return null;
}

/**
* Determines the client-side error type based on the provided Throwable. This method checks for
* various network and client-specific exceptions.
*
* @param error The Throwable to analyze.
* @return A string representing the client-side error type, or null if not matched.
*/
@Nullable
private static String getClientSideError(Throwable error) {
if (isClientTimeout(error)) {
return ErrorType.CLIENT_TIMEOUT.toString();
}
if (isClientConnectionError(error)) {
return ErrorType.CLIENT_CONNECTION_ERROR.toString();
}
if (isClientAuthenticationError(error)) {
return ErrorType.CLIENT_AUTHENTICATION_ERROR.toString();
}
if (isClientResponseDecodeError(error)) {
return ErrorType.CLIENT_RESPONSE_DECODE_ERROR.toString();
}
if (isClientRedirectError(error)) {
return ErrorType.CLIENT_REDIRECT_ERROR.toString();
}
// This covers CLIENT_REQUEST_ERROR for general illegal arguments in client requests.
if (error instanceof IllegalArgumentException) {
return ErrorType.CLIENT_REQUEST_ERROR.toString();
}
if (isRequestBodyError(error)) {
return ErrorType.CLIENT_REQUEST_BODY_ERROR.toString();
}
if (isClientUnknownError(error)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I thought this was the default option, as in there shouldn't be a check here?

Copy link
Contributor Author

@diegomarquezp diegomarquezp Mar 17, 2026

Choose a reason for hiding this comment

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

So the fallback sequence is "reason", then "server error" then "client error" then "exception name" then "INTERNAL".
This class is not only for "client error" but for the whole the fallback sequence, so we need the flow to check for other cases such as exception name and INTERNAL.

return ErrorType.CLIENT_UNKNOWN_ERROR.toString();
}
return null;
}

/**
* Checks if the given Throwable represents a client-side timeout error. This includes socket
* timeouts and GAX-specific watchdog timeouts.
*
* @param e The Throwable to check.
* @return true if the error is a client timeout, false otherwise.
*/
private static boolean isClientTimeout(Throwable e) {
return hasErrorNameInCauseChain(e, CLIENT_TIMEOUT_EXCEPTION_CLASS_NAMES);
}

/**
* Checks if the given Throwable represents a client-side connection error. This includes issues
* with establishing connections, unknown hosts, SSL handshakes, and unresolved addresses.
*
* @param e The Throwable to check.
* @return true if the error is a client connection error, false otherwise.
*/
private static boolean isClientConnectionError(Throwable e) {
return hasErrorNameInCauseChain(e, CLIENT_CONNECTION_EXCEPTIONS);
}

/**
* Checks if the given Throwable represents a client-side response decoding error. This is
* identified by exceptions related to JSON or Gson parsing, either directly or as a cause.
*
* @param e The Throwable to check.
* @return true if the error is a client response decode error, false otherwise.
*/
private static boolean isClientResponseDecodeError(Throwable e) {
return hasErrorNameInCauseChain(e, JSON_DECODING_EXCEPTION_CLASS_NAMES);
}

/**
* Checks if the given Throwable represents a client-side redirect error. This is identified by
* the presence of "redirect" in the exception message.
*
* @param e The Throwable to check.
* @return true if the error is a client redirect error, false otherwise.
*/
private static boolean isClientRedirectError(Throwable e) {
return e.getMessage() != null && e.getMessage().contains("redirect");
}
Comment on lines +257 to +259
Copy link
Contributor

Choose a reason for hiding this comment

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

high

Relying on the content of an exception message with e.getMessage().contains("redirect") is highly fragile. Exception messages are not a stable API; they can be changed, might not be present, or could be localized, which would break this logic.

It would be much more robust to check for a specific exception type that indicates a redirect error. If no such standard exception exists, this approach has a high risk of being unreliable and should be documented as such.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are no redirect-specific exceptions in gax. This is only a heuristic to comply with the higher level effort.


/**
* Checks if the given Throwable represents a client-side authentication error. This is identified
* by exceptions related to the auth library.
*
* @param e The Throwable to check.
* @return true if the error is a client authentication error, false otherwise.
*/
private static boolean isClientAuthenticationError(Throwable e) {
return hasErrorNameInCauseChain(e, AUTHENTICATION_EXCEPTION_CLASS_NAMES);
}

/**
* Checks if the given Throwable represents a client-side request body error. This is specifically
* mapped to RestSerializationException from httpjson, which indicates issues during the
* serialization of the request body for REST calls.
*
* @param e The Throwable to check.
* @return true if the error is a client request body error, false otherwise.
*/
private static boolean isRequestBodyError(Throwable e) {
return hasErrorNameInCauseChain(e, ImmutableSet.of("RestSerializationException"));
}

/**
* Checks if the given Throwable represents an unknown client-side error. This is a general
* fallback for exceptions whose class name contains "unknown", indicating an unclassified
* client-side issue.
*
* @param e The Throwable to check.
* @return true if the error is an unknown client error, false otherwise.
*/
private static boolean isClientUnknownError(Throwable e) {
return e.getClass().getName().toLowerCase().contains("unknown");
}
Comment on lines +292 to +294
Copy link
Contributor

Choose a reason for hiding this comment

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

high

Matching unknown in a class name is very broad and risky. It could lead to misclassifying exceptions. For example, an exception from another library with unknown in its name would be incorrectly categorized as CLIENT_UNKNOWN_ERROR.

Given that UnknownHostException is already handled by isClientConnectionError, this check seems too generic. Please consider making this check more specific to the intended exception types or removing it if it's a speculative catch-all.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed we don't have a reliable heuristic that also prevents external code from being caught here. In favor of the current approach, this is the last case to be handled.
One alternative: do not handle CLIENT_UNKNOWN_ERROR.


/**
* Recursively checks the throwable and its cause chain for any of the specified error name.
*
* @param t The Throwable to check.
* @param errorClassNames A set of fully qualified class names to check against.
* @return true if an error from the set is found in the cause chain, false otherwise.
*/
private static boolean hasErrorNameInCauseChain(Throwable t, Set<String> errorClassNames) {
Throwable current = t;
while (current != null) {
if (errorClassNames.contains(current.getClass().getName())) {
return true;
}
current = current.getCause();
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,10 @@ public class ObservabilityAttributes {

/** The url template of the request (e.g. /v1/{name}:access). */
public static final String URL_TEMPLATE_ATTRIBUTE = "url.template";

/**
* The specific error type. Value will be google.rpc.ErrorInfo.reason, a specific Server Error
* Code, Client-Side Network/Operational Error (e.g., CLIENT_TIMEOUT) or internal fallback.
*/
public static final String ERROR_TYPE_ATTRIBUTE = "error.type";
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@

class ObservabilityUtils {

/**
* Extracts a low-cardinality string representing the specific classification of the error to be
* used in the {@link ObservabilityAttributes#ERROR_TYPE_ATTRIBUTE} attribute. See {@link
* ErrorTypeUtil#extractErrorType} for extended documentation.
*/
static String extractErrorType(@Nullable Throwable error) {
return ErrorTypeUtil.extractErrorType(error);
}

/** Function to extract the status of the error as a string */
static String extractStatus(@Nullable Throwable error) {
final String statusString;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ private OtelSpan(io.opentelemetry.api.trace.Span span) {
this.span = span;
}

@Override
public void addAttribute(String key, String value) {
span.setAttribute(key, value);
}

@Override
public void end() {
span.end();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ public void attemptSucceeded() {
endAttempt();
}

@Override
public void attemptCancelled() {
endAttempt();
}

@Override
public void attemptFailedDuration(Throwable error, java.time.Duration delay) {
recordErrorAndEndAttempt(error);
}

@Override
public void attemptFailedRetriesExhausted(Throwable error) {
recordErrorAndEndAttempt(error);
}

@Override
public void attemptPermanentFailure(Throwable error) {
recordErrorAndEndAttempt(error);
}

private void recordErrorAndEndAttempt(Throwable error) {
if (attemptHandle != null) {
attemptHandle.addAttribute(
ObservabilityAttributes.ERROR_TYPE_ATTRIBUTE, ObservabilityUtils.extractErrorType(error));
endAttempt();
}
}

private void endAttempt() {
if (attemptHandle != null) {
attemptHandle.end();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public interface TraceManager {
Span createSpan(String name, Map<String, Object> attributes);

interface Span {
void addAttribute(String key, String value);

void end();
}
}
Loading
Loading