Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,13 @@ public class VaultClient {

private final Predicate<Throwable> shouldRetry = cause -> {
if (cause instanceof WebApplicationException wae) {
final int status = wae.getResponse().getStatus();
final Response response = wae.getResponse();
if (response == null) {
// no response information: don't make the retry loop NPE, keep retrying as a
// recoverable error (the underlying cause is logged by retryFuture).
return true;
}
Comment thread
undx marked this conversation as resolved.
final int status = response.getStatus();
if (Status.NOT_FOUND.getStatusCode() == status || status >= 500) {
return false;
}
Expand Down Expand Up @@ -326,7 +332,7 @@ private CompletionStage<List<DecryptedValue>> doDecipher(final Collection<String
})
// oops, smtg went wrong
.exceptionally(e -> {
final Throwable cause = e.getCause();
final Throwable cause = unwrap(e);
String message = "";
int status = cantDecipherStatusCode;
if (cause instanceof WebApplicationException wae) {
Expand Down Expand Up @@ -406,28 +412,33 @@ private CompletionStage<Authentication> doAuth(final String role, final String s
return authentication;
})
//
.exceptionally(e -> {
final Throwable cause = e.getCause();
if (cause instanceof WebApplicationException wae) {
final Response response = wae.getResponse();
String message = "";
if (wae.getResponse().getEntity() instanceof ErrorPayload) {
throw wae; // already logged and setup broken so just rethrow
} else {
try {
message = response.readEntity(String.class);
} catch (final Exception ignored) {
// no-op
}
if (message.isEmpty()) {
message = cause.getMessage();
}
throwError(response.getStatus(), message);
}
}
throwError(cause);
return null;
});
.exceptionally(this::handleAuthException);
}

private Authentication handleAuthException(final Throwable e) {
final Throwable cause = unwrap(e);
if (cause instanceof WebApplicationException wae) {
final Response response = wae.getResponse();
// if the response is null we cannot extract status/entity, fall back to the
// null-safe throwError(Throwable) below (avoids an NPE that would mask the cause).
if (response != null) {
String message = "";
if (response.getEntity() instanceof ErrorPayload) {
throw wae; // already logged and setup broken so just rethrow
}
try {
message = response.readEntity(String.class);
} catch (final Exception ignored) {
// no-op
}
if (message == null || message.isEmpty()) {
message = cause.getMessage();
}
throwError(response.getStatus(), message);
}
}
throwError(cause);
return null;
}

private <T> CompletableFuture<T> withRetries(final Supplier<CompletableFuture<T>> attempt,
Expand All @@ -446,9 +457,9 @@ private <T> CompletableFuture<T> retryFuture(final Supplier<CompletableFuture<T>
log
.info("[retryFuture] Retry failed operation ({}/{}). Reason: {}.", attemptsSoFar,
numberOfRetryOnFailure, throwable.getMessage());
if (nextAttempt > numberOfRetryOnFailure || !shouldRetry.test(throwable.getCause())) {
if (nextAttempt > numberOfRetryOnFailure || !shouldRetry.test(unwrap(throwable))) {
log.info("[retryFuture] Stop retry failed operation (condition triggered).");
throwError(throwable.getCause());
throwError(unwrap(throwable));
}
return flatten(flatten(CompletableFuture.supplyAsync(attempter, scheduler))
.thenApply(CompletableFuture::completedFuture)
Expand All @@ -466,11 +477,16 @@ private void throwError(final int status, final String message) {
}

private void throwError(final Throwable cause) {
// CompletableFuture#exceptionally hands the exception "as is". If the previous stage threw
// the exception directly (not wrapped in a CompletionException), Throwable#getCause() can
// return null. Be defensive so we never trigger an NPE that would mask the original error.
final Throwable safeCause = cause != null
? cause
: new IllegalStateException("Unknown error (null cause)");
String message = "";
int status = cantDecipherStatusCode;
if (cause instanceof WebApplicationException wae) {
if (safeCause instanceof WebApplicationException wae) {
final Response response = wae.getResponse();
status = response.getStatus();
if (response != null) {
if (response.getEntity() instanceof ErrorPayload) { // internal error
throw wae;
Expand All @@ -483,13 +499,27 @@ private void throwError(final Throwable cause) {
}
}
}
if (message.isEmpty()) {
message = cause.getMessage();
if (message == null || message.isEmpty()) {
message = safeCause.getMessage();
}
throw new WebApplicationException(message,
Response.status(status).entity(new ErrorPayload(ErrorDictionary.UNEXPECTED, message)).build());
}

/**
* Returns a non-null root cause for a {@link Throwable} received by a
* {@link java.util.concurrent.CompletableFuture#exceptionally(java.util.function.Function)} lambda.
* Falls back to the exception itself when {@link Throwable#getCause()} is {@code null} (the
* upstream stage threw the exception directly instead of wrapping it in a {@code CompletionException}).
*/
private static Throwable unwrap(final Throwable e) {
if (e == null) {
return new IllegalStateException("Unknown error (null exception)");
}
final Throwable cause = e.getCause();
return cause != null ? cause : e;
}

// workaround while geronimo-config does not support generics of generics
// (1.2.1 in org.apache.geronimo.config.cdi.ConfigInjectionBean.create)
private boolean isReloadableConfigSet(final String value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@
import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletionException;
import java.util.function.Predicate;

import javax.enterprise.inject.se.SeContainer;
import javax.enterprise.inject.se.SeContainerInitializer;
import javax.inject.Inject;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.WebTarget;
Expand Down Expand Up @@ -296,6 +303,118 @@
response.readEntity(ErrorPayload.class).getDescription());
}

@Test
void throwErrorWithNullResponseDoesNotNpe() throws Exception {
// A WebApplicationException whose getResponse() returns null must not trigger an NPE
// in throwError(Throwable); the default cantDecipherStatusCode should be used instead.
final int defaultStatus = 422;
// Use a fresh VaultClient instance (not the CDI proxy) so reflective field access works.
final VaultClient vaultClient = new VaultClient();
vaultClient.setCantDecipherStatusCode(defaultStatus);
final WebApplicationException waeWithoutResponse = new WebApplicationException("boom") {

@Override
public Response getResponse() {
return null;
}
};

final Method throwError = VaultClient.class.getDeclaredMethod("throwError", Throwable.class);
throwError.setAccessible(true);
try {
throwError.invoke(vaultClient, waeWithoutResponse);
fail("Expected a WebApplicationException to be thrown");
} catch (final InvocationTargetException ite) {
final Throwable target = ite.getTargetException();
assertTrue(target instanceof WebApplicationException,
"Expected WebApplicationException, got " + target);
final WebApplicationException thrown = (WebApplicationException) target;
assertEquals(defaultStatus, thrown.getResponse().getStatus());
assertEquals("boom", thrown.getMessage());
}
}

@Test
@SuppressWarnings("unchecked")
void shouldRetryWithNullResponseDoesNotNpe() throws Exception {
// Regression: shouldRetry must not call wae.getResponse().getStatus() when the response is null;
// otherwise retryFuture() raises an NPE before throwError(...) can build the fallback payload.
final VaultClient vaultClient = new VaultClient();
final Field f = VaultClient.class.getDeclaredField("shouldRetry");
f.setAccessible(true);

final WebApplicationException waeWithoutResponse = new WebApplicationException("boom") {

@Override
public Response getResponse() {
return null;
}
};

// Must not NPE, and must allow the retry loop to keep running (true = retry).
assertTrue(((Predicate<Throwable>) f.get(vaultClient)).test(waeWithoutResponse));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can it be assertEquals?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The result is the same with assertEquals and is more expressive with a predicate.
Any reason to prefer?

}

@Test
void handleAuthExceptionWithNullResponseDoesNotNpe() throws Exception {
// Regression: doAuth().exceptionally() used to dereference wae.getResponse().getEntity()
// and response.getStatus() without a null check; with a null response that NPE masked the
// original cause. The handler now falls through to throwError(Throwable) which is null-safe.
final int defaultStatus = 422;
final VaultClient client = new VaultClient();

Check warning on line 364 in vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java

View check run for this annotation

sonar-rnd / SonarQube Code Analysis

vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java#L364

Rename "client" which hides the field declared at line 56.
client.setCantDecipherStatusCode(defaultStatus);

final WebApplicationException waeWithoutResponse = new WebApplicationException("boom") {

@Override
public Response getResponse() {
return null;
}
};
// Mimics what CompletableFuture#exceptionally receives: a CompletionException wrapping cause.
final CompletionException wrapped = new CompletionException(waeWithoutResponse);

final Method handler = VaultClient.class.getDeclaredMethod("handleAuthException", Throwable.class);
handler.setAccessible(true);
try {
handler.invoke(client, wrapped);
fail("Expected a WebApplicationException to be thrown");
} catch (final java.lang.reflect.InvocationTargetException ite) {
final Throwable target = ite.getTargetException();
assertTrue(target instanceof WebApplicationException,
"Expected WebApplicationException, got " + target);
final WebApplicationException thrown = (WebApplicationException) target;
assertEquals(defaultStatus, thrown.getResponse().getStatus());
assertEquals("boom", thrown.getMessage());
}
}

@Test
void handleAuthExceptionWithNullCauseDoesNotNpe() throws Exception {
// Regression: CompletableFuture#exceptionally can receive an exception thrown directly
// (not wrapped in CompletionException), so e.getCause() may be null. The handler must
// not NPE on cause.getMessage() / shouldRetry / throwError.
final int defaultStatus = 422;
final VaultClient client = new VaultClient();

Check warning on line 398 in vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java

View check run for this annotation

sonar-rnd / SonarQube Code Analysis

vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java#L398

Rename "client" which hides the field declared at line 56.
client.setCantDecipherStatusCode(defaultStatus);
// RuntimeException with no cause => e.getCause() returns null.
final RuntimeException directThrow = new RuntimeException("direct");

final Method handler = VaultClient.class.getDeclaredMethod("handleAuthException", Throwable.class);
handler.setAccessible(true);
try {
handler.invoke(client, directThrow);
fail("Expected a WebApplicationException to be thrown");
} catch (final java.lang.reflect.InvocationTargetException ite) {
final Throwable target = ite.getTargetException();
assertTrue(target instanceof WebApplicationException,
"Expected WebApplicationException, got " + target);
final WebApplicationException thrown = (WebApplicationException) target;
assertEquals(defaultStatus, thrown.getResponse().getStatus());
assertEquals("direct", thrown.getMessage());
}
}

/**
* Demonstration purpose: with a "real" vault server instance
*
Expand All @@ -320,9 +439,7 @@
@Test
@Disabled
void executeWithRealVault() {
// setup.setVaultUrl("http://localhost:8200");
Comment thread
undx marked this conversation as resolved.
setup.vaultClient(setup.vaultExecutorService());
// vault.setVault(setup.vaultTarget(realClt));
vault.setAuthEndpoint("/v1/auth/approle/login");
vault.setDecryptEndpoint("/v1/transit/decrypt/{x-talend-tenant-id}");
vault.setRole(() -> "_role-id_");
Expand Down
Loading