Skip to content
Draft
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
Expand Up @@ -183,6 +183,16 @@ default void responseReceived() {}
default void requestSent() {}
;

/**
* Adds an annotation that a streaming request has been sent.
*
* @param requestSize the size of the request in bytes.
*/
default void requestSent(long requestSize) {
requestSent();
}
;

/**
* Adds an annotation that a batch of writes has been flushed.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
/*
* 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.api.gax.rpc.DeadlineExceededException;
import com.google.api.gax.rpc.WatchdogTimeoutException;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import java.io.FileNotFoundException;
import java.net.BindException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.nio.channels.UnresolvedAddressException;
import java.security.GeneralSecurityException;
import java.util.Set;
import javax.annotation.Nullable;
import javax.net.ssl.SSLHandshakeException;

public class ErrorTypeUtil {

public 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<Class<? extends Throwable>> AUTHENTICATION_EXCEPTION_CLASSES =
ImmutableSet.of(GeneralSecurityException.class, FileNotFoundException.class);

private static final Set<Class<? extends Throwable>> CLIENT_TIMEOUT_EXCEPTION_CLASSES =
ImmutableSet.of(
SocketTimeoutException.class,
WatchdogTimeoutException.class,
DeadlineExceededException.class);

private static final Set<Class<? extends Throwable>> CLIENT_CONNECTION_EXCEPTIONS =
ImmutableSet.of(
ConnectException.class,
UnknownHostException.class,
SSLHandshakeException.class,
UnresolvedAddressException.class,
NoRouteToHostException.class,
BindException.class);

/**
* 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>Client-Side Network/Operational Errors:</b> For errors occurring within the client
* library or network stack, mapping to specific enum representations from {@link
* ErrorType}. This includes checking the cause chain for diagnostic markers (e.g., {@code
* ConnectException} or {@code SocketTimeoutException}).
* <li><b>Specific Server Error Code:</b> If no {@code ErrorInfo.reason} is available and it is
* not a client-side failure, 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>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. Extract error info reason (most specific server-side info)
if (error instanceof ApiException) {
String reason = ((ApiException) error).getReason();
if (!Strings.isNullOrEmpty(reason)) {
return reason;
}
}

// 2. Attempt client side error (includes checking cause chains)
String clientError = getClientSideError(error);
if (clientError != null) {
return clientError;
}

// 3. Extract server status code if available
if (error instanceof ApiException) {
String errorCode = extractServerErrorCode((ApiException) error);
if (errorCode != null) {
return errorCode;
}
}

// 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 server error code from an ApiException.
*
* @param apiException The ApiException to extract the error code from.
* @return A string representing the error code, or null if no specific code can be determined.
*/
@Nullable
private static String extractServerErrorCode(ApiException apiException) {
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 (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 (isClientUnknownError(error)) {
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 hasErrorClassInCauseChain(e, CLIENT_TIMEOUT_EXCEPTION_CLASSES);
}

/**
* 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 hasErrorClassInCauseChain(e, CLIENT_CONNECTION_EXCEPTIONS);
}

/**
* 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 +238 to +240
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 hasErrorClassInCauseChain(e, AUTHENTICATION_EXCEPTION_CLASSES);
}

/**
* 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 +261 to +263
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 classes.
*
* @param t The Throwable to check.
* @param errorClasses A set of class objects to check against.
* @return true if an error from the set is found in the cause chain, false otherwise.
*/
private static boolean hasErrorClassInCauseChain(
Throwable t, Set<Class<? extends Throwable>> errorClasses) {
Throwable current = t;
while (current != null) {
for (Class<? extends Throwable> errorClass : errorClasses) {
if (errorClass.isInstance(current)) {
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