diff --git a/.gitignore b/.gitignore index 8be8ff69..5a414a60 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ classes/ .classpath ### Misc stuff -.vagrant \ No newline at end of file +.vagrant +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index dbe17891..68705701 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ Riposte is a collection of several libraries, mainly divided up based on depende * [riposte-spi](riposte-spi/) - Contains the main interfaces and classes necessary to define the interface for a Riposte server. * [riposte-core](riposte-core/) - Builds on `riposte-spi` to provide a fully functioning Riposte server. -* [riposte-async-http-client](riposte-async-http-client/) - Contains [`AsyncHttpClientHelper`](https://github.com/Nike-Inc/riposte/blob/master/riposte-async-http-client/src/main/java/com/nike/riposte/client/asynchttp/ning/AsyncHttpClientHelper.java), an HTTP client for performing async nonblocking calls using `CompletableFuture`s with distributed tracing baked in. +* [riposte-async-http-client](riposte-async-http-client/) - **DEPRECATED - Please see riposte-async-http-clien2** Contains [`AsyncHttpClientHelper`](https://github.com/Nike-Inc/riposte/blob/master/riposte-async-http-client/src/main/java/com/nike/riposte/client/asynchttp/ning/AsyncHttpClientHelper.java), an HTTP client for performing async nonblocking calls using `CompletableFuture`s with distributed tracing baked in. +* [riposte-async-http-client2](riposte-async-http-client2/) - Contains [`AsyncHttpClientHelper`](https://github.com/Nike-Inc/riposte/blob/master/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/ning/AsyncHttpClientHelper.java), an HTTP client for performing async nonblocking calls using `CompletableFuture`s with distributed tracing baked in. * [riposte-metrics-codahale](riposte-metrics-codahale/) - Contains metrics support for Riposte using the `io.dropwizard` version of Codahale metrics. * [riposte-metrics-codahale-signalfx](riposte-metrics-codahale-signalfx/) - Contains SignalFx-specific extensions of the `riposte-metrics-codahale` library module. * [riposte-auth](riposte-auth/) - Contains a few implementations of the Riposte [`RequestSecurityValidator`](https://github.com/Nike-Inc/riposte/blob/master/riposte-spi/src/main/java/com/nike/riposte/server/error/validation/RequestSecurityValidator.java), e.g. for basic auth and other security schemes. diff --git a/build.gradle b/build.gradle index f2f6b4c5..4cde925b 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,7 @@ ext { javassistVersion = '3.18.2-GA' jacksonVersion = '2.9.9' ningAsyncHttpClientVersion = '1.9.38' + asyncHttpClientVersion = '2.9.0' servletApiVersion = '3.1.0' diff --git a/riposte-async-http-client2/build.gradle b/riposte-async-http-client2/build.gradle new file mode 100644 index 00000000..afd75c40 --- /dev/null +++ b/riposte-async-http-client2/build.gradle @@ -0,0 +1,21 @@ +evaluationDependsOn(':') + +dependencies { + compile( + project(":riposte-core"), + "org.asynchttpclient:async-http-client:$asyncHttpClientVersion" + ) + compileOnly( + "org.jetbrains:annotations:$jetbrainsAnnotationsVersion" + ) + testCompile( + "org.jetbrains:annotations:$jetbrainsAnnotationsVersion", + "junit:junit:$junitVersion", + "org.mockito:mockito-core:$mockitoVersion", + "io.rest-assured:rest-assured:$restAssuredVersion", + "org.assertj:assertj-core:$assertJVersion", + "com.tngtech.java:junit-dataprovider:$junitDataproviderVersion", + "ch.qos.logback:logback-classic:$logbackVersion", + "ch.qos.logback:logback-core:$logbackVersion" + ) +} diff --git a/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncCompletionHandlerWithTracingAndMdcSupport.java b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncCompletionHandlerWithTracingAndMdcSupport.java new file mode 100644 index 00000000..3bdafac9 --- /dev/null +++ b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncCompletionHandlerWithTracingAndMdcSupport.java @@ -0,0 +1,299 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.fastbreak.CircuitBreaker; +import com.nike.internal.util.Pair; +import com.nike.internal.util.StringUtils; +import com.nike.riposte.server.config.distributedtracing.SpanNamingAndTaggingStrategy; +import com.nike.riposte.util.AsyncNettyHelper; +import com.nike.wingtips.Span; +import com.nike.wingtips.Tracer; +import com.nike.wingtips.http.HttpRequestTracingUtils; + +import org.asynchttpclient.AsyncCompletionHandler; +import org.asynchttpclient.Response; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.util.Deque; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import static com.nike.riposte.util.AsyncNettyHelper.linkTracingAndMdcToCurrentThread; + +/** + * Extension of {@link org.asynchttpclient.AsyncCompletionHandler} that handles distributed tracing and MDC issues so + * that the dtrace and MDC info you want are attached to the thread performing the work for the downstream call. The + * {@link #completableFutureResponse} you pass in will be completed or completed exceptionally depending on the result + * of the downstream call, and it will be completed with the result of the {@link #responseHandlerFunction} you pass + * in. + *

+ * Used by {@link AsyncHttpClientHelper} + * + * @author Nic Munroe + */ +@SuppressWarnings({"WeakerAccess", "OptionalUsedAsFieldOrParameterType"}) +class AsyncCompletionHandlerWithTracingAndMdcSupport extends AsyncCompletionHandler { + + private static final Logger logger = LoggerFactory.getLogger(AsyncCompletionHandlerWithTracingAndMdcSupport.class); + + /** + * The {@link CompletableFuture} that should be completed with {@link #responseHandlerFunction}'s value (or + * completed exceptionally if an error occurs) when the downstream call returns. + */ + protected final CompletableFuture completableFutureResponse; + /** + * The handler that will get notified with the downstream call's response. The value of {@link + * AsyncResponseHandler#handleResponse(Response)} will be used to complete {@link #completableFutureResponse}. + */ + protected final AsyncResponseHandler responseHandlerFunction; + /** + * Whether or not the downstream call should be surrounded with a subspan. If true then {@link + * #distributedTraceStackToUse} will have a subspan placed on top, otherwise it will be used as-is. + */ + protected final boolean performSubSpanAroundDownstreamCalls; + /** + * The distributed tracing span stack to use for the downstream call. If {@link + * #performSubSpanAroundDownstreamCalls} is true then a new subspan will be placed on top of this, otherwise it will + * be used as-is. + */ + protected final Deque distributedTraceStackToUse; + /** + * The MDC context to associate with the downstream call. + */ + protected final Map mdcContextToUse; + /** + * The circuit breaker manual mode task to notify of response events or exceptions, or empty if circuit breaking has + * been disabled for this call. + */ + protected final Optional> circuitBreakerManualTask; + /** + * A copy of the {@link RequestBuilderWrapper} that will be used to execute the HTTP client call, with ony the + * HTTP method and URL populated. This copy performs two purposes - it keeps us from holding onto the original, + * which may have a large body that we don't want to hold onto, and it lets the original be reused (i.e. adjusting + * HTTP method and/or URL, etc) without affecting this class' referencing of the original values. + * + */ + protected final RequestBuilderWrapper rbwCopyWithHttpMethodAndUrlOnly; + /** + * The {@link SpanNamingAndTaggingStrategy} to use when creating subspan names and response/error tagging for the + * subspan (only used if {@link #performSubSpanAroundDownstreamCalls} is true). + */ + protected final SpanNamingAndTaggingStrategy tagAndNamingStrategy; + + /** + * @param completableFutureResponse + * The {@link CompletableFuture} that should be completed with {@code responseHandlerFunction}'s value (or + * completed exceptionally if an error occurs) when the downstream call returns. + * @param responseHandlerFunction + * The handler that will get notified with the downstream call's response. The value of {@link + * AsyncResponseHandler#handleResponse(Response)} will be used to complete {@code completableFutureResponse}. + * @param performSubSpanAroundDownstreamCalls + * Whether or not the downstream call should be surrounded with a subspan. If true then {@code + * distributedTraceStackToUse} will have a subspan placed on top, otherwise it will be used as-is. + * @param requestBuilderWrapper The {@link RequestBuilderWrapper} that will be used to execute the HTTP client call. + * @param circuitBreakerManualTask + * The circuit breaker manual mode task to notify of response events or exceptions, or empty if circuit breaking + * has been disabled for this call. + * @param distributedTraceStackToUse + * The distributed trace stack to use for the downstream call. If {@code performSubSpanAroundDownstreamCalls} is + * true then a new subspan will be placed on top of this, otherwise it will be used as-is. + * @param mdcContextToUse The MDC context to associate with the downstream call. + * @param tagAndNamingStrategy The {@link SpanNamingAndTaggingStrategy} to use when creating subspan names and + * response/error tagging for the subspan (only used if {@link #performSubSpanAroundDownstreamCalls} is true). + */ + AsyncCompletionHandlerWithTracingAndMdcSupport( + CompletableFuture completableFutureResponse, + AsyncResponseHandler responseHandlerFunction, + boolean performSubSpanAroundDownstreamCalls, + RequestBuilderWrapper requestBuilderWrapper, + Optional> circuitBreakerManualTask, + Deque distributedTraceStackToUse, + Map mdcContextToUse, + SpanNamingAndTaggingStrategy tagAndNamingStrategy + ) { + this.completableFutureResponse = completableFutureResponse; + this.responseHandlerFunction = responseHandlerFunction; + this.performSubSpanAroundDownstreamCalls = performSubSpanAroundDownstreamCalls; + this.circuitBreakerManualTask = circuitBreakerManualTask; + this.rbwCopyWithHttpMethodAndUrlOnly = new RequestBuilderWrapper( + requestBuilderWrapper.getUrl(), + requestBuilderWrapper.getHttpMethod(), + null, + null, + true + ); + this.tagAndNamingStrategy = tagAndNamingStrategy; + + // Grab the calling thread's dtrace stack and MDC info so we can set it back when this constructor completes. + Pair, Map> originalThreadInfo = null; + + try { + // Do a subspan around the downstream call if desired. + if (performSubSpanAroundDownstreamCalls) { + // Start by setting up the distributed trace stack and MDC for the call as specified in the method + // arguments, and grab the return value so we have the original calling thread's dtrace stack and + // MDC info (used to set everything back to original state when this constructor completes). + originalThreadInfo = linkTracingAndMdcToCurrentThread(distributedTraceStackToUse, mdcContextToUse); + + // Then add the subspan. + String spanName = getSubspanSpanName(requestBuilderWrapper, tagAndNamingStrategy); + // Start a new child/subspan for this call if possible, falling back to a new trace (rather + // than child/subspan) if there's no current span on the thread. The + // startSpanInCurrentContext() method will do the right thing here in either case. + Tracer.getInstance().startSpanInCurrentContext(spanName, Span.SpanPurpose.CLIENT); + + // Since we modified the stack/MDC we need to update the args that will be used for the downstream call. + distributedTraceStackToUse = Tracer.getInstance().getCurrentSpanStackCopy(); + mdcContextToUse = MDC.getCopyOfContextMap(); + } + + this.distributedTraceStackToUse = distributedTraceStackToUse; + this.mdcContextToUse = mdcContextToUse; + } finally { + // Reset the tracing and MDC info to what it was when the constructor was called if we messed around with + // stuff. If originalThreadInfo is null then nothing needs to be done. + if (originalThreadInfo != null) + AsyncNettyHelper.unlinkTracingAndMdcFromCurrentThread(originalThreadInfo); + } + } + + /** + * @return The span that will be used for the downstream call, or null if no span will be used. + */ + public Span getSpanForCall() { + if (distributedTraceStackToUse == null || distributedTraceStackToUse.isEmpty()) + return null; + + return distributedTraceStackToUse.peek(); + } + + /** + * Returns the name that should be used for the subspan surrounding the call. Defaults to whatever {@link + * SpanNamingAndTaggingStrategy#getInitialSpanName(Object)} returns, with a fallback + * of {@link HttpRequestTracingUtils#getFallbackSpanNameForHttpRequest(String, String)} if the naming strategy + * returned null or blank string. You can override this method to return something else if you want different + * behavior and you don't want to adjust the naming strategy or adapter. + * + * @param request The request that is about to be executed. + * @param namingStrategy The {@link SpanNamingAndTaggingStrategy} being used. + * @return The name that should be used for the subspan surrounding the call. + */ + protected @NotNull String getSubspanSpanName( + @NotNull RequestBuilderWrapper request, + @NotNull SpanNamingAndTaggingStrategy namingStrategy + ) { + // Try the naming strategy first. + String subspanNameFromStrategy = namingStrategy.getInitialSpanName(request); + + if (StringUtils.isNotBlank(subspanNameFromStrategy)) { + return subspanNameFromStrategy; + } + + // The naming strategy didn't have anything for us. Fall back to something reasonable. + return HttpRequestTracingUtils.getFallbackSpanNameForHttpRequest( + "async_downstream_call", request.httpMethod + ); + } + + @Override + public Response onCompleted(Response response) { + Pair, Map> originalThreadInfo = null; + + try { + // Link up the distributed tracing and MDC information to the current thread + originalThreadInfo = linkTracingAndMdcToCurrentThread(distributedTraceStackToUse, mdcContextToUse); + + // Notify the circuit breaker of an event. + try { + circuitBreakerManualTask.ifPresent(cb -> cb.handleEvent(response)); + } catch (Throwable t) { + logger.error( + "Circuit breaker threw an exception during handleEvent. This should never happen and means the " + + "CircuitBreaker is malfunctioning. Ignoring exception.", t + ); + } + + // If a subspan was started for the downstream call, it should now be completed + if (performSubSpanAroundDownstreamCalls) { + Span spanAroundCall = Tracer.getInstance().getCurrentSpan(); + + // Handle the final span naming and response tagging. + tagAndNamingStrategy.handleResponseTaggingAndFinalSpanName( + spanAroundCall, rbwCopyWithHttpMethodAndUrlOnly, response, null + ); + + // The Span.close() method will do the right thing whether or not this is an overall request span or + // subspan. + spanAroundCall.close(); + } + + // If the completableFutureResponse is already done it means we were cancelled or some other error occurred, + // and we should not do any more processing here. + if (completableFutureResponse.isDone()) + return response; + + // Pass the response to our responseHandlerFunction to get the resulting object to complete the + // completableFutureResponse with. + try { + O responseInfo = responseHandlerFunction.handleResponse(response); + completableFutureResponse.complete(responseInfo); + } catch (Throwable throwable) { + // responseHandlerFunction threw an error. Complete completableFutureResponse exceptionally. + completableFutureResponse.completeExceptionally(throwable); + } + + return response; + } finally { + AsyncNettyHelper.unlinkTracingAndMdcFromCurrentThread(originalThreadInfo); + } + } + + @Override + public void onThrowable(Throwable t) { + Pair, Map> originalThreadInfo = null; + + try { + // Link up the distributed trace and MDC information to the current thread + originalThreadInfo = + linkTracingAndMdcToCurrentThread(distributedTraceStackToUse, mdcContextToUse); + + // Notify the circuit breaker of an exception. + try { + circuitBreakerManualTask.ifPresent(cb -> cb.handleException(t)); + } catch (Throwable cbError) { + logger.error( + "Circuit breaker threw an exception during handleException. This should never happen and means the " + + "CircuitBreaker is malfunctioning. Ignoring exception.", cbError + ); + } + + // If a subspan was started for the downstream call, it should now be completed + if (performSubSpanAroundDownstreamCalls) { + Span spanAroundCall = Tracer.getInstance().getCurrentSpan(); + + // Handle the final span naming and response tagging. + tagAndNamingStrategy.handleResponseTaggingAndFinalSpanName( + spanAroundCall, rbwCopyWithHttpMethodAndUrlOnly, null, t + ); + + // The Span.close() method will do the right thing whether or not this is an overall request span or + // subspan. + spanAroundCall.close(); + } + + // If the completableFutureResponse is already done it means we were cancelled or some other error occurred, + // and we should not do any more processing here. + if (completableFutureResponse.isDone()) + return; + + // Complete the completableFutureResponse with the exception. + completableFutureResponse.completeExceptionally(t); + } finally { + AsyncNettyHelper.unlinkTracingAndMdcFromCurrentThread(originalThreadInfo); + } + } +} diff --git a/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelper.java b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelper.java new file mode 100644 index 00000000..e25cd813 --- /dev/null +++ b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelper.java @@ -0,0 +1,503 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.fastbreak.CircuitBreaker; +import com.nike.fastbreak.CircuitBreaker.ManualModeTask; +import com.nike.fastbreak.CircuitBreakerDelegate; +import com.nike.fastbreak.exception.CircuitBreakerOpenException; +import com.nike.riposte.server.channelpipeline.ChannelAttributes; +import com.nike.riposte.server.config.distributedtracing.SpanNamingAndTaggingStrategy; +import com.nike.riposte.server.http.HttpProcessingState; +import com.nike.wingtips.Span; +import com.nike.wingtips.Tracer; +import com.nike.wingtips.http.HttpRequestTracingUtils; + +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.DefaultAsyncHttpClient; +import org.asynchttpclient.DefaultAsyncHttpClientConfig; +import org.asynchttpclient.Response; +import org.asynchttpclient.SignatureCalculator; +import org.asynchttpclient.uri.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.net.InetAddress; +import java.util.Deque; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.EventLoop; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.resolver.DefaultNameResolver; +import io.netty.resolver.NameResolver; +import io.netty.resolver.RoundRobinInetAddressResolver; +import io.netty.util.concurrent.ImmediateEventExecutor; + +import static com.nike.fastbreak.CircuitBreakerForHttpStatusCode.getDefaultHttpStatusCodeCircuitBreakerForKey; + +/** + * WARNING: This class should be used as a singleton (or a small set number per app, depending on if you have calls + * with different connection pooling/TTL requirements, etc)! Each new instance of this class will create a new + * threadpool under the hood for handling the HTTP calls and responses, so if you create a new instance of this class + * for every call your app may eventually fall over due to a thread explosion. + *

+ * {@code AsyncHttpClientHelper} is a helper class to make it easy to perform asynchronous downstream HTTP requests that + * map to a {@link CompletableFuture}. This is a wrapper around com.ning's Async HTTP Client that takes care of all + * the distributed tracing and MDC issues for you, and fully supports connection keep-alive and connection pooling. The + * only drawback to using this library is that it doesn't link up to your Netty 4 server's worker I/O threads so there + * will be a handful of extra threads (2 * CPU cores) for the library to do its work. The overhead for this number of + * extra threads is negligible since it's a small fixed number that does not increase no matter how much traffic it + * handles (but again, see the warning above about using this as a singleton). + *

+ * USAGE: + *

    + *
  1. + * As stated previously in the warning at the top of this javadoc, you generally should only create *one* + * instance of this {@code AsyncHttpClientHelper} class and reuse it for *all* calls. If you have calls with + * different connection pooling/TTL type requirements then you can create one instance for each category, but + * do *not* create new instances of this class frequently - there should only be a small handful (at most) per + * application. + *
  2. + *
  3. + * Call {@link #getRequestBuilder(String, HttpMethod)} or {@link + * #getRequestBuilder(String, HttpMethod, Optional, boolean)} to get your hands on a request builder. + *
  4. + *
  5. + * Call the builder methods on the returned {@link RequestBuilderWrapper#requestBuilder} to set all the query + * params, headers, request body, etc that you want in the request. + *
  6. + *
  7. + * Call one of the {@code executeAsyncHttpRequest(...)} methods with the finished request builder to execute + * the async HTTP call and return a {@link CompletableFuture} that will be completed when the async HTTP call + * returns. + *
  8. + *
+ * + * @author Nic Munroe + */ +@SuppressWarnings({"WeakerAccess", "OptionalUsedAsFieldOrParameterType"}) +public class AsyncHttpClientHelper { + + /** + * The default amount of time in milliseconds that pooled downstream connections are eligible to be reused. If you + * want a different value make sure you call the {@link + * AsyncHttpClientHelper#(Builder)} constructor and set {@link + * Builder#setConnectionTtl(int)} to your desired value. Pass in -1 to disable TTL, causing + * connections to be held onto and reused forever (as long as they are open and valid). This is not recommended, + * however - see below for the reason why you should have a reasonable TTL. + *

+ * It's generally a good idea to have a TTL of some sort because the IP addresses associated with a DNS address can + * change, and if you never TTL your connections you could have requests fail when a pooled connection associated + * with a domain tries to hit an IP address that is no longer valid for that DNS. In particular, Amazon ELBs scale + * up by (among other things) swapping in bigger boxes and changing the DNS to point to the new instance IPs, so if + * you never TTL your connections you could have your service end up in a bad state trying to talk to stale ELB IPs. + * A TTL of a few minutes is generally a good tradeoff between the cost of creating new connections vs. hitting + * stale IPs. + *

+ * NOTE: When this TTL is passed a connection will *not* be severed if it is actively serving a request, but the + * next time it is returned to the pool it will be discarded. + */ + public static final int DEFAULT_POOLED_DOWNSTREAM_CONNECTION_TTL_MILLIS = + ((Long) TimeUnit.MINUTES.toMillis(3)).intValue(); + + /** + * The default amount of time in milliseconds that a request sent by {@link AsyncHttpClientHelper} will wait before + * giving up. This default is set to just under the default Amazon ELB timeout of 60 seconds so that the error you + * receive will be a much more informative request timeout error rather than the opaque 504 the ELB would throw. + */ + public static final int DEFAULT_REQUEST_TIMEOUT_MILLIS = ((Long) TimeUnit.SECONDS.toMillis(58)) + .intValue(); + + /** + * Default name resolver used for {@link AsyncHttpClient} if none is provided. + */ + public static final RoundRobinInetAddressResolver DEFAULT_NAME_RESOLVER = new RoundRobinInetAddressResolver( + ImmediateEventExecutor.INSTANCE, + new DefaultNameResolver(ImmediateEventExecutor.INSTANCE) + ); + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + protected final AsyncHttpClient asyncHttpClient; + protected final boolean performSubSpanAroundDownstreamCalls; + /** + * Controls span naming and tagging when {@link #performSubSpanAroundDownstreamCalls} is true. + */ + protected final SpanNamingAndTaggingStrategy spanNamingAndTaggingStrategy; + + protected final NameResolver nameResolver; + + /** + * Constructor that gives you maximum control over configuration and behavior. + * + * @param builder + * The builder that will create the {@link #asyncHttpClient} and execute all the async downstream HTTP + * requests. + */ + private AsyncHttpClientHelper(Builder builder) { + this.performSubSpanAroundDownstreamCalls = builder.performSubSpanAroundDownstreamCalls; + this.spanNamingAndTaggingStrategy = builder.spanNamingAndTaggingStrategy; + this.nameResolver = builder.nameResolver; + + Map mdcContextMap = MDC.getCopyOfContextMap(); + Deque distributedTraceStack = null; + + try { + // We have to unlink tracing and MDC from the current thread before we setup the async http client library, + // otherwise all the internal threads it uses to do its job will be attached to the current thread's + // trace/MDC info forever and always. + distributedTraceStack = Tracer.getInstance().unregisterFromThread(); + MDC.clear(); + AsyncHttpClientConfig cf = builder.clientConfigBuilder.build(); + asyncHttpClient = new DefaultAsyncHttpClient(cf) + .setSignatureCalculator(builder.defaultSignatureCalculator); + } + finally { + // Reattach the original tracing and MDC before we leave + if (mdcContextMap == null) + MDC.clear(); + else + MDC.setContextMap(mdcContextMap); + + Tracer.getInstance().registerWithThread(distributedTraceStack); + } + } + + /** + * Default constructor that uses default settings for {@link #asyncHttpClient} and sets {@link + * #performSubSpanAroundDownstreamCalls} to true (so all downstream requests will be tagged with subspans). + */ + public AsyncHttpClientHelper() { + this(new Builder()); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private boolean performSubSpanAroundDownstreamCalls = true; + private SignatureCalculator defaultSignatureCalculator; + private NameResolver nameResolver = DEFAULT_NAME_RESOLVER; + public DefaultAsyncHttpClientConfig.Builder clientConfigBuilder = new DefaultAsyncHttpClientConfig.Builder() + .setMaxRequestRetry(0) + .setRequestTimeout(DEFAULT_REQUEST_TIMEOUT_MILLIS) + .setConnectionTtl(DEFAULT_POOLED_DOWNSTREAM_CONNECTION_TTL_MILLIS); + + private SpanNamingAndTaggingStrategy spanNamingAndTaggingStrategy = DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy.getDefaultInstance(); + + public Builder() { + } + + /** + * Sets the default {@link SignatureCalculator} that will used when making requests with the configured + * {@link AsyncHttpClient}. + *

+ * {@link SignatureCalculator} is a class that is normally used to set a header based on the full request being sent. + * It provides a hook as the last step before sending the request. + */ + public AsyncHttpClientHelper.Builder setDefaultSignatureCalculator( + SignatureCalculator signatureCalculator) { + this.defaultSignatureCalculator = signatureCalculator; + return this; + } + + /** + * Sets the flag to determine if SubSpan are created around the downstream calls + */ + public AsyncHttpClientHelper.Builder setPerformSubSpanAroundDownstreamCalls(boolean performSubSpanAroundDownstreamCalls) { + this.performSubSpanAroundDownstreamCalls = performSubSpanAroundDownstreamCalls; + return this; + } + + /** + * Sets the {@link SpanNamingAndTaggingStrategy} that should be used when this class surrounds outbound calls + * with subspans (i.e. when {@link #performSubSpanAroundDownstreamCalls} is true). The standard/default + * class that is used is {@link DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy} - if you want to + * adjust something, you will probably want to start with that class as a base. + * + * @param spanNamingAndTaggingStrategy The strategy to use. + * @return This same instance being called, to enable fluent setup. + */ + public AsyncHttpClientHelper.Builder setSpanNamingAndTaggingStrategy( + SpanNamingAndTaggingStrategy spanNamingAndTaggingStrategy + ) { + if (spanNamingAndTaggingStrategy == null) { + throw new IllegalArgumentException("spanNamingAndTaggingStrategy cannot be null"); + } + + this.spanNamingAndTaggingStrategy = spanNamingAndTaggingStrategy; + return this; + } + + public AsyncHttpClientHelper.Builder setMaxRequestRetry(int maxRequestRetry) { + this.clientConfigBuilder.setMaxRequestRetry(maxRequestRetry); + return this; + } + + public AsyncHttpClientHelper.Builder setRequestTimeout(int requestTimeout) { + this.clientConfigBuilder.setRequestTimeout(requestTimeout); + return this; + } + + public AsyncHttpClientHelper.Builder setConnectionTtl(int connectionTtl) { + this.clientConfigBuilder.setConnectionTtl(connectionTtl); + return this; + } + + public AsyncHttpClientHelper.Builder setClientConfigBuilder(DefaultAsyncHttpClientConfig.Builder clientConfigBuilder) { + if (clientConfigBuilder == null) { + throw new IllegalArgumentException("clientConfigBuilder cannot be null"); + } + + this.clientConfigBuilder = clientConfigBuilder; + return this; + } + + public AsyncHttpClientHelper.Builder setNameResolver(NameResolver nameResolver) { + this.nameResolver = nameResolver; + return this; + } + + public AsyncHttpClientHelper build() { + return new AsyncHttpClientHelper(this); + } + } + + /** + * Call this before one of the {@code executeAsyncHttpRequest(...)} methods in order to get a request builder you + * can populate with query params, headers, body, etc. If you want to specify a custom circuit breaker (or disable + * circuit breaking entirely) for this call then use {@link #getRequestBuilder(String, HttpMethod, Optional, + * boolean)} instead. This method tells the HTTP client to use a default circuit breaker based on the host being + * called. + */ + public RequestBuilderWrapper getRequestBuilder(String url, HttpMethod method) { + return getRequestBuilder(url, method, Optional.empty(), false); + } + + /** + * Call this before one of the {@code executeAsyncHttpRequest(...)} methods in order to get a request builder you + * can populate with query params, headers, body, etc. Pass in a non-empty {@code customCircuitBreaker} argument to + * specify the exact circuit breaker you want to use, pass in an empty {@code customCircuitBreaker} if you want the + * HTTP client to use a default one based on the host being called, and pass in true for the {@code + * disableCircuitBreaker} argument if you want to disable circuit breaking entirely for this call. + */ + public RequestBuilderWrapper getRequestBuilder(String url, HttpMethod method, + Optional> customCircuitBreaker, + boolean disableCircuitBreaker) { + RequestBuilderWrapper wrapper = generateRequestBuilderWrapper( + url, method, customCircuitBreaker, disableCircuitBreaker + ); + + // By default, The AsyncHttpClient doesn't properly split traffic when a DNS has multiple IP addresses associated with it, + // so we have to set a name resolver that does. + wrapper.requestBuilder.setNameResolver(nameResolver); + + return wrapper; + } + + protected RequestBuilderWrapper generateRequestBuilderWrapper(String url, HttpMethod method, + Optional> customCircuitBreaker, + boolean disableCircuitBreaker) { + String httpMethod = method.name(); + switch (httpMethod) { + case "CONNECT": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.prepareConnect(url), + customCircuitBreaker, disableCircuitBreaker); + case "DELETE": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.prepareDelete(url), + customCircuitBreaker, disableCircuitBreaker); + case "GET": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.prepareGet(url), + customCircuitBreaker, + disableCircuitBreaker); + case "HEAD": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.prepareHead(url), + customCircuitBreaker, disableCircuitBreaker); + case "POST": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.preparePost(url), + customCircuitBreaker, disableCircuitBreaker); + case "OPTIONS": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.prepareOptions(url), + customCircuitBreaker, disableCircuitBreaker); + case "PUT": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.preparePut(url), + customCircuitBreaker, + disableCircuitBreaker); + case "PATCH": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.preparePatch(url), + customCircuitBreaker, disableCircuitBreaker); + case "TRACE": + return new RequestBuilderWrapper(url, httpMethod, asyncHttpClient.prepareTrace(url), + customCircuitBreaker, disableCircuitBreaker); + default: + logger.warn( + "The given method {} is not directly supported. We will try to force it anyway. The returned request builder may or may not work.", + httpMethod); + return new RequestBuilderWrapper(url, httpMethod, + asyncHttpClient.preparePost(url).setMethod(httpMethod), + customCircuitBreaker, + disableCircuitBreaker); + } + } + + /** + * Executes the given request asynchronously, handling the response with the given responseHandlerFunction, and + * returns a {@link CompletableFuture} that represents the result of executing the + * responseHandlerFunction on the downstream response. Any error anywhere along the way will cause the returned + * future to be completed with {@link CompletableFuture#completeExceptionally(Throwable)}. + *

+ * NOTE: This is a helper method for calling {@link #executeAsyncHttpRequest(RequestBuilderWrapper, + * AsyncResponseHandler, java.util.Deque, java.util.Map)} that uses the current thread's {@link + * Tracer#getCurrentSpanStackCopy()} and {@link org.slf4j.MDC#getCopyOfContextMap()} for the for the distributed + * trace stack and MDC info for the downstream call. + */ + public CompletableFuture executeAsyncHttpRequest( + RequestBuilderWrapper requestBuilderWrapper, + AsyncResponseHandler responseHandlerFunction) { + Map mdcContextMap = MDC.getCopyOfContextMap(); + Deque distributedTraceStack = Tracer.getInstance().getCurrentSpanStackCopy(); + + return executeAsyncHttpRequest(requestBuilderWrapper, responseHandlerFunction, + distributedTraceStack, + mdcContextMap); + } + + /** + * Executes the given request asynchronously, handling the response with the given responseHandlerFunction, and + * returns a {@link CompletableFuture} that represents the result of executing the + * responseHandlerFunction on the downstream response. Any error anywhere along the way will cause the returned + * future to be completed with {@link CompletableFuture#completeExceptionally(Throwable)}. + *

+ * NOTE: This is a helper method for calling {@link #executeAsyncHttpRequest(RequestBuilderWrapper, + * AsyncResponseHandler, java.util.Deque, java.util.Map)} that uses {@link + * ChannelAttributes#getHttpProcessingStateForChannel(ChannelHandlerContext)} to extract the {@link + * HttpProcessingState} from the given ctx argument, and then grabs {@link + * HttpProcessingState#getDistributedTraceStack()} and {@link HttpProcessingState#getLoggerMdcContextMap()} to use + * as the distributed trace stack and MDC info for the downstream call. + */ + public CompletableFuture executeAsyncHttpRequest( + RequestBuilderWrapper requestBuilderWrapper, + AsyncResponseHandler responseHandlerFunction, + ChannelHandlerContext ctx) { + + HttpProcessingState state = ChannelAttributes.getHttpProcessingStateForChannel(ctx).get(); + if (state == null) + throw new IllegalStateException("state cannot be null"); + + Map mdcContextMap = state.getLoggerMdcContextMap(); + Deque distributedTraceStack = state.getDistributedTraceStack(); + + requestBuilderWrapper.setCtx(ctx); + + return executeAsyncHttpRequest(requestBuilderWrapper, responseHandlerFunction, + distributedTraceStack, + mdcContextMap); + } + + /** + * Executes the given request asynchronously, handling the response with the given responseHandlerFunction, and + * returns a {@link CompletableFuture} that represents the result of executing the + * responseHandlerFunction on the downstream response. Any error anywhere along the way will cause the returned + * future to be completed with {@link CompletableFuture#completeExceptionally(Throwable)}. + *

+ * Distributed Tracing and MDC for the downstream call: The given {@code distributedTraceStackForCall} and + * {@code mdcContextForCall} arguments are used to setup distributed trace and MDC info for the downstream call so + * that the callback will be performed with that data attached to whatever thread the callback is done on. + */ + public CompletableFuture executeAsyncHttpRequest( + RequestBuilderWrapper requestBuilderWrapper, + AsyncResponseHandler responseHandlerFunction, + Deque distributedTraceStackForCall, + Map mdcContextForCall) { + CompletableFuture completableFutureResponse = new CompletableFuture<>(); + + try { + Optional> circuitBreakerManualTask = + getCircuitBreaker(requestBuilderWrapper).map(CircuitBreaker::newManualModeTask); + + // If we have a circuit breaker, give it a chance to throw an exception if the circuit is open/tripped + circuitBreakerManualTask.ifPresent(ManualModeTask::throwExceptionIfCircuitBreakerIsOpen); + + // Setup the async completion handler for the call. + AsyncCompletionHandlerWithTracingAndMdcSupport asyncCompletionHandler = + new AsyncCompletionHandlerWithTracingAndMdcSupport<>( + completableFutureResponse, responseHandlerFunction, + performSubSpanAroundDownstreamCalls, + requestBuilderWrapper, circuitBreakerManualTask, distributedTraceStackForCall, + mdcContextForCall, + spanNamingAndTaggingStrategy + ); + + // Add distributed trace headers to the downstream call if we have a span. + Span spanForCall = asyncCompletionHandler.getSpanForCall(); + if (spanForCall != null) { + HttpRequestTracingUtils.propagateTracingHeaders( + (headerKey, headerValue) -> { + if (headerValue != null) { + requestBuilderWrapper.requestBuilder.setHeader(headerKey, headerValue); + } + }, + spanForCall + ); + } + + // Add span tags if we're doing a subspan around the call. + if (performSubSpanAroundDownstreamCalls && spanForCall != null) { + spanNamingAndTaggingStrategy.handleRequestTagging(spanForCall, requestBuilderWrapper); + } + + // Execute the downstream call. The completableFutureResponse will be completed or completed exceptionally + // depending on the result of the call. + requestBuilderWrapper.requestBuilder.execute(asyncCompletionHandler); + } catch (Throwable t) { + // Log the error for later debugging, unless it's a CircuitBreakerOpenException, which is expected and + // normal when the circuit breaker associated with this request has been tripped. + if (!(t instanceof CircuitBreakerOpenException)) { + logger.error( + "An error occurred while trying to set up an async HTTP call for method {} and URL {}. " + + "The CompletableFuture will be instantly failed with this error", + requestBuilderWrapper.httpMethod, requestBuilderWrapper.url, t + ); + } + completableFutureResponse.completeExceptionally(t); + } + + return completableFutureResponse; + } + + protected Optional> getCircuitBreaker( + RequestBuilderWrapper requestBuilderWrapper) { + if (requestBuilderWrapper.disableCircuitBreaker) + return Optional.empty(); + + // Circuit breaking is enabled for this call. So we return the custom one specified or use the default one if a + // custom one is not specified. + if (requestBuilderWrapper.customCircuitBreaker.isPresent()) + return requestBuilderWrapper.customCircuitBreaker; + + // No custom circuit breaker. Use the default for the given request's host. + Uri uri = Uri.create(requestBuilderWrapper.url); + String host = uri.getHost(); + EventLoop nettyEventLoop = requestBuilderWrapper.getCtx() == null + ? null + : requestBuilderWrapper.getCtx().channel().eventLoop(); + CircuitBreaker defaultStatusCodeCircuitBreaker = getDefaultHttpStatusCodeCircuitBreakerForKey( + host, Optional.ofNullable(nettyEventLoop), Optional.ofNullable(nettyEventLoop) + ); + return Optional.of( + new CircuitBreakerDelegate<>( + defaultStatusCodeCircuitBreaker, + response -> (response == null ? null : response.getStatusCode()) + ) + ); + } + +} \ No newline at end of file diff --git a/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTagAdapter.java b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTagAdapter.java new file mode 100644 index 00000000..0a3e5770 --- /dev/null +++ b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTagAdapter.java @@ -0,0 +1,141 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.internal.util.StringUtils; +import com.nike.riposte.util.HttpUtils; +import com.nike.wingtips.tags.HttpTagAndSpanNamingAdapter; + +import org.asynchttpclient.Response; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Extension of {@link com.nike.wingtips.tags.HttpTagAndSpanNamingAdapter} that knows how to handle the request + * and response for {@link AsyncHttpClientHelper} ({@link RequestBuilderWrapper} and {@link Response}). + * + * @author Nic Munroe + */ +public class AsyncHttpClientHelperTagAdapter extends HttpTagAndSpanNamingAdapter { + + @SuppressWarnings("WeakerAccess") + protected static final AsyncHttpClientHelperTagAdapter + DEFAULT_INSTANCE = new AsyncHttpClientHelperTagAdapter(); + + /** + * @return A reusable, thread-safe, singleton instance of this class that can be used by anybody who wants to use + * this class and does not need any customization. + */ + @SuppressWarnings("unchecked") + public static AsyncHttpClientHelperTagAdapter getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + @Nullable + @Override + public String getRequestUrl(@Nullable RequestBuilderWrapper request) { + if (request == null) { + return null; + } + + return request.getUrl(); + } + + @Nullable + @Override + public String getRequestPath(@Nullable RequestBuilderWrapper request) { + if (request == null) { + return null; + } + + String result = request.getUrl(); + if (StringUtils.isBlank(result)) { + return null; + } + + // Chop out the query string (if any). + result = HttpUtils.extractPath(result); + + // If it starts with '/' then there's nothing left for us to do - it's already the path. + if (result.startsWith("/")) { + return result; + } + + // Doesn't start with '/'. We expect it to start with http at this point. + if (!result.toLowerCase().startsWith("http")) { + // Didn't start with http. Not sure what to do with this at this point, so return null. + return null; + } + + // It starts with http. Chop out the scheme and host/port. + int schemeColonAndDoubleSlashIndex = result.indexOf("://"); + if (schemeColonAndDoubleSlashIndex < 0) { + // It didn't have a colon-double-slash after the scheme. Not sure what to do at this point, so return null. + return null; + } + + int firstSlashIndexAfterSchemeDoubleSlash = result.indexOf('/', (schemeColonAndDoubleSlashIndex + 3)); + if (firstSlashIndexAfterSchemeDoubleSlash < 0) { + // No other slashes after the scheme colon-double-slash, so no real path. The path at this point is + // effectively "/". + return "/"; + } + + return result.substring(firstSlashIndexAfterSchemeDoubleSlash); + } + + @Nullable + @Override + public String getRequestUriPathTemplate(@Nullable RequestBuilderWrapper request, @Nullable Response response) { + // Nothing we can do by default - this needs to be overridden on a per-project basis and given some smarts + // based on project-specific knowledge. + return null; + } + + @Nullable + @Override + public Integer getResponseHttpStatus(@Nullable Response response) { + if (response == null) { + return null; + } + + return response.getStatusCode(); + } + + @Nullable + @Override + public String getRequestHttpMethod(@Nullable RequestBuilderWrapper request) { + if (request == null) { + return null; + } + + return request.getHttpMethod(); + } + + @Nullable + @Override + public String getHeaderSingleValue( + @Nullable RequestBuilderWrapper request, @NotNull String headerKey + ) { + // There's no way for us to get the headers - they're hidden away in the request.requestBuilder object and + // we have no access to them. + return null; + } + + @Nullable + @Override + public List getHeaderMultipleValue(@Nullable RequestBuilderWrapper request, @NotNull String headerKey) { + // There's no way for us to get the headers - they're hidden away in the request.requestBuilder object and + // we have no access to them. + return null; + } + + @Nullable + @Override + public String getSpanHandlerTagValue( + @Nullable RequestBuilderWrapper request, @Nullable Response response + ) { + return "riposte.asynchttpclienthelper"; + } +} diff --git a/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncResponseHandler.java b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncResponseHandler.java new file mode 100644 index 00000000..34845ec6 --- /dev/null +++ b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/AsyncResponseHandler.java @@ -0,0 +1,17 @@ +package com.nike.riposte.client.asynchttp; + +import org.asynchttpclient.Response; + +/** + * Interface representing a handler for an async downstream HTTP call's response. Used by {@link AsyncHttpClientHelper}. + * + * @author Nic Munroe + */ +public interface AsyncResponseHandler { + + /** + * @return The result of handling the given response. + */ + T handleResponse(Response response) throws Throwable; + +} diff --git a/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy.java b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy.java new file mode 100644 index 00000000..a0aff244 --- /dev/null +++ b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy.java @@ -0,0 +1,114 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.riposte.server.config.distributedtracing.SpanNamingAndTaggingStrategy; +import com.nike.wingtips.Span; +import com.nike.wingtips.SpanMutator; +import com.nike.wingtips.tags.HttpTagAndSpanNamingAdapter; +import com.nike.wingtips.tags.HttpTagAndSpanNamingStrategy; +import com.nike.wingtips.tags.ZipkinHttpTagStrategy; + +import org.asynchttpclient.Response; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A concrete implementation of {@link SpanNamingAndTaggingStrategy} for {@link AsyncHttpClientHelper} that works with + * {@link RequestBuilderWrapper} requests, Ning {@link Response}s, and Wingtips {@link Span}s. This class + * delegates the actual work to Wingtips {@link HttpTagAndSpanNamingStrategy} and {@link + * HttpTagAndSpanNamingAdapter} classes. + * + *

By default (default constructor, or {@link #getDefaultInstance()}) you'll get {@link + * ZipkinHttpTagStrategy#getDefaultInstance()} for the Wingtips strategy and {@link + * AsyncHttpClientHelperTagAdapter#getDefaultInstance()} for the adapter. + * + *

You can use the alternate constructor if you want different implementations, e.g. you could pass a custom {@link + * AsyncHttpClientHelperTagAdapter} that overrides {@link HttpTagAndSpanNamingAdapter#getInitialSpanName(Object)} + * and/or {@link HttpTagAndSpanNamingAdapter#getFinalSpanName(Object, Object)} if you want to adjust the span + * names that are generated. + * + * @author Nic Munroe + */ +public class DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy + extends SpanNamingAndTaggingStrategy { + + protected final @NotNull HttpTagAndSpanNamingStrategy tagAndNamingStrategy; + protected final @NotNull HttpTagAndSpanNamingAdapter tagAndNamingAdapter; + + protected static final DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy DEFAULT_INSTANCE = + new DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy(); + + /** + * @return A reusable, thread-safe, singleton instance of this class that can be used by anybody who wants to use + * this class and does not need any customization. + */ + public static DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + /** + * Creates a new instance that uses {@link ZipkinHttpTagStrategy#getDefaultInstance()} and {@link + * AsyncHttpClientHelperTagAdapter#getDefaultInstance()} to do the work of span naming and tagging. + */ + public DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy() { + this(ZipkinHttpTagStrategy.getDefaultInstance(), AsyncHttpClientHelperTagAdapter.getDefaultInstance()); + } + + /** + * Creates a new instance that uses the given arguments to do the work of span naming and tagging. + * + * @param tagAndNamingStrategy The {@link HttpTagAndSpanNamingStrategy} to use. + * @param tagAndNamingAdapter The {@link HttpTagAndSpanNamingAdapter} to use. + */ + @SuppressWarnings("ConstantConditions") + public DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy( + @NotNull HttpTagAndSpanNamingStrategy tagAndNamingStrategy, + @NotNull HttpTagAndSpanNamingAdapter tagAndNamingAdapter + ) { + if (tagAndNamingStrategy == null) { + throw new IllegalArgumentException( + "tagAndNamingStrategy cannot be null - if you really want no strategy, use NoOpHttpTagStrategy" + ); + } + + if (tagAndNamingAdapter == null) { + throw new IllegalArgumentException( + "tagAndNamingAdapter cannot be null - if you really want no adapter, use NoOpHttpTagAdapter" + ); + } + + this.tagAndNamingStrategy = tagAndNamingStrategy; + this.tagAndNamingAdapter = tagAndNamingAdapter; + } + + @Override + public @Nullable String doGetInitialSpanName( + @NotNull RequestBuilderWrapper request + ) { + return tagAndNamingStrategy.getInitialSpanName(request, tagAndNamingAdapter); + } + + @Override + public void doChangeSpanName(@NotNull Span span, @NotNull String newName) { + SpanMutator.changeSpanName(span, newName); + } + + @Override + public void doHandleRequestTagging( + @NotNull Span span, @NotNull RequestBuilderWrapper request + ) { + tagAndNamingStrategy.handleRequestTagging(span, request, tagAndNamingAdapter); + } + + @Override + public void doHandleResponseTaggingAndFinalSpanName( + @NotNull Span span, + @Nullable RequestBuilderWrapper request, + @Nullable Response response, + @Nullable Throwable error + ) { + tagAndNamingStrategy.handleResponseTaggingAndFinalSpanName( + span, request, response, error, tagAndNamingAdapter + ); + } +} diff --git a/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/RequestBuilderWrapper.java b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/RequestBuilderWrapper.java new file mode 100644 index 00000000..1e6c7fe4 --- /dev/null +++ b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/RequestBuilderWrapper.java @@ -0,0 +1,118 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.fastbreak.CircuitBreaker; + +import org.asynchttpclient.BoundRequestBuilder; +import org.asynchttpclient.Response; + +import java.util.Optional; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.HttpMethod; + +/** + * Wrapper around the actual {@link #requestBuilder} that also keeps track of the URL and HTTP method used to create the + * builder (since there are no getters to inspect the builder). Generate one of these by calling one of the + * request-starter methods in {@link AsyncHttpClientHelper}, e.g. {@link + * AsyncHttpClientHelper#getRequestBuilder(String, HttpMethod)}. From there you'll want to set any headers, add request + * body, or adjust anything else about the request by interacting with {@link #requestBuilder} before passing it into + * one of the execute methods (e.g. {@link + * AsyncHttpClientHelper#executeAsyncHttpRequest(RequestBuilderWrapper, AsyncResponseHandler, ChannelHandlerContext)}). + * + * @author Nic Munroe + */ +@SuppressWarnings({"WeakerAccess", "OptionalUsedAsFieldOrParameterType"}) +public class RequestBuilderWrapper { + + String url; + String httpMethod; + public final BoundRequestBuilder requestBuilder; + /** + * An Optional containing a custom circuit breaker if a custom one should be used, or empty if the request sender + * should use a default circuit breaker. If you don't want *any* circuit breaker to be used, set {@link + * #disableCircuitBreaker} to true. The default circuit breaker will be based on the host value of the {@link #url} + * (i.e. all calls to the same host will use the same circuit breaker). If you need something more (or less) fine + * grained than that then you'll need to provide a custom circuit breaker. + */ + Optional> customCircuitBreaker; + /** + * Set this to true if you don't want *any* circuit breaker to be used - if this is false then {@link + * #customCircuitBreaker} will be used to determine which circuit breaker to use (custom vs. default). + */ + boolean disableCircuitBreaker; + + private ChannelHandlerContext ctx; + + /** + * Intentionally package-scoped. Instances of this class are generated and returned by calling one of the + * request-starter methods in {@link AsyncHttpClientHelper}, e.g. {@link + * AsyncHttpClientHelper#getRequestBuilder(String, HttpMethod)}. + */ + RequestBuilderWrapper( + String url, String httpMethod, BoundRequestBuilder requestBuilder, + Optional> customCircuitBreaker, boolean disableCircuitBreaker + ) { + this.url = url; + this.httpMethod = httpMethod; + this.requestBuilder = requestBuilder; + this.customCircuitBreaker = customCircuitBreaker; + this.disableCircuitBreaker = disableCircuitBreaker; + } + + ChannelHandlerContext getCtx() { + return ctx; + } + + void setCtx(ChannelHandlerContext ctx) { + this.ctx = ctx; + } + + public void setCustomCircuitBreaker(Optional> customCircuitBreaker) { + this.customCircuitBreaker = customCircuitBreaker; + } + + public Optional> getCustomCircuitBreaker() { + return customCircuitBreaker; + } + + public void setDisableCircuitBreaker(boolean disableCircuitBreaker) { + this.disableCircuitBreaker = disableCircuitBreaker; + } + + public boolean isDisableCircuitBreaker() { + return disableCircuitBreaker; + } + + /** + *

Use this method to update the url stored inside this {@link RequestBuilderWrapper} + * and the wrapped {@link BoundRequestBuilder} + * + *

Setting the url only on the wrapped {@link BoundRequestBuilder} will impact logging + * and circuit breakers potentially. Use this method to keep the two in sync. + */ + public void setUrl(String url) { + this.url = url; + requestBuilder.setUrl(url); + } + + public String getUrl() { + return url; + } + + /** + *

Use this method to update the httpMethod stored inside this {@link RequestBuilderWrapper} + * and the wrapped {@link BoundRequestBuilder} + * + *

Setting the httpMethod only on the wrapped {@link BoundRequestBuilder} will impact logging + * and circuit breakers potentially. Use this method to keep the two in sync. + */ + public void setHttpMethod(String httpMethod) { + this.httpMethod = httpMethod; + requestBuilder.setMethod(httpMethod); + } + + public String getHttpMethod() { + return httpMethod; + } + +} diff --git a/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/util/AwsUtil.java b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/util/AwsUtil.java new file mode 100644 index 00000000..36605066 --- /dev/null +++ b/riposte-async-http-client2/src/main/java/com/nike/riposte/client/asynchttp/util/AwsUtil.java @@ -0,0 +1,244 @@ +package com.nike.riposte.client.asynchttp.util; + +import com.nike.riposte.client.asynchttp.AsyncHttpClientHelper; +import com.nike.riposte.server.config.AppInfo; +import com.nike.riposte.server.config.impl.AppInfoImpl; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.asynchttpclient.Response; + +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import io.netty.handler.codec.http.HttpMethod; + +/** + * Helper class for dealing with AWS related stuff. In particular {@link #getAppInfoFutureWithAwsInfo(String, String, + * AsyncHttpClientHelper)} or {@link #getAppInfoFutureWithAwsInfo(AsyncHttpClientHelper)} will give you a {@link + * CompletableFuture} that will return an {@link AppInfo} with the {@link AppInfo#dataCenter()} and {@link + * AppInfo#instanceId()} pulled from the AWS metadata services. + * + * @author Nic Munroe + */ +@SuppressWarnings("WeakerAccess") +public class AwsUtil { + + private static final Logger logger = LoggerFactory.getLogger(AwsUtil.class); + + /** + * The base IP of the magic URLs that you can call from an AWS instance to get information about that instance. + */ + public static final String AMAZON_METADATA_URL_BASE = "http://169.254.169.254"; + /** + * If you call this URL from an AWS instance you'll get back a simple string response that looks like: i-abc12d3e + */ + public static final String AMAZON_METADATA_INSTANCE_ID_URL = + AMAZON_METADATA_URL_BASE + "/latest/meta-data/instance-id"; + /** + * If you call this URL from an AWS instance you'll get back a simple string response that looks like: us-west-2b + * NOTE: Don't confuse this with region - they are slightly different (the region for this us-west-2b availability + * zone would be us-west-2). + */ + @SuppressWarnings("unused") + public static final String AMAZON_METADATA_AVAILABILITY_ZONE_URL = + AMAZON_METADATA_URL_BASE + "/latest/meta-data/placement/availability-zone"; + /** + * If you call this URL from an AWS instance you'll get back a JSON string that looks like: + *

+     *      {
+     *          "devpayProductCodes" : null,
+     *          "privateIp" : "123.45.67.89",
+     *          "availabilityZone" : "us-west-2b",
+     *          "version" : "2010-08-31",
+     *          "accountId" : "111222333444",
+     *          "instanceId" : "i-aaa11b2c",
+     *          "billingProducts" : null,
+     *          "imageId" : "ami-1111a222",
+     *          "instanceType" : "m3.medium",
+     *          "kernelId" : null,
+     *          "ramdiskId" : null,
+     *          "architecture" : "x86_64",
+     *          "pendingTime" : "2015-04-06T19:49:49Z",
+     *          "region" : "us-west-2"
+     *      }
+     * 
+ */ + public static final String AMAZON_METADATA_DOCUMENT_URL = + AMAZON_METADATA_URL_BASE + "/latest/dynamic/instance-identity/document"; + + // Intentionally protected - use the static methods + protected AwsUtil() { /* do nothing */ } + + /** + * @param asyncHttpClientHelper The async HTTP client you want this method to use to make the AWS metadata call. + * + * @return A {@link CompletableFuture} that will contain the AWS region this app is running in (assuming it + * completes successfully). If an error occurs retrieving the region from AWS then the error will be logged and + * {@link AppInfo#UNKNOWN_VALUE} returned as the value. + */ + public static @NotNull CompletableFuture<@NotNull String> getAwsRegion( + @NotNull AsyncHttpClientHelper asyncHttpClientHelper + ) { + return asyncHttpClientHelper.executeAsyncHttpRequest( + asyncHttpClientHelper.getRequestBuilder(AMAZON_METADATA_DOCUMENT_URL, HttpMethod.GET), + response -> { + String region = null; + try { + ObjectMapper objectMapper = new ObjectMapper(); + Map resultMap = + objectMapper.readValue(response.getResponseBody(), new TypeReference>() { + }); + + region = resultMap.get("region"); + } catch (Throwable t) { + logger.error("Error retrieving region from AWS", t); + } + + if (region == null) { + logger.error("AWS metadata service returned null for region. Using 'unknown' as fallback."); + region = AppInfo.UNKNOWN_VALUE; + } + + return region; + } + ).handle((region, error) -> { + if (error != null) { + logger.error("Unable to get region info from AWS metadata service.", error); + return AppInfo.UNKNOWN_VALUE; + } + + if (region == null) { + logger.error("AWS metadata service returned null for region. Using 'unknown' as fallback."); + region = AppInfo.UNKNOWN_VALUE; + } + + return region; + }); + } + + /** + * @param asyncHttpClientHelper The async HTTP client you want this method to use to make the AWS metadata call. + * + * @return A {@link CompletableFuture} that will contain the AWS instance ID this app is running on (assuming it + * completes successfully). If an error occurs retrieving the instance ID from AWS then the error will be logged and + * {@link AppInfo#UNKNOWN_VALUE} returned as the value. + */ + public static @NotNull CompletableFuture<@NotNull String> getAwsInstanceId( + @NotNull AsyncHttpClientHelper asyncHttpClientHelper + ) { + return asyncHttpClientHelper.executeAsyncHttpRequest( + asyncHttpClientHelper.getRequestBuilder(AMAZON_METADATA_INSTANCE_ID_URL, HttpMethod.GET), + Response::getResponseBody + ).handle((instanceId, error) -> { + if (error != null) { + logger.error("Unable to get instance ID info from AWS metadata service.", error); + return AppInfo.UNKNOWN_VALUE; + } + + if (instanceId == null) { + logger.error("AWS metadata service returned null for instance ID. Using 'unknown' as fallback."); + return AppInfo.UNKNOWN_VALUE; + } + + return instanceId; + }); + } + + /** + * Helper that uses {@link AppInfoImpl#detectAppId()} and {@link AppInfoImpl#detectEnvironment()} to get the app ID + * and environment values, then returns {@link #getAppInfoFutureWithAwsInfo(String, String, AsyncHttpClientHelper)} + * using those values. If either app ID or environment cannot be determined then an {@link IllegalStateException} + * will be thrown, therefore if you know that your app's app ID and/or environment will not be successfully + * extracted using those methods then you should call {@link #getAppInfoFutureWithAwsInfo(String, String, + * AsyncHttpClientHelper)} directly with the correct values. + *

+ * See {@link #getAppInfoFutureWithAwsInfo(String, String, AsyncHttpClientHelper)} for more details on how the + * {@link AppInfo} returned by the {@link CompletableFuture} will be structured. + */ + public static @NotNull CompletableFuture<@NotNull AppInfo> getAppInfoFutureWithAwsInfo( + @NotNull AsyncHttpClientHelper asyncHttpClientHelper + ) { + String appId = AppInfoImpl.detectAppId(); + if (appId == null) + throw new IllegalStateException( + "Unable to autodetect app ID. Please call getAppInfoFutureWithAwsInfo(String, String, " + + "AsyncHttpClientHelper) instead and pass the app ID and environment manually" + ); + + String environment = AppInfoImpl.detectEnvironment(); + if (environment == null) + throw new IllegalStateException( + "Unable to autodetect environment. Please call getAppInfoFutureWithAwsInfo(String, String, " + + "AsyncHttpClientHelper) instead and pass the app ID and environment manually" + ); + + return getAppInfoFutureWithAwsInfo(appId, environment, asyncHttpClientHelper); + } + + /** + * @param appId The app ID for the running application. + * @param environment The environment the application is running in (local, test, prod, etc). + * @param asyncHttpClientHelper The async HTTP client you want this method to use to make the AWS metadata calls. + * + * @return A {@link CompletableFuture} that will eventually yield an {@link AppInfo} with the values coming from the + * given arguments for app ID and environment, and coming from the AWS metadata services for datacenter and instance + * ID. If the given environment is "local" then {@link AppInfoImpl#createLocalInstance(String)} will be returned + * (see the javadocs of that method for more information on what values it will contain). Otherwise the AWS metadata + * services will be used to determine {@link AppInfo#dataCenter()} and {@link AppInfo#instanceId()}. If those AWS + * metadata calls fail for any reason then {@link AppInfo#UNKNOWN_VALUE} will be used instead. + */ + public static @NotNull CompletableFuture<@NotNull AppInfo> getAppInfoFutureWithAwsInfo( + @NotNull String appId, + @NotNull String environment, + @NotNull AsyncHttpClientHelper asyncHttpClientHelper + ) { + if ("local".equalsIgnoreCase(environment) || "compiletimetest".equalsIgnoreCase(environment)) { + AppInfo localAppInfo = AppInfoImpl.createLocalInstance(appId); + + logger.info( + "Local environment. Using the following data for AppInfo. " + + "appId={}, environment={}, dataCenter={}, instanceId={}", + localAppInfo.appId(), localAppInfo.environment(), localAppInfo.dataCenter(), localAppInfo.instanceId() + ); + + return CompletableFuture.completedFuture(AppInfoImpl.createLocalInstance(appId)); + } + + // Not local, so assume AWS. + CompletableFuture<@NotNull String> dataCenterFuture = getAwsRegion(asyncHttpClientHelper); + CompletableFuture<@NotNull String> instanceIdFuture = getAwsInstanceId(asyncHttpClientHelper); + + return CompletableFuture.allOf(dataCenterFuture, instanceIdFuture).thenApply((aVoid) -> { + + String dataCenter = dataCenterFuture.join(); + String instanceId = instanceIdFuture.join(); + + if (AppInfo.UNKNOWN_VALUE.equals(instanceId)) { + try { + instanceId = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + logger.error( + "An error occurred trying to use local hostname as fallback. " + + "Using 'unknown' as the fallback's fallback.", e + ); + } + } + + logger.info( + "Non-local environment. Using the following data for AppInfo. " + + "appId={}, environment={}, dataCenter={}, instanceId={}", + appId, environment, dataCenter, instanceId + ); + + return new AppInfoImpl(appId, environment, dataCenter, instanceId); + }); + } + +} diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncCompletionHandlerWithTracingAndMdcSupportTest.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncCompletionHandlerWithTracingAndMdcSupportTest.java new file mode 100644 index 00000000..bf7c846d --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncCompletionHandlerWithTracingAndMdcSupportTest.java @@ -0,0 +1,662 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.fastbreak.CircuitBreaker; +import com.nike.fastbreak.CircuitBreaker.ManualModeTask; +import com.nike.internal.util.Pair; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.InitialSpanNameArgs; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.RequestTaggingArgs; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.ResponseTaggingArgs; +import com.nike.riposte.server.config.distributedtracing.SpanNamingAndTaggingStrategy; +import com.nike.wingtips.Span; +import com.nike.wingtips.Tracer; +import com.nike.wingtips.tags.HttpTagAndSpanNamingAdapter; +import com.nike.wingtips.tags.HttpTagAndSpanNamingStrategy; + +import org.asynchttpclient.BoundRequestBuilder; +import org.asynchttpclient.Response; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.internal.util.reflection.Whitebox; +import org.slf4j.MDC; + +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static com.nike.riposte.client.asynchttp.AsyncCompletionHandlerWithTracingAndMdcSupportTest.ExistingSpanStackState.EMPTY; +import static java.lang.Boolean.TRUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; + +/** + * Tests the functionality of {@link AsyncCompletionHandlerWithTracingAndMdcSupport}. + * + * @author Nic Munroe + */ +@RunWith(DataProviderRunner.class) +public class AsyncCompletionHandlerWithTracingAndMdcSupportTest { + + private CompletableFuture completableFutureResponse; + private AsyncResponseHandler responseHandlerFunctionMock; + private Response responseMock; + private String responseHandlerResult; + private RequestBuilderWrapper requestBuilderWrapper; + private String downstreamMethod; + private String downstreamUrl; + private ManualModeTask circuitBreakerManualTaskMock; + private Deque initialSpanStack; + private Map initialMdcInfo; + private AsyncCompletionHandlerWithTracingAndMdcSupport handlerSpy; + + private SpanNamingAndTaggingStrategy tagAndNamingStrategy; + private HttpTagAndSpanNamingStrategy wingtipsTagAndNamingStrategy; + private HttpTagAndSpanNamingAdapter wingtipsTagAndNamingAdapterMock; + private AtomicReference initialSpanNameFromStrategy; + private AtomicBoolean strategyInitialSpanNameMethodCalled; + private AtomicBoolean strategyRequestTaggingMethodCalled; + private AtomicBoolean strategyResponseTaggingAndFinalSpanNameMethodCalled; + private AtomicReference> strategyInitialSpanNameArgs; + private AtomicReference> strategyRequestTaggingArgs; + private AtomicReference> strategyResponseTaggingArgs; + + @Before + public void beforeMethod() throws Throwable { + resetTracingAndMdc(); + + initialSpanNameFromStrategy = new AtomicReference<>("span-name-from-strategy-" + UUID.randomUUID().toString()); + strategyInitialSpanNameMethodCalled = new AtomicBoolean(false); + strategyRequestTaggingMethodCalled = new AtomicBoolean(false); + strategyResponseTaggingAndFinalSpanNameMethodCalled = new AtomicBoolean(false); + strategyInitialSpanNameArgs = new AtomicReference<>(null); + strategyRequestTaggingArgs = new AtomicReference<>(null); + strategyResponseTaggingArgs = new AtomicReference<>(null); + wingtipsTagAndNamingStrategy = new ArgCapturingHttpTagAndSpanNamingStrategy<>( + initialSpanNameFromStrategy, strategyInitialSpanNameMethodCalled, strategyRequestTaggingMethodCalled, + strategyResponseTaggingAndFinalSpanNameMethodCalled, strategyInitialSpanNameArgs, + strategyRequestTaggingArgs, strategyResponseTaggingArgs + ); + wingtipsTagAndNamingAdapterMock = mock(HttpTagAndSpanNamingAdapter.class); + tagAndNamingStrategy = new DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy( + wingtipsTagAndNamingStrategy, wingtipsTagAndNamingAdapterMock + ); + + completableFutureResponse = new CompletableFuture<>(); + responseHandlerFunctionMock = mock(AsyncResponseHandler.class); + downstreamMethod = "method-" + UUID.randomUUID().toString(); + downstreamUrl = "url-" + UUID.randomUUID().toString(); + circuitBreakerManualTaskMock = mock(ManualModeTask.class); + + requestBuilderWrapper = new RequestBuilderWrapper( + downstreamUrl, downstreamMethod, mock(BoundRequestBuilder.class), Optional.empty(), true); + + responseMock = mock(Response.class); + responseHandlerResult = "result-" + UUID.randomUUID().toString(); + doReturn(responseHandlerResult).when(responseHandlerFunctionMock).handleResponse(responseMock); + + Tracer.getInstance().startRequestWithRootSpan("overallReqSpan"); + initialSpanStack = Tracer.getInstance().getCurrentSpanStackCopy(); + initialMdcInfo = MDC.getCopyOfContextMap(); + + handlerSpy = spy(new AsyncCompletionHandlerWithTracingAndMdcSupport<>( + completableFutureResponse, responseHandlerFunctionMock, true, requestBuilderWrapper, + Optional.of(circuitBreakerManualTaskMock), initialSpanStack, initialMdcInfo, + tagAndNamingStrategy + )); + + resetTracingAndMdc(); + } + + @After + public void afterMethod() { + resetTracingAndMdc(); + } + + private void resetTracingAndMdc() { + MDC.clear(); + Tracer.getInstance().unregisterFromThread(); + } + + @Test + public void constructor_sets_values_exactly_as_given_when_subtracing_is_off() { + // given + CompletableFuture cfResponse = mock(CompletableFuture.class); + AsyncResponseHandler responseHandlerFunc = mock(AsyncResponseHandler.class); + RequestBuilderWrapper rbwMock = mock(RequestBuilderWrapper.class); + Optional> circuitBreaker = Optional.of(mock(CircuitBreaker.class)); + Deque spanStack = mock(Deque.class); + Map mdcInfo = mock(Map.class); + + Deque spanStackBeforeCall = Tracer.getInstance().getCurrentSpanStackCopy(); + Map mdcInfoBeforeCall = MDC.getCopyOfContextMap(); + + // when + AsyncCompletionHandlerWithTracingAndMdcSupport instance = new AsyncCompletionHandlerWithTracingAndMdcSupport( + cfResponse, responseHandlerFunc, false, rbwMock, circuitBreaker, spanStack, mdcInfo, + tagAndNamingStrategy + ); + + // then + assertThat(instance.completableFutureResponse).isSameAs(cfResponse); + assertThat(instance.responseHandlerFunction).isSameAs(responseHandlerFunc); + assertThat(instance.performSubSpanAroundDownstreamCalls).isEqualTo(false); + assertThat(instance.circuitBreakerManualTask).isSameAs(circuitBreaker); + assertThat(instance.distributedTraceStackToUse).isSameAs(spanStack); + assertThat(instance.mdcContextToUse).isSameAs(mdcInfo); + + assertThat(Tracer.getInstance().getCurrentSpanStackCopy()).isEqualTo(spanStackBeforeCall); + assertThat(MDC.getCopyOfContextMap()).isEqualTo(mdcInfoBeforeCall); + } + + protected enum ExistingSpanStackState { + NULL, EMPTY, HAS_EXISTING_SPAN + } + + @DataProvider(value = { + "NULL", + "EMPTY", + "HAS_EXISTING_SPAN" + }, splitBy = "\\|") + @Test + public void constructor_sets_values_with_subspan_when_subtracing_is_on( + ExistingSpanStackState existingSpanStackState) { + // given + CompletableFuture cfResponse = mock(CompletableFuture.class); + AsyncResponseHandler responseHandlerFunc = mock(AsyncResponseHandler.class); + RequestBuilderWrapper rbwMock = mock(RequestBuilderWrapper.class); + Optional> circuitBreaker = Optional.of(mock(CircuitBreaker.class)); + Span initialSpan = null; + switch (existingSpanStackState) { + case NULL: + case EMPTY: //intentional fall-through + resetTracingAndMdc(); + break; + case HAS_EXISTING_SPAN: + initialSpan = Tracer.getInstance().startRequestWithRootSpan("overallReqSpan"); + break; + default: + throw new IllegalArgumentException("Unhandled state: " + existingSpanStackState.name()); + } + + Deque spanStack = + (existingSpanStackState == EMPTY) ? new LinkedList<>() : Tracer.getInstance().getCurrentSpanStackCopy(); + Map mdcInfo = (existingSpanStackState == EMPTY) ? new HashMap<>() : MDC.getCopyOfContextMap(); + + resetTracingAndMdc(); + + Deque spanStackBeforeCall = Tracer.getInstance().getCurrentSpanStackCopy(); + Map mdcInfoBeforeCall = MDC.getCopyOfContextMap(); + + // when + AsyncCompletionHandlerWithTracingAndMdcSupport instance = new AsyncCompletionHandlerWithTracingAndMdcSupport( + cfResponse, responseHandlerFunc, true, rbwMock, circuitBreaker, spanStack, mdcInfo, + tagAndNamingStrategy + ); + + // then + assertThat(instance.completableFutureResponse).isSameAs(cfResponse); + assertThat(instance.responseHandlerFunction).isSameAs(responseHandlerFunc); + assertThat(instance.performSubSpanAroundDownstreamCalls).isEqualTo(true); + assertThat(instance.circuitBreakerManualTask).isSameAs(circuitBreaker); + + int initialSpanStackSize = (spanStack == null) ? 0 : spanStack.size(); + assertThat(instance.distributedTraceStackToUse).hasSize(initialSpanStackSize + 1); + Span subspan = (Span) instance.distributedTraceStackToUse.peek(); + assertThat(instance.mdcContextToUse.get(Tracer.TRACE_ID_MDC_KEY)).isEqualTo(subspan.getTraceId()); + + if (existingSpanStackState == ExistingSpanStackState.NULL || existingSpanStackState == EMPTY) { + assertThat(instance.distributedTraceStackToUse).hasSize(1); + } + else { + assertThat(instance.distributedTraceStackToUse.peekLast()).isEqualTo(initialSpan); + assertThat(subspan).isNotEqualTo(initialSpan); + assertThat(subspan.getTraceId()).isEqualTo(initialSpan.getTraceId()); + assertThat(subspan.getParentSpanId()).isEqualTo(initialSpan.getSpanId()); + assertThat(subspan.getSpanName()).isEqualTo(instance.getSubspanSpanName( + rbwMock, tagAndNamingStrategy + )); + } + + assertThat(Tracer.getInstance().getCurrentSpanStackCopy()).isEqualTo(spanStackBeforeCall); + assertThat(MDC.getCopyOfContextMap()).isEqualTo(mdcInfoBeforeCall); + } + + @DataProvider(value = { + "NULL", + "EMPTY", + "HAS_EXISTING_SPAN" + }, splitBy = "\\|") + @Test + public void getTraceForCall_works_as_expected(ExistingSpanStackState existingSpanStackState) { + // given + Deque spanStack; + Span expectedResult; + switch (existingSpanStackState) { + case NULL: + spanStack = null; + expectedResult = null; + break; + case EMPTY: + spanStack = new LinkedList<>(); + expectedResult = null; + break; + case HAS_EXISTING_SPAN: + spanStack = handlerSpy.distributedTraceStackToUse; + assertThat(spanStack).isNotEmpty(); + expectedResult = spanStack.peek(); + break; + default: + throw new IllegalArgumentException("Unhandled state: " + existingSpanStackState.name()); + } + Whitebox.setInternalState(handlerSpy, "distributedTraceStackToUse", spanStack); + + // when + Span spanForCall = handlerSpy.getSpanForCall(); + + // then + assertThat(spanForCall).isEqualTo(expectedResult); + } + + @DataProvider(value = { + "spanNameFromStrategy | PATCH | spanNameFromStrategy", + "null | PATCH | async_downstream_call-PATCH", + " | PATCH | async_downstream_call-PATCH", + "[whitespace] | PATCH | async_downstream_call-PATCH", + "null | null | async_downstream_call-UNKNOWN_HTTP_METHOD", + }, splitBy = "\\|") + @Test + public void getSubspanSpanName_works_as_expected( + String strategyResult, String httpMethod, String expectedResult + ) { + // given + if ("[whitespace]".equals(strategyResult)) { + strategyResult = " \n\r\t "; + } + + initialSpanNameFromStrategy.set(strategyResult); + requestBuilderWrapper.setHttpMethod(httpMethod); + + // when + String result = handlerSpy.getSubspanSpanName( + requestBuilderWrapper, tagAndNamingStrategy + ); + + // then + assertThat(result).isEqualTo(expectedResult); + } + + @DataProvider(value = { + "true", + "false" + }) + @Test + public void onCompleted_completes_completableFutureResponse_with_result_of_responseHandlerFunction( + boolean throwException + ) throws Throwable { + // given + Exception ex = new Exception("kaboom"); + if (throwException) + doThrow(ex).when(responseHandlerFunctionMock).handleResponse(responseMock); + + // when + Response ignoredResult = handlerSpy.onCompleted(responseMock); + + // then + verify(responseHandlerFunctionMock).handleResponse(responseMock); + if (throwException) { + assertThat(completableFutureResponse).isCompletedExceptionally(); + assertThat(completableFutureResponse).hasFailedWithThrowableThat().isEqualTo(ex); + } + else { + assertThat(completableFutureResponse).isCompleted(); + assertThat(completableFutureResponse.get()).isEqualTo(responseHandlerResult); + } + + assertThat(ignoredResult).isEqualTo(responseMock); + verifyZeroInteractions(responseMock); + } + + @DataProvider(value = { + "true", + "false" + }) + @Test + public void onCompleted_interacts_with_circuit_breaker_appropriately(boolean throwException) throws Throwable { + // given + RuntimeException ex = new RuntimeException("kaboom"); + if (throwException) + doThrow(ex).when(circuitBreakerManualTaskMock).handleEvent(responseMock); + + // when + handlerSpy.onCompleted(responseMock); + + // then + verify(circuitBreakerManualTaskMock).handleEvent(responseMock); + assertThat(completableFutureResponse).isCompleted(); + assertThat(completableFutureResponse.get()).isEqualTo(responseHandlerResult); + } + + @Test + public void onCompleted_handles_circuit_breaker_but_does_nothing_else_if_completableFutureResponse_is_already_completed() + throws Throwable { + // given + CompletableFuture cfMock = mock(CompletableFuture.class); + Whitebox.setInternalState(handlerSpy, "completableFutureResponse", cfMock); + doReturn(true).when(cfMock).isDone(); + + // when + Response ignoredResult = handlerSpy.onCompleted(responseMock); + + // then + verify(circuitBreakerManualTaskMock).handleEvent(responseMock); + + verify(cfMock).isDone(); + verifyNoMoreInteractions(cfMock); + + assertThat(ignoredResult).isEqualTo(responseMock); + verifyZeroInteractions(responseMock); + } + + private Pair, Map> generateTraceInfo(Boolean setupForSubspan) { + if (setupForSubspan == null) + return Pair.of(null, null); + + try { + resetTracingAndMdc(); + Tracer.getInstance().startRequestWithRootSpan("overallReqSpan"); + + if (setupForSubspan) + Tracer.getInstance().startSubSpan("subSpan", Span.SpanPurpose.LOCAL_ONLY); + + return Pair.of(Tracer.getInstance().getCurrentSpanStackCopy(), MDC.getCopyOfContextMap()); + } + finally { + resetTracingAndMdc(); + } + } + + private static class ObjectHolder { + + public T obj; + public boolean objSet = false; + + public void setObj(T obj) { + this.obj = obj; + this.objSet = true; + } + } + + private Pair, ObjectHolder> setupBeforeAndAfterSpanCaptureForOnCompleted() + throws Throwable { + ObjectHolder before = new ObjectHolder<>(); + ObjectHolder after = new ObjectHolder<>(); + + doAnswer(invocation -> { + before.setObj(Tracer.getInstance().getCurrentSpan()); + return null; + }).when(circuitBreakerManualTaskMock).handleEvent(responseMock); + + doAnswer(invocation -> { + after.setObj(Tracer.getInstance().getCurrentSpan()); + return responseHandlerResult; + }).when(responseHandlerFunctionMock).handleResponse(responseMock); + + return Pair.of(before, after); + } + + @DataProvider(value = { + "null", + "false", + "true" + }) + @Test + public void onCompleted_deals_with_trace_info_as_expected(Boolean setupForSubspan) throws Throwable { + // given + Pair, Map> traceInfo = generateTraceInfo(setupForSubspan); + Whitebox.setInternalState(handlerSpy, "distributedTraceStackToUse", traceInfo.getLeft()); + Whitebox.setInternalState(handlerSpy, "mdcContextToUse", traceInfo.getRight()); + Whitebox.setInternalState(handlerSpy, "performSubSpanAroundDownstreamCalls", TRUE.equals(setupForSubspan)); + Span expectedSpanBeforeCompletion = (traceInfo.getLeft() == null) ? null : traceInfo.getLeft().peek(); + Span expectedSpanAfterCompletion = (setupForSubspan == null) + ? null + // If setupForSubspan is true, then there will be two items and peekLast will + // get the "parent". Otherwise there will only be one item, and + // peekLast will still get the correct thing (in this case the only + // one there). + : traceInfo.getLeft().peekLast(); + Pair, ObjectHolder> actualBeforeAndAfterSpanHolders = + setupBeforeAndAfterSpanCaptureForOnCompleted(); + + // when + handlerSpy.onCompleted(responseMock); + + // then + verify(circuitBreakerManualTaskMock).handleEvent(responseMock); + verify(responseHandlerFunctionMock).handleResponse(responseMock); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().objSet).isTrue(); + assertThat(actualBeforeAndAfterSpanHolders.getRight().objSet).isTrue(); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().obj).isEqualTo(expectedSpanBeforeCompletion); + assertThat(actualBeforeAndAfterSpanHolders.getRight().obj).isEqualTo(expectedSpanAfterCompletion); + + if (TRUE.equals(setupForSubspan)) { + assertThat(strategyResponseTaggingAndFinalSpanNameMethodCalled.get()).isTrue(); + strategyResponseTaggingArgs.get().verifyArgs( + expectedSpanBeforeCompletion, handlerSpy.rbwCopyWithHttpMethodAndUrlOnly, responseMock, + null, wingtipsTagAndNamingAdapterMock + ); + } + else { + assertThat(strategyResponseTaggingAndFinalSpanNameMethodCalled.get()).isFalse(); + } + } + + @DataProvider(value = { + "null", + "false", + "true" + }) + @Test + public void onCompleted_does_nothing_to_trace_info_if_performSubSpanAroundDownstreamCalls_is_false( + Boolean setupForSubspan) throws Throwable { + // given + Pair, Map> traceInfo = generateTraceInfo(setupForSubspan); + Whitebox.setInternalState(handlerSpy, "distributedTraceStackToUse", traceInfo.getLeft()); + Whitebox.setInternalState(handlerSpy, "mdcContextToUse", traceInfo.getRight()); + Whitebox.setInternalState(handlerSpy, "performSubSpanAroundDownstreamCalls", false); + Pair, ObjectHolder> actualBeforeAndAfterSpanHolders = + setupBeforeAndAfterSpanCaptureForOnCompleted(); + + // when + handlerSpy.onCompleted(responseMock); + + // then + verify(circuitBreakerManualTaskMock).handleEvent(responseMock); + verify(responseHandlerFunctionMock).handleResponse(responseMock); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().objSet).isTrue(); + assertThat(actualBeforeAndAfterSpanHolders.getRight().objSet).isTrue(); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().obj) + .isEqualTo(actualBeforeAndAfterSpanHolders.getRight().obj); + } + + @Test + public void onThrowable_completes_completableFutureResponse_exceptionally_with_provided_error() { + // given + Exception ex = new Exception("kaboom"); + + // when + handlerSpy.onThrowable(ex); + + // then + assertThat(completableFutureResponse).isCompletedExceptionally(); + assertThat(completableFutureResponse).hasFailedWithThrowableThat().isEqualTo(ex); + } + + @DataProvider(value = { + "true", + "false" + }) + @Test + public void onThrowable_interacts_with_circuit_breaker_appropriately(boolean throwException) throws Throwable { + // given + Exception ex = new Exception("kaboom"); + RuntimeException circuitBreakerEx = new RuntimeException("circuit breaker kaboom"); + if (throwException) + doThrow(circuitBreakerEx).when(circuitBreakerManualTaskMock).handleException(ex); + + // when + handlerSpy.onThrowable(ex); + + // then + verify(circuitBreakerManualTaskMock).handleException(ex); + assertThat(completableFutureResponse).isCompletedExceptionally(); + assertThat(completableFutureResponse).hasFailedWithThrowableThat().isEqualTo(ex); + } + + @Test + public void onThrowable_handles_circuit_breaker_but_does_nothing_else_if_completableFutureResponse_is_already_completed() + throws Throwable { + // given + Exception ex = new Exception("kaboom"); + CompletableFuture cfMock = mock(CompletableFuture.class); + Whitebox.setInternalState(handlerSpy, "completableFutureResponse", cfMock); + doReturn(true).when(cfMock).isDone(); + + // when + handlerSpy.onThrowable(ex); + + // then + verify(circuitBreakerManualTaskMock).handleException(ex); + + verify(cfMock).isDone(); + verifyNoMoreInteractions(cfMock); + } + + private Pair, ObjectHolder> setupBeforeAndAfterSpanCaptureForOnThrowable( + CompletableFuture cfMock) throws Throwable { + ObjectHolder before = new ObjectHolder<>(); + ObjectHolder after = new ObjectHolder<>(); + + doAnswer(invocation -> { + before.setObj(Tracer.getInstance().getCurrentSpan()); + return invocation.callRealMethod(); + }).when(circuitBreakerManualTaskMock).handleException(any(Throwable.class)); + + doAnswer(invocation -> { + after.setObj(Tracer.getInstance().getCurrentSpan()); + return invocation.callRealMethod(); + }).when(cfMock).completeExceptionally(any(Throwable.class)); + + return Pair.of(before, after); + } + + @DataProvider(value = { + "null", + "false", + "true" + }) + @Test + public void onThrowable_deals_with_trace_info_as_expected(Boolean setupForSubspan) throws Throwable { + // given + Exception ex = new Exception("kaboom"); + CompletableFuture cfMock = mock(CompletableFuture.class); + Whitebox.setInternalState(handlerSpy, "completableFutureResponse", cfMock); + doReturn(false).when(cfMock).isDone(); + + Pair, Map> traceInfo = generateTraceInfo(setupForSubspan); + Whitebox.setInternalState(handlerSpy, "distributedTraceStackToUse", traceInfo.getLeft()); + Whitebox.setInternalState(handlerSpy, "mdcContextToUse", traceInfo.getRight()); + Whitebox.setInternalState(handlerSpy, "performSubSpanAroundDownstreamCalls", TRUE.equals(setupForSubspan)); + Span expectedSpanBeforeCompletion = (traceInfo.getLeft() == null) ? null : traceInfo.getLeft().peek(); + Span expectedSpanAfterCompletion = (setupForSubspan == null) + ? null + // If setupForSubspan is true, then there will be two items and peekLast will + // get the "parent". Otherwise there will only be one item, and + // peekLast will still get the correct thing (in this case the only + // one there). + : traceInfo.getLeft().peekLast(); + Pair, ObjectHolder> actualBeforeAndAfterSpanHolders = + setupBeforeAndAfterSpanCaptureForOnThrowable(cfMock); + + // when + handlerSpy.onThrowable(ex); + + // then + verify(circuitBreakerManualTaskMock).handleException(ex); + verify(cfMock).completeExceptionally(ex); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().objSet).isTrue(); + assertThat(actualBeforeAndAfterSpanHolders.getRight().objSet).isTrue(); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().obj).isEqualTo(expectedSpanBeforeCompletion); + assertThat(actualBeforeAndAfterSpanHolders.getRight().obj).isEqualTo(expectedSpanAfterCompletion); + + if (TRUE.equals(setupForSubspan)) { + assertThat(strategyResponseTaggingAndFinalSpanNameMethodCalled.get()).isTrue(); + strategyResponseTaggingArgs.get().verifyArgs( + expectedSpanBeforeCompletion, handlerSpy.rbwCopyWithHttpMethodAndUrlOnly, null, + ex, wingtipsTagAndNamingAdapterMock + ); + } + else { + assertThat(strategyResponseTaggingAndFinalSpanNameMethodCalled.get()).isFalse(); + } + } + + @DataProvider(value = { + "null", + "false", + "true" + }) + @Test + public void onThrowable_does_nothing_to_trace_info_if_performSubSpanAroundDownstreamCalls_is_false( + Boolean setupForSubspan) throws Throwable { + // given + Exception ex = new Exception("kaboom"); + CompletableFuture cfMock = mock(CompletableFuture.class); + Whitebox.setInternalState(handlerSpy, "completableFutureResponse", cfMock); + doReturn(false).when(cfMock).isDone(); + + Pair, Map> traceInfo = generateTraceInfo(setupForSubspan); + Whitebox.setInternalState(handlerSpy, "distributedTraceStackToUse", traceInfo.getLeft()); + Whitebox.setInternalState(handlerSpy, "mdcContextToUse", traceInfo.getRight()); + Whitebox.setInternalState(handlerSpy, "performSubSpanAroundDownstreamCalls", false); + Pair, ObjectHolder> actualBeforeAndAfterSpanHolders = + setupBeforeAndAfterSpanCaptureForOnThrowable(cfMock); + + // when + handlerSpy.onThrowable(ex); + + // then + verify(circuitBreakerManualTaskMock).handleException(ex); + verify(cfMock).completeExceptionally(ex); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().objSet).isTrue(); + assertThat(actualBeforeAndAfterSpanHolders.getRight().objSet).isTrue(); + + assertThat(actualBeforeAndAfterSpanHolders.getLeft().obj) + .isEqualTo(actualBeforeAndAfterSpanHolders.getRight().obj); + } +} \ No newline at end of file diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTagAdapterTest.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTagAdapterTest.java new file mode 100644 index 00000000..e4ee9938 --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTagAdapterTest.java @@ -0,0 +1,205 @@ +package com.nike.riposte.client.asynchttp; + +import org.asynchttpclient.Response; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verifyZeroInteractions; + +/** + * Tests the functionality of {@link AsyncHttpClientHelperTagAdapter}. + * + * @author Nic Munroe + */ +@RunWith(DataProviderRunner.class) +public class AsyncHttpClientHelperTagAdapterTest { + + private AsyncHttpClientHelperTagAdapter adapterSpy; + private RequestBuilderWrapper requestMock; + private Response responseMock; + + @Before + public void setup() { + adapterSpy = spy(new AsyncHttpClientHelperTagAdapter()); + requestMock = mock(RequestBuilderWrapper.class); + responseMock = mock(Response.class); + } + + @Test + public void getDefaultInstance_returns_DEFAULT_INSTANCE() { + // expect + assertThat(AsyncHttpClientHelperTagAdapter.getDefaultInstance()) + .isSameAs(AsyncHttpClientHelperTagAdapter.DEFAULT_INSTANCE); + } + + @Test + public void getRequestUrl_works_as_expected() { + // given + String expectedResult = UUID.randomUUID().toString(); + + doReturn(expectedResult).when(requestMock).getUrl(); + + // when + String result = adapterSpy.getRequestUrl(requestMock); + + // then + assertThat(result).isEqualTo(expectedResult); + } + + @Test + public void getRequestUrl_returns_null_if_passed_null() { + // expect + assertThat(adapterSpy.getRequestUrl(null)).isNull(); + } + + @DataProvider(value = { + // Basic HTTP URIs + "http://foo.bar/some/path | /some/path", + "http://foo.bar/ | /", + + "http://foo.bar:4242/some/path | /some/path", + "http://foo.bar:4242/ | /", + + // Same thing, but for HTTPS + "https://foo.bar/some/path | /some/path", + "https://foo.bar/ | /", + + "https://foo.bar:4242/some/path | /some/path", + "https://foo.bar:4242/ | /", + + // Basic HTTP URIs with query string + "http://foo.bar/some/path?thing=stuff | /some/path", + "http://foo.bar/?thing=stuff | /", + + "http://foo.bar:4242/some/path?thing=stuff | /some/path", + "http://foo.bar:4242/?thing=stuff | /", + + // Same thing, but for HTTPS (with query string) + "https://foo.bar/some/path?thing=stuff | /some/path", + "https://foo.bar/?thing=stuff | /", + + "https://foo.bar:4242/some/path?thing=stuff | /some/path", + "https://foo.bar:4242/?thing=stuff | /", + + // URIs missing path + "http://no.real.path | /", + "https://no.real.path | /", + "http://no.real.path?thing=stuff | /", + "https://no.real.path?thing=stuff | /", + + // URIs missing scheme and host - just path + "/some/path | /some/path", + "/some/path?thing=stuff | /some/path", + "/ | /", + "/?thing=stuff | /", + + // Broken URIs + "nothttp://foo.bar/some/path | null", + "missing/leading/slash | null", + "http//missing.scheme.colon/some/path | null", + "http:/missing.scheme.double.slash/some/path | null", + }, splitBy = "\\|") + @Test + public void getRequestPath_works_as_expected(String url, String expectedResult) { + // given + doReturn(url).when(requestMock).getUrl(); + + // when + String result = adapterSpy.getRequestPath(requestMock); + + // then + assertThat(result).isEqualTo(expectedResult); + } + + @Test + public void getRequestPath_returns_null_if_passed_null() { + // expect + assertThat(adapterSpy.getRequestPath(null)).isNull(); + } + + @Test + public void getRequestUriPathTemplate_returns_null() { + // when + String result = adapterSpy.getRequestUriPathTemplate(requestMock, responseMock); + + // then + assertThat(result).isNull(); + verifyZeroInteractions(requestMock, responseMock); + } + + @Test + public void getResponseHttpStatus_works_as_expected() { + // given + Integer expectedResult = 42; + doReturn(expectedResult).when(responseMock).getStatusCode(); + + // when + Integer result = adapterSpy.getResponseHttpStatus(responseMock); + + // then + assertThat(result).isEqualTo(expectedResult); + } + + @Test + public void getResponseHttpStatus_returns_null_if_passed_null() { + // expect + assertThat(adapterSpy.getResponseHttpStatus(null)).isNull(); + } + + @Test + public void getRequestHttpMethod_works_as_expected() { + // given + String expectedResult = UUID.randomUUID().toString(); + doReturn(expectedResult).when(requestMock).getHttpMethod(); + + // when + String result = adapterSpy.getRequestHttpMethod(requestMock); + + // then + assertThat(result).isEqualTo(expectedResult); + } + + @Test + public void getRequestHttpMethod_returns_null_if_passed_null() { + // expect + assertThat(adapterSpy.getRequestHttpMethod(null)).isNull(); + } + + @Test + public void getHeaderSingleValue_returns_null() { + // when + String result = adapterSpy.getHeaderSingleValue(requestMock, "foo"); + + // then + assertThat(result).isNull(); + verifyZeroInteractions(requestMock); + } + + @Test + public void getHeaderMultipleValue_returns_null() { + // when + List result = adapterSpy.getHeaderMultipleValue(requestMock, "foo"); + + // then + assertThat(result).isNull(); + verifyZeroInteractions(requestMock); + } + + @Test + public void getSpanHandlerTagValue_returns_expected_value() { + // expect + assertThat(adapterSpy.getSpanHandlerTagValue(requestMock, responseMock)) + .isEqualTo("riposte.asynchttpclienthelper"); + } +} \ No newline at end of file diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTest.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTest.java new file mode 100644 index 00000000..fe11e719 --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/AsyncHttpClientHelperTest.java @@ -0,0 +1,677 @@ +package com.nike.riposte.client.asynchttp; + +import static com.nike.wingtips.http.HttpRequestTracingUtils.convertSampleableBooleanToExpectedB3Value; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyBoolean; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +import com.nike.fastbreak.CircuitBreaker; +import com.nike.fastbreak.CircuitBreaker.ManualModeTask; +import com.nike.fastbreak.CircuitBreakerDelegate; +import com.nike.fastbreak.CircuitBreakerForHttpStatusCode; +import com.nike.fastbreak.exception.CircuitBreakerOpenException; +import com.nike.internal.util.Pair; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.InitialSpanNameArgs; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.RequestTaggingArgs; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.ResponseTaggingArgs; +import com.nike.riposte.server.channelpipeline.ChannelAttributes; +import com.nike.riposte.server.config.distributedtracing.SpanNamingAndTaggingStrategy; +import com.nike.riposte.server.http.HttpProcessingState; +import com.nike.wingtips.Span; +import com.nike.wingtips.TraceHeaders; +import com.nike.wingtips.Tracer; +import com.nike.wingtips.tags.HttpTagAndSpanNamingAdapter; +import com.nike.wingtips.tags.HttpTagAndSpanNamingStrategy; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.EventLoop; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.resolver.NameResolver; +import io.netty.resolver.RoundRobinInetAddressResolver; +import io.netty.util.Attribute; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import org.asynchttpclient.AsyncHandler; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.BoundRequestBuilder; +import org.asynchttpclient.DefaultAsyncHttpClientConfig; +import org.asynchttpclient.Request; +import org.asynchttpclient.Response; +import org.asynchttpclient.SignatureCalculator; +import org.asynchttpclient.uri.Uri; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.internal.util.reflection.Whitebox; +import org.slf4j.Logger; +import org.slf4j.MDC; + +/** + * Tests the functionality of {@link AsyncHttpClientHelper}. + * + * @author Nic Munroe + */ +@RunWith(DataProviderRunner.class) +public class AsyncHttpClientHelperTest { + + private AsyncHttpClientHelper helperSpy; + private Channel channelMock; + private ChannelHandlerContext ctxMock; + private Attribute stateAttributeMock; + private HttpProcessingState state; + private EventLoop eventLoopMock; + private AsyncCompletionHandlerWithTracingAndMdcSupport handlerWithTracingAndMdcDummyExample; + private SignatureCalculator signatureCalculator; + + private SpanNamingAndTaggingStrategy tagAndNamingStrategy; + private HttpTagAndSpanNamingStrategy wingtipsTagAndNamingStrategy; + private HttpTagAndSpanNamingAdapter wingtipsTagAndNamingAdapterMock; + private AtomicReference initialSpanNameFromStrategy; + private AtomicBoolean strategyInitialSpanNameMethodCalled; + private AtomicBoolean strategyRequestTaggingMethodCalled; + private AtomicBoolean strategyResponseTaggingAndFinalSpanNameMethodCalled; + private AtomicReference> strategyInitialSpanNameArgs; + private AtomicReference> strategyRequestTaggingArgs; + private AtomicReference> strategyResponseTaggingArgs; + + @Before + public void beforeMethod() { + initialSpanNameFromStrategy = new AtomicReference<>("span-name-from-strategy-" + UUID.randomUUID().toString()); + strategyInitialSpanNameMethodCalled = new AtomicBoolean(false); + strategyRequestTaggingMethodCalled = new AtomicBoolean(false); + strategyResponseTaggingAndFinalSpanNameMethodCalled = new AtomicBoolean(false); + strategyInitialSpanNameArgs = new AtomicReference<>(null); + strategyRequestTaggingArgs = new AtomicReference<>(null); + strategyResponseTaggingArgs = new AtomicReference<>(null); + wingtipsTagAndNamingStrategy = new ArgCapturingHttpTagAndSpanNamingStrategy<>( + initialSpanNameFromStrategy, strategyInitialSpanNameMethodCalled, strategyRequestTaggingMethodCalled, + strategyResponseTaggingAndFinalSpanNameMethodCalled, strategyInitialSpanNameArgs, + strategyRequestTaggingArgs, strategyResponseTaggingArgs + ); + wingtipsTagAndNamingAdapterMock = mock(HttpTagAndSpanNamingAdapter.class); + tagAndNamingStrategy = new DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy( + wingtipsTagAndNamingStrategy, wingtipsTagAndNamingAdapterMock + ); + + helperSpy = spy( + AsyncHttpClientHelper.builder() + .setSpanNamingAndTaggingStrategy(tagAndNamingStrategy) + .build() + ); + channelMock = mock(Channel.class); + ctxMock = mock(ChannelHandlerContext.class); + stateAttributeMock = mock(Attribute.class); + state = new HttpProcessingState(); + eventLoopMock = mock(EventLoop.class); + signatureCalculator = mock(SignatureCalculator.class); + doReturn(channelMock).when(ctxMock).channel(); + doReturn(stateAttributeMock).when(channelMock).attr(ChannelAttributes.HTTP_PROCESSING_STATE_ATTRIBUTE_KEY); + doReturn(state).when(stateAttributeMock).get(); + doReturn(eventLoopMock).when(channelMock).eventLoop(); + + handlerWithTracingAndMdcDummyExample = new AsyncCompletionHandlerWithTracingAndMdcSupport<>( + null, null, false, mock(RequestBuilderWrapper.class), null, null, null, + DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy.getDefaultInstance() + ); + + resetTracingAndMdc(); + } + + @After + public void afterMethod() { + resetTracingAndMdc(); + } + + private void resetTracingAndMdc() { + MDC.clear(); + Tracer.getInstance().completeRequestSpan(); + } + + private void verifyDefaultUnderlyingClientConfig(AsyncHttpClientHelper instance) { + AsyncHttpClientConfig config = instance.asyncHttpClient.getConfig(); + assertThat(config.getMaxRequestRetry()).isEqualTo(0); + assertThat(config.getRequestTimeout()).isEqualTo(AsyncHttpClientHelper.DEFAULT_REQUEST_TIMEOUT_MILLIS); + assertThat(config.getConnectionTtl()).isEqualTo(AsyncHttpClientHelper.DEFAULT_POOLED_DOWNSTREAM_CONNECTION_TTL_MILLIS); + assertThat(Whitebox.getInternalState(instance.asyncHttpClient, "signatureCalculator")).isNull(); + } + + @Test + public void default_constructor_creates_instance_with_default_values() { + // when + AsyncHttpClientHelper instance = new AsyncHttpClientHelper(); + + // then + assertThat(instance.performSubSpanAroundDownstreamCalls).isTrue(); + verifyDefaultUnderlyingClientConfig(instance); + } + + @Test + public void fluent_setters_work_as_expected() { + // when + AsyncHttpClientHelper instance = AsyncHttpClientHelper.builder() + .setPerformSubSpanAroundDownstreamCalls(false) + .setDefaultSignatureCalculator(signatureCalculator) + .setSpanNamingAndTaggingStrategy(tagAndNamingStrategy) + .build(); + + // then + assertThat(instance.performSubSpanAroundDownstreamCalls).isFalse(); + assertThat(Whitebox.getInternalState(instance.asyncHttpClient, "signatureCalculator")).isEqualTo(signatureCalculator); + assertThat(instance.spanNamingAndTaggingStrategy).isSameAs(tagAndNamingStrategy); + } + + @Test + public void setSpanNamingAndTaggingStrategy_throws_IllegalArgumentException_if_passed_null() { + // when + Throwable ex = catchThrowable(() -> AsyncHttpClientHelper.builder() + .setSpanNamingAndTaggingStrategy(null) + .build() + ); + + // then + assertThat(ex) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("spanNamingAndTaggingStrategy cannot be null"); + } + + @Test + public void setClientConfigBuilder_throws_IllegalArgumentException_if_passed_null() { + // when + Throwable ex = catchThrowable(() -> AsyncHttpClientHelper.builder() + .setClientConfigBuilder(null) + .build() + ); + + // then + assertThat(ex) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("clientConfigBuilder cannot be null"); + } + + @DataProvider(value = { + "true", + "false" + }, splitBy = "\\|") + @Test + public void constructor_with_subspan_opt_works_as_expected(boolean performSubspan) { + // when + AsyncHttpClientHelper instance = AsyncHttpClientHelper.builder() + .setPerformSubSpanAroundDownstreamCalls(performSubspan) + .build(); + + // then + assertThat(instance.performSubSpanAroundDownstreamCalls).isEqualTo(performSubspan); + verifyDefaultUnderlyingClientConfig(instance); + } + + @DataProvider(value = { + "true", + "false" + }, splitBy = "\\|") + @Test + public void kitchen_sink_constructor_sets_up_underlying_client_with_expected_config(boolean performSubspan) { + // given + int customRequestTimeoutVal = 4242; + AsyncHttpClientConfig config = + new DefaultAsyncHttpClientConfig.Builder().setRequestTimeout(customRequestTimeoutVal).build(); + DefaultAsyncHttpClientConfig.Builder builderMock = mock(DefaultAsyncHttpClientConfig.Builder.class); + doReturn(config).when(builderMock).build(); + + // when + AsyncHttpClientHelper instance = AsyncHttpClientHelper.builder() + .setPerformSubSpanAroundDownstreamCalls(performSubspan) + .setClientConfigBuilder(builderMock) + .setRequestTimeout(customRequestTimeoutVal) + .build(); + + // then + assertThat(instance.performSubSpanAroundDownstreamCalls).isEqualTo(performSubspan); + assertThat(instance.asyncHttpClient.getConfig()).isSameAs(config); + assertThat(instance.asyncHttpClient.getConfig().getRequestTimeout()).isEqualTo(customRequestTimeoutVal); + } + + @DataProvider(value = { + "true | true", + "true | false", + "false | true", + "false | false" + }, splitBy = "\\|") + @Test + public void constructor_clears_out_tracing_and_mdc_info_before_building_underlying_client_and_resets_afterward( + boolean emptyBeforeCall, boolean explode + ) { + AsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder().build(); + DefaultAsyncHttpClientConfig.Builder builderMock = mock(DefaultAsyncHttpClientConfig.Builder.class); + List traceAtTimeOfBuildCall = new ArrayList<>(); + List> mdcAtTimeOfBuildCall = new ArrayList<>(); + RuntimeException explodeEx = new RuntimeException("kaboom"); + doAnswer(invocation -> { + traceAtTimeOfBuildCall.add(Tracer.getInstance().getCurrentSpan()); + mdcAtTimeOfBuildCall.add(MDC.getCopyOfContextMap()); + if (explode) + throw explodeEx; + return config; + }).when(builderMock).build(); + + Span spanBeforeCall = (emptyBeforeCall) ? null : Tracer.getInstance().startRequestWithRootSpan("foo"); + Map mdcBeforeCall = MDC.getCopyOfContextMap(); + assertThat(Tracer.getInstance().getCurrentSpan()).isEqualTo(spanBeforeCall); + if (emptyBeforeCall) + assertThat(mdcBeforeCall).isNull(); + else + assertThat(mdcBeforeCall).isNotEmpty(); + + // when + Throwable ex = catchThrowable(() -> AsyncHttpClientHelper.builder() + .setClientConfigBuilder(builderMock) + .build() + ); + + // then + verify(builderMock).build(); + assertThat(traceAtTimeOfBuildCall).hasSize(1); + assertThat(traceAtTimeOfBuildCall.get(0)).isNull(); + assertThat(mdcAtTimeOfBuildCall).hasSize(1); + assertThat(mdcAtTimeOfBuildCall.get(0)).isNull(); + + assertThat(Tracer.getInstance().getCurrentSpan()).isEqualTo(spanBeforeCall); + assertThat(MDC.getCopyOfContextMap()).isEqualTo(mdcBeforeCall); + + if (explode) + assertThat(ex).isSameAs(explodeEx); + } + + private void verifyRequestBuilderWrapperGeneratedAsExpected( + RequestBuilderWrapper rbw, String url, String method, Optional> customCb, + boolean disableCb + ) { + assertThat(rbw.url).isEqualTo(url); + assertThat(rbw.httpMethod).isEqualTo(method); + assertThat(rbw.customCircuitBreaker).isEqualTo(customCb); + assertThat(rbw.disableCircuitBreaker).isEqualTo(disableCb); + Request req = rbw.requestBuilder.build(); + assertThat(req.getMethod()).isEqualTo(method); + assertThat(req.getUri()).isEqualTo(Uri.create(url)); + assertThat(req.getUrl()).isEqualTo(url); + assertThat(req.getNameResolver()).isInstanceOf(RoundRobinInetAddressResolver.class); + } + + @Test + public void builderWorksAsExpected() { + // given + int customRequestTimeoutVal = 4242; + int customConnectionTtl = 42; + boolean performSubspan = true; + int customMaxRetry = 4; + NameResolver customNameResolver = mock(NameResolver.class); + + // when + AsyncHttpClientHelper instance = AsyncHttpClientHelper.builder() + .setPerformSubSpanAroundDownstreamCalls(performSubspan) + .setConnectionTtl(customConnectionTtl) + .setRequestTimeout(customRequestTimeoutVal) + .setMaxRequestRetry(customMaxRetry) + .setNameResolver(customNameResolver) + .build(); + + // then + assertThat(instance.performSubSpanAroundDownstreamCalls).isEqualTo(performSubspan); + assertThat(instance.asyncHttpClient.getConfig().getRequestTimeout()).isEqualTo(customRequestTimeoutVal); + assertThat(instance.asyncHttpClient.getConfig().getConnectionTtl()).isEqualTo(customConnectionTtl); + assertThat(instance.asyncHttpClient.getConfig().getMaxRequestRetry()).isEqualTo(customMaxRetry); + assertThat(instance.nameResolver).isSameAs(customNameResolver); + assertThat(instance.performSubSpanAroundDownstreamCalls).isTrue(); + } + + @Test + public void getRequestBuilder_delegates_to_helper_with_default_circuit_breaker_args() { + // given + String url = UUID.randomUUID().toString(); + HttpMethod method = HttpMethod.valueOf(UUID.randomUUID().toString()); + RequestBuilderWrapper rbwMock = mock(RequestBuilderWrapper.class); + doReturn(rbwMock).when(helperSpy) + .getRequestBuilder(anyString(), any(HttpMethod.class), any(Optional.class), anyBoolean()); + + // when + RequestBuilderWrapper rbw = helperSpy.getRequestBuilder(url, method); + + // then + verify(helperSpy).getRequestBuilder(url, method, Optional.empty(), false); + assertThat(rbw).isSameAs(rbwMock); + } + + @DataProvider(value = { + "CONNECT", + "DELETE", + "GET", + "HEAD", + "POST", + "OPTIONS", + "PUT", + "PATCH", + "TRACE", + "FOO_METHOD_DOES_NOT_EXIST" + }, splitBy = "\\|") + @Test + public void getRequestBuilder_with_circuit_breaker_args_sets_values_as_expected(String methodName) { + CircuitBreaker cbMock = mock(CircuitBreaker.class); + List>, Boolean>> variations = Arrays.asList( + Pair.of(Optional.empty(), true), + Pair.of(Optional.empty(), false), + Pair.of(Optional.of(cbMock), true), + Pair.of(Optional.of(cbMock), false) + ); + + variations.forEach(variation -> { + // given + String url = "http://localhost/some/path"; + HttpMethod method = HttpMethod.valueOf(methodName); + Optional> cbOpt = variation.getLeft(); + boolean disableCb = variation.getRight(); + + // when + RequestBuilderWrapper rbw = helperSpy.getRequestBuilder(url, method, cbOpt, disableCb); + + // then + verifyRequestBuilderWrapperGeneratedAsExpected(rbw, url, methodName, cbOpt, disableCb); + }); + } + + @Test + public void basic_executeAsyncHttpRequest_extracts_mdc_and_tracing_info_from_current_thread_and_delegates_to_kitchen_sink_execute_method() { + // given + RequestBuilderWrapper rbw = mock(RequestBuilderWrapper.class); + AsyncResponseHandler responseHandler = mock(AsyncResponseHandler.class); + CompletableFuture cfMock = mock(CompletableFuture.class); + doReturn(cfMock).when(helperSpy).executeAsyncHttpRequest( + any(RequestBuilderWrapper.class), any(AsyncResponseHandler.class), any(Deque.class), any(Map.class) + ); + Tracer.getInstance().startRequestWithRootSpan("foo"); + Deque expectedSpanStack = Tracer.getInstance().getCurrentSpanStackCopy(); + Map expectedMdc = MDC.getCopyOfContextMap(); + + // when + CompletableFuture result = helperSpy.executeAsyncHttpRequest(rbw, responseHandler); + + // then + verify(helperSpy).executeAsyncHttpRequest(rbw, responseHandler, expectedSpanStack, expectedMdc); + assertThat(result).isSameAs(cfMock); + } + + @Test + public void executeAsyncHttpRequest_with_ctx_extracts_mdc_and_tracing_info_from_ctx_and_delegates_to_kitchen_sink_execute_method() { + // given + RequestBuilderWrapper rbwMock = mock(RequestBuilderWrapper.class); + AsyncResponseHandler responseHandlerMock = mock(AsyncResponseHandler.class); + CompletableFuture cfMock = mock(CompletableFuture.class); + doReturn(cfMock).when(helperSpy).executeAsyncHttpRequest( + any(RequestBuilderWrapper.class), any(AsyncResponseHandler.class), any(Deque.class), any(Map.class) + ); + + Map mdcMock = mock(Map.class); + Deque spanStackMock = mock(Deque.class); + state.setLoggerMdcContextMap(mdcMock); + state.setDistributedTraceStack(spanStackMock); + + // when + CompletableFuture result = helperSpy.executeAsyncHttpRequest(rbwMock, responseHandlerMock, ctxMock); + + // then + verify(helperSpy).executeAsyncHttpRequest(rbwMock, responseHandlerMock, spanStackMock, mdcMock); + assertThat(result).isSameAs(cfMock); + verify(rbwMock).setCtx(ctxMock); + } + + @Test + public void executeAsyncHttpRequest_with_ctx_throws_IllegalStateException_if_state_is_null() { + // given + RequestBuilderWrapper rbwMock = mock(RequestBuilderWrapper.class); + AsyncResponseHandler responseHandlerMock = mock(AsyncResponseHandler.class); + CompletableFuture cfMock = mock(CompletableFuture.class); + doReturn(cfMock).when(helperSpy).executeAsyncHttpRequest( + any(RequestBuilderWrapper.class), any(AsyncResponseHandler.class), any(Deque.class), any(Map.class) + ); + + doReturn(null).when(stateAttributeMock).get(); + + // when + Throwable ex = catchThrowable(() -> helperSpy.executeAsyncHttpRequest(rbwMock, responseHandlerMock, ctxMock)); + + // then + assertThat(ex).isInstanceOf(IllegalStateException.class); + } + + @DataProvider(value = { + "true | true", + "true | false", + "false | true", + "false | false" + }, splitBy = "\\|") + @Test + public void executeAsyncHttpRequest_sets_up_and_executes_call_as_expected( + boolean performSubspan, boolean currentTracingInfoNull + ) { + // given + Whitebox.setInternalState(helperSpy, "performSubSpanAroundDownstreamCalls", performSubspan); + + CircuitBreaker circuitBreakerMock = mock(CircuitBreaker.class); + doReturn(Optional.of(circuitBreakerMock)).when(helperSpy).getCircuitBreaker(any(RequestBuilderWrapper.class)); + ManualModeTask cbManualTaskMock = mock(ManualModeTask.class); + doReturn(cbManualTaskMock).when(circuitBreakerMock).newManualModeTask(); + + String url = "http://localhost/some/path"; + String method = "GET"; + BoundRequestBuilder reqMock = mock(BoundRequestBuilder.class); + RequestBuilderWrapper rbw = new RequestBuilderWrapper(url, method, reqMock, Optional.empty(), false); + AsyncResponseHandler responseHandlerMock = mock(AsyncResponseHandler.class); + + Span initialSpan = (currentTracingInfoNull) ? null : Tracer.getInstance().startRequestWithRootSpan("foo"); + Deque initialSpanStack = (currentTracingInfoNull) + ? null + : Tracer.getInstance().getCurrentSpanStackCopy(); + Map initialMdc = (currentTracingInfoNull) ? null : MDC.getCopyOfContextMap(); + resetTracingAndMdc(); + + // when + CompletableFuture resultFuture = helperSpy.executeAsyncHttpRequest( + rbw, responseHandlerMock, initialSpanStack, initialMdc + ); + + // then + // Verify that the circuit breaker came from the getCircuitBreaker helper method and that its + // throwExceptionIfCircuitBreakerIsOpen() method was called. + verify(helperSpy).getCircuitBreaker(rbw); + verify(cbManualTaskMock).throwExceptionIfCircuitBreakerIsOpen(); + + // Verify that the inner request's execute method was called with a + // AsyncCompletionHandlerWithTracingAndMdcSupport for the handler. + ArgumentCaptor executedHandlerCaptor = ArgumentCaptor.forClass(AsyncHandler.class); + verify(reqMock).execute(executedHandlerCaptor.capture()); + AsyncHandler executedHandler = executedHandlerCaptor.getValue(); + assertThat(executedHandler).isInstanceOf(AsyncCompletionHandlerWithTracingAndMdcSupport.class); + + // Verify that the AsyncCompletionHandlerWithTracingAndMdcSupport was created with the expected args + AsyncCompletionHandlerWithTracingAndMdcSupport achwtams = + (AsyncCompletionHandlerWithTracingAndMdcSupport) executedHandler; + assertThat(achwtams.completableFutureResponse).isSameAs(resultFuture); + assertThat(achwtams.responseHandlerFunction).isSameAs(responseHandlerMock); + assertThat(achwtams.performSubSpanAroundDownstreamCalls).isEqualTo(performSubspan); + assertThat(achwtams.circuitBreakerManualTask).isEqualTo(Optional.of(cbManualTaskMock)); + if (performSubspan) { + int initialSpanStackSize = (initialSpanStack == null) ? 0 : initialSpanStack.size(); + assertThat(achwtams.distributedTraceStackToUse).hasSize(initialSpanStackSize + 1); + Span subspan = (Span) achwtams.distributedTraceStackToUse.peek(); + assertThat(subspan.getSpanName()) + .isEqualTo(initialSpanNameFromStrategy.get()); + if (initialSpan != null) { + assertThat(subspan.getTraceId()).isEqualTo(initialSpan.getTraceId()); + assertThat(subspan.getParentSpanId()).isEqualTo(initialSpan.getSpanId()); + } + assertThat(achwtams.mdcContextToUse.get(Tracer.TRACE_ID_MDC_KEY)).isEqualTo(subspan.getTraceId()); + } + else { + assertThat(achwtams.distributedTraceStackToUse).isSameAs(initialSpanStack); + assertThat(achwtams.mdcContextToUse).isSameAs(initialMdc); + } + + // Verify that the trace headers were added (or not depending on state). + Span spanForDownstreamCall = achwtams.getSpanForCall(); + if (initialSpan == null && !performSubspan) { + assertThat(spanForDownstreamCall).isNull(); + verifyZeroInteractions(reqMock); + } + else { + assertThat(spanForDownstreamCall).isNotNull(); + verify(reqMock).setHeader(TraceHeaders.TRACE_SAMPLED, + convertSampleableBooleanToExpectedB3Value(spanForDownstreamCall.isSampleable())); + verify(reqMock).setHeader(TraceHeaders.TRACE_ID, spanForDownstreamCall.getTraceId()); + verify(reqMock).setHeader(TraceHeaders.SPAN_ID, spanForDownstreamCall.getSpanId()); + if (spanForDownstreamCall.getParentSpanId() == null) { + verify(reqMock, never()).setHeader(eq(TraceHeaders.PARENT_SPAN_ID), anyString()); + } + else { + verify(reqMock).setHeader(TraceHeaders.PARENT_SPAN_ID, spanForDownstreamCall.getParentSpanId()); + } + verify(reqMock, never()).setHeader(eq(TraceHeaders.SPAN_NAME), anyString()); + } + + // Verify that any subspan had request tagging performed. + if (performSubspan) { + strategyRequestTaggingArgs.get().verifyArgs(spanForDownstreamCall, rbw, wingtipsTagAndNamingAdapterMock); + } + } + + @DataProvider(value = { + "true", + "false" + }, splitBy = "\\|") + @Test + public void executeAsyncHttpRequest_completes_future_if_exception_happens_during_setup( + boolean throwCircuitBreakerOpenException) { + // given + RuntimeException exToThrow = (throwCircuitBreakerOpenException) + ? new CircuitBreakerOpenException("foo", "kaboom") + : new RuntimeException("kaboom"); + doThrow(exToThrow).when(helperSpy).getCircuitBreaker(any(RequestBuilderWrapper.class)); + Logger loggerMock = mock(Logger.class); + Whitebox.setInternalState(helperSpy, "logger", loggerMock); + + // when + CompletableFuture result = helperSpy + .executeAsyncHttpRequest(mock(RequestBuilderWrapper.class), mock(AsyncResponseHandler.class), null, null); + + // then + assertThat(result).isCompletedExceptionally(); + Throwable ex = catchThrowable(result::get); + assertThat(ex) + .isInstanceOf(ExecutionException.class) + .hasCause(exToThrow); + if (throwCircuitBreakerOpenException) + verifyZeroInteractions(loggerMock); + else + verify(loggerMock).error(anyString(), anyString(), anyString(), eq(exToThrow)); + } + + @DataProvider(value = { + "true", + "false" + }, splitBy = "\\|") + @Test + public void getCircuitBreaker_returns_CircuitBreakerDelegate_wrapping_default_CircuitBreakerForHttpStatusCode_using_host_as_the_key( + boolean useNettyEventLoop + ) { + // given + String host = UUID.randomUUID().toString(); + String url = "http://" + host + "/some/path"; + String method = "GET"; + BoundRequestBuilder reqMock = mock(BoundRequestBuilder.class); + Optional> customCb = Optional.empty(); + RequestBuilderWrapper rbw = new RequestBuilderWrapper(url, method, reqMock, customCb, false); + if (useNettyEventLoop) + rbw.setCtx(ctxMock); + + // when + Optional> result = helperSpy.getCircuitBreaker(rbw); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isInstanceOf(CircuitBreakerDelegate.class); + CircuitBreakerDelegate wrapper = (CircuitBreakerDelegate) result.get(); + CircuitBreaker delegate = (CircuitBreaker) Whitebox.getInternalState(wrapper, "delegate"); + Function eventConverter = + (Function) Whitebox.getInternalState(wrapper, "eventConverter"); + + assertThat(delegate) + .isSameAs(CircuitBreakerForHttpStatusCode.getDefaultHttpStatusCodeCircuitBreakerForKey(host)); + + Response responseMock = mock(Response.class); + doReturn(42).when(responseMock).getStatusCode(); + assertThat(eventConverter.apply(responseMock)).isEqualTo(42); + assertThat(eventConverter.apply(null)).isNull(); + + if (useNettyEventLoop) { + assertThat(Whitebox.getInternalState(delegate, "scheduler")).isEqualTo(eventLoopMock); + assertThat(Whitebox.getInternalState(delegate, "stateChangeNotificationExecutor")).isEqualTo(eventLoopMock); + } + else { + assertThat(Whitebox.getInternalState(delegate, "scheduler")).isNotEqualTo(eventLoopMock); + assertThat(Whitebox.getInternalState(delegate, "stateChangeNotificationExecutor")) + .isNotEqualTo(eventLoopMock); + } + } + + @Test + public void getCircuitBreaker_returns_custom_circuit_breaker_if_disableCircuitBreaker_is_false_and_customCircuitBreaker_exists() { + // given + Optional> customCb = Optional.of(mock(CircuitBreaker.class)); + RequestBuilderWrapper rbw = new RequestBuilderWrapper( + "foo", "bar", mock(BoundRequestBuilder.class), customCb, false); + + // when + Optional> result = helperSpy.getCircuitBreaker(rbw); + + // then + assertThat(result).isSameAs(customCb); + } + + @Test + public void getCircuitBreaker_returns_empty_if_disableCircuitBreaker_is_true() { + // given + RequestBuilderWrapper rbw = new RequestBuilderWrapper( + "foo", "bar", mock(BoundRequestBuilder.class), Optional.of(mock(CircuitBreaker.class)), + true); + + // when + Optional> result = helperSpy.getCircuitBreaker(rbw); + + // then + assertThat(result).isEmpty(); + } + +} \ No newline at end of file diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategyTest.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategyTest.java new file mode 100644 index 00000000..eb3393b5 --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategyTest.java @@ -0,0 +1,219 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.InitialSpanNameArgs; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.RequestTaggingArgs; +import com.nike.riposte.client.asynchttp.testutils.ArgCapturingHttpTagAndSpanNamingStrategy.ResponseTaggingArgs; +import com.nike.wingtips.Span; +import com.nike.wingtips.tags.HttpTagAndSpanNamingAdapter; +import com.nike.wingtips.tags.HttpTagAndSpanNamingStrategy; +import com.nike.wingtips.tags.ZipkinHttpTagStrategy; + +import org.asynchttpclient.Response; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Mockito.mock; + +/** + * Tests the functionality of {@link DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy}. + * + * @author Nic Munroe + */ +@RunWith(DataProviderRunner.class) +public class DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategyTest { + + private DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy impl; + + private Span spanMock; + private RequestBuilderWrapper requestMock; + private Response responseMock; + private Throwable errorMock; + + private HttpTagAndSpanNamingStrategy wingtipsStrategy; + private HttpTagAndSpanNamingAdapter wingtipsAdapterMock; + private AtomicReference initialSpanNameFromStrategy; + private AtomicBoolean strategyInitialSpanNameMethodCalled; + private AtomicBoolean strategyRequestTaggingMethodCalled; + private AtomicBoolean strategyResponseTaggingAndFinalSpanNameMethodCalled; + private AtomicReference> strategyInitialSpanNameArgs; + private AtomicReference> strategyRequestTaggingArgs; + private AtomicReference> strategyResponseTaggingArgs; + + @Before + public void beforeMethod() { + initialSpanNameFromStrategy = new AtomicReference<>("span-name-from-strategy-" + UUID.randomUUID().toString()); + strategyInitialSpanNameMethodCalled = new AtomicBoolean(false); + strategyRequestTaggingMethodCalled = new AtomicBoolean(false); + strategyResponseTaggingAndFinalSpanNameMethodCalled = new AtomicBoolean(false); + strategyInitialSpanNameArgs = new AtomicReference<>(null); + strategyRequestTaggingArgs = new AtomicReference<>(null); + strategyResponseTaggingArgs = new AtomicReference<>(null); + wingtipsStrategy = new ArgCapturingHttpTagAndSpanNamingStrategy<>( + initialSpanNameFromStrategy, strategyInitialSpanNameMethodCalled, strategyRequestTaggingMethodCalled, + strategyResponseTaggingAndFinalSpanNameMethodCalled, strategyInitialSpanNameArgs, + strategyRequestTaggingArgs, strategyResponseTaggingArgs + ); + wingtipsAdapterMock = mock(HttpTagAndSpanNamingAdapter.class); + + impl = new DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy(wingtipsStrategy, wingtipsAdapterMock); + + requestMock = mock(RequestBuilderWrapper.class); + responseMock = mock(Response.class); + errorMock = mock(Throwable.class); + spanMock = mock(Span.class); + } + + @Test + public void getDefaultInstance_returns_DEFAULT_INSTANCE() { + // when + DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy instance = + DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy.getDefaultInstance(); + + // then + assertThat(instance) + .isSameAs(DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy.DEFAULT_INSTANCE); + assertThat(instance.tagAndNamingStrategy).isSameAs(ZipkinHttpTagStrategy.getDefaultInstance()); + assertThat(instance.tagAndNamingAdapter).isSameAs(AsyncHttpClientHelperTagAdapter.getDefaultInstance()); + } + + @Test + public void default_constructor_creates_instance_using_default_ZipkinHttpTagStrategy_and_AsyncHttpClientHelperTagAdapter() { + // when + DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy instance = + new DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy(); + + // then + assertThat(instance.tagAndNamingStrategy).isSameAs(ZipkinHttpTagStrategy.getDefaultInstance()); + assertThat(instance.tagAndNamingAdapter).isSameAs(AsyncHttpClientHelperTagAdapter.getDefaultInstance()); + } + + @Test + public void alternate_constructor_creates_instance_using_specified_wingtips_strategy_and_adapter() { + // given + HttpTagAndSpanNamingStrategy wingtipsStrategyMock = + mock(HttpTagAndSpanNamingStrategy.class); + HttpTagAndSpanNamingAdapter wingtipsAdapterMock = + mock(HttpTagAndSpanNamingAdapter.class); + + // when + DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy instance = + new DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy(wingtipsStrategyMock, wingtipsAdapterMock); + + // then + assertThat(instance.tagAndNamingStrategy).isSameAs(wingtipsStrategyMock); + assertThat(instance.tagAndNamingAdapter).isSameAs(wingtipsAdapterMock); + } + + private enum NullArgsScenario { + NULL_WINGTIPS_STRATEGY( + null, + mock(HttpTagAndSpanNamingAdapter.class), + "tagAndNamingStrategy cannot be null - if you really want no strategy, use NoOpHttpTagStrategy" + ), + NULL_WINGTIPS_ADAPTER( + mock(HttpTagAndSpanNamingStrategy.class), + null, + "tagAndNamingAdapter cannot be null - if you really want no adapter, use NoOpHttpTagAdapter" + ); + + public final HttpTagAndSpanNamingStrategy wingtipsStrategy; + public final HttpTagAndSpanNamingAdapter wingtipsAdapter; + public final String expectedExceptionMessage; + + NullArgsScenario( + HttpTagAndSpanNamingStrategy wingtipsStrategy, + HttpTagAndSpanNamingAdapter wingtipsAdapter, + String expectedExceptionMessage + ) { + this.wingtipsStrategy = wingtipsStrategy; + this.wingtipsAdapter = wingtipsAdapter; + this.expectedExceptionMessage = expectedExceptionMessage; + } + } + + @DataProvider(value = { + "NULL_WINGTIPS_STRATEGY", + "NULL_WINGTIPS_ADAPTER" + }) + @Test + public void alternate_constructor_throws_IllegalArgumentException_if_passed_null_args( + NullArgsScenario scenario + ) { + // when + Throwable ex = catchThrowable( + () -> new DefaultAsyncHttpClientHelperSpanNamingAndTaggingStrategy( + scenario.wingtipsStrategy, scenario.wingtipsAdapter + ) + ); + + // then + assertThat(ex) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(scenario.expectedExceptionMessage); + } + + @Test + public void doGetInitialSpanName_delegates_to_wingtips_strategy() { + // when + String result = impl.doGetInitialSpanName(requestMock); + + // then + assertThat(result).isEqualTo(initialSpanNameFromStrategy.get()); + strategyInitialSpanNameArgs.get().verifyArgs(requestMock, wingtipsAdapterMock); + } + + @DataProvider(value = { + "null | false", + " | false", + "[whitespace] | false", + "fooNewName | true" + }, splitBy = "\\|") + @Test + public void doChangeSpanName_changes_span_name_as_expected(String newName, boolean expectNameToBeChanged) { + // given + if ("[whitespace]".equals(newName)) { + newName = " \r\n\t "; + } + + String initialSpanName = UUID.randomUUID().toString(); + Span span = Span.newBuilder(initialSpanName, Span.SpanPurpose.CLIENT).build(); + + String expectedSpanName = (expectNameToBeChanged) ? newName : initialSpanName; + + // when + impl.doChangeSpanName(span, newName); + + // then + assertThat(span.getSpanName()).isEqualTo(expectedSpanName); + } + + @Test + public void doHandleRequestTagging_delegates_to_wingtips_strategy() { + // when + impl.doHandleRequestTagging(spanMock, requestMock); + + // then + strategyRequestTaggingArgs.get().verifyArgs(spanMock, requestMock, wingtipsAdapterMock); + } + + @Test + public void doHandleResponseTaggingAndFinalSpanName_delegates_to_wingtips_strategy() { + // when + impl.doHandleResponseTaggingAndFinalSpanName(spanMock, requestMock, responseMock, errorMock); + + // then + strategyResponseTaggingArgs.get().verifyArgs( + spanMock, requestMock, responseMock, errorMock, wingtipsAdapterMock + ); + } +} \ No newline at end of file diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/RequestBuilderWrapperTest.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/RequestBuilderWrapperTest.java new file mode 100644 index 00000000..50abb1d3 --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/RequestBuilderWrapperTest.java @@ -0,0 +1,151 @@ +package com.nike.riposte.client.asynchttp; + +import com.nike.fastbreak.CircuitBreaker; +import com.nike.fastbreak.CircuitBreakerImpl; +import org.asynchttpclient.BoundRequestBuilder; +import org.asynchttpclient.Response; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.HttpMethod; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + + +public class RequestBuilderWrapperTest { + + private RequestBuilderWrapper requestBuilderWrapper; + private String url; + private String httpMethod; + private BoundRequestBuilder requestBuilder; + private Optional> customCircuitBreaker; + private boolean disableCircuitBreaker; + + @Before + public void setup() { + url = "http://localhost.com"; + httpMethod = HttpMethod.GET.name(); + requestBuilder = mock(BoundRequestBuilder.class); + customCircuitBreaker = Optional.of(new CircuitBreakerImpl<>()); + disableCircuitBreaker = true; + } + + @Test + public void constructor_sets_values_as_expected() { + // given + requestBuilderWrapper = new RequestBuilderWrapper( + url, + httpMethod, + requestBuilder, + customCircuitBreaker, + disableCircuitBreaker); + + // then + assertThat(requestBuilderWrapper.url).isEqualTo(url); + assertThat(requestBuilderWrapper.httpMethod).isEqualTo(httpMethod); + assertThat(requestBuilderWrapper.customCircuitBreaker).isEqualTo(customCircuitBreaker); + assertThat(requestBuilderWrapper.disableCircuitBreaker).isEqualTo(disableCircuitBreaker); + } + + @Test + public void get_set_ChannelHandlerContext_works_as_expected() { + // given + requestBuilderWrapper = new RequestBuilderWrapper( + url, + httpMethod, + requestBuilder, + customCircuitBreaker, + disableCircuitBreaker); + + ChannelHandlerContext ctx = mock(ChannelHandlerContext.class); + + // when + requestBuilderWrapper.setCtx(ctx); + + // then + assertThat(ctx).isEqualTo(requestBuilderWrapper.getCtx()); + } + + @Test + public void get_set_Url_works_as_expected() { + // given + requestBuilderWrapper = new RequestBuilderWrapper( + url, + httpMethod, + requestBuilder, + customCircuitBreaker, + disableCircuitBreaker); + + String alternateUrl = "http://alteredUrl.testing"; + + // when + requestBuilderWrapper.setUrl(alternateUrl); + + // then + assertThat(alternateUrl).isEqualTo(requestBuilderWrapper.getUrl()); + verify(requestBuilder).setUrl(alternateUrl); + } + + @Test + public void get_set_HttpMethod_works_as_expected() { + // given + requestBuilderWrapper = new RequestBuilderWrapper( + url, + httpMethod, + requestBuilder, + customCircuitBreaker, + disableCircuitBreaker); + + String alteredMethod = "POST"; + + // when + requestBuilderWrapper.setHttpMethod(alteredMethod); + + // then + assertThat(alteredMethod).isEqualTo(requestBuilderWrapper.getHttpMethod()); + verify(requestBuilder).setMethod(alteredMethod); + } + + @Test + public void get_set_DisableCircuitBreaker_works_as_expected() { + // given + requestBuilderWrapper = new RequestBuilderWrapper( + url, + httpMethod, + requestBuilder, + customCircuitBreaker, + disableCircuitBreaker); + + boolean alteredDisableCircuitBreaker = false; + + // when + requestBuilderWrapper.setDisableCircuitBreaker(alteredDisableCircuitBreaker); + + // then + assertThat(requestBuilderWrapper.isDisableCircuitBreaker()).isFalse(); + } + + @Test + public void get_set_CustomCircuitBreaker_works_as_expected() { + // given + requestBuilderWrapper = new RequestBuilderWrapper( + url, + httpMethod, + requestBuilder, + customCircuitBreaker, + disableCircuitBreaker); + + Optional> alteredCircuitBreaker = Optional.of(new CircuitBreakerImpl<>()); + + // when + requestBuilderWrapper.setCustomCircuitBreaker(alteredCircuitBreaker); + + // then + assertThat(requestBuilderWrapper.getCustomCircuitBreaker()).isEqualTo(alteredCircuitBreaker); + } +} diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/testutils/ArgCapturingHttpTagAndSpanNamingStrategy.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/testutils/ArgCapturingHttpTagAndSpanNamingStrategy.java new file mode 100644 index 00000000..67913ab0 --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/testutils/ArgCapturingHttpTagAndSpanNamingStrategy.java @@ -0,0 +1,155 @@ +package com.nike.riposte.client.asynchttp.testutils; + +import com.nike.wingtips.Span; +import com.nike.wingtips.tags.HttpTagAndSpanNamingAdapter; +import com.nike.wingtips.tags.HttpTagAndSpanNamingStrategy; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Helper class that gives you a {@link HttpTagAndSpanNamingStrategy} that lets you know when methods are called + * and what the args were. This is necessary because the entry methods to {@link HttpTagAndSpanNamingStrategy} + * are final, so they can't be mocked. + * + * @author Nic Munroe + */ +public class ArgCapturingHttpTagAndSpanNamingStrategy + extends HttpTagAndSpanNamingStrategy { + + private final AtomicReference initialSpanName; + private final AtomicBoolean initialSpanNameMethodCalled; + private final AtomicBoolean requestTaggingMethodCalled; + private final AtomicBoolean responseTaggingAndFinalSpanNameMethodCalled; + private final AtomicReference> initialSpanNameArgs; + private final AtomicReference> requestTaggingArgs; + private final AtomicReference> responseTaggingArgs; + + public ArgCapturingHttpTagAndSpanNamingStrategy( + AtomicReference initialSpanName, + AtomicBoolean initialSpanNameMethodCalled, + AtomicBoolean requestTaggingMethodCalled, + AtomicBoolean responseTaggingAndFinalSpanNameMethodCalled, + AtomicReference> initialSpanNameArgs, + AtomicReference> requestTaggingArgs, + AtomicReference> responseTaggingArgs + ) { + this.initialSpanName = initialSpanName; + this.initialSpanNameMethodCalled = initialSpanNameMethodCalled; + this.requestTaggingMethodCalled = requestTaggingMethodCalled; + this.responseTaggingAndFinalSpanNameMethodCalled = responseTaggingAndFinalSpanNameMethodCalled; + this.initialSpanNameArgs = initialSpanNameArgs; + this.requestTaggingArgs = requestTaggingArgs; + this.responseTaggingArgs = responseTaggingArgs; + } + + @Override + protected @Nullable String doGetInitialSpanName( + @NotNull REQ request, @NotNull HttpTagAndSpanNamingAdapter adapter + ) { + initialSpanNameMethodCalled.set(true); + initialSpanNameArgs.set(new InitialSpanNameArgs(request, adapter)); + return initialSpanName.get(); + } + + @Override + protected void doHandleResponseAndErrorTagging( + @NotNull Span span, + @Nullable REQ request, + @Nullable RES response, + @Nullable Throwable error, + @NotNull HttpTagAndSpanNamingAdapter adapter + ) { + responseTaggingAndFinalSpanNameMethodCalled.set(true); + responseTaggingArgs.set( + new ResponseTaggingArgs(span, request, response, error, adapter) + ); + } + + @Override + protected void doHandleRequestTagging( + @NotNull Span span, + @NotNull REQ request, + @NotNull HttpTagAndSpanNamingAdapter adapter + ) { + requestTaggingMethodCalled.set(true); + requestTaggingArgs.set(new RequestTaggingArgs(span, request, adapter)); + } + + public static class InitialSpanNameArgs { + + public final REQ request; + public final HttpTagAndSpanNamingAdapter adapter; + + private InitialSpanNameArgs( + REQ request, HttpTagAndSpanNamingAdapter adapter + ) { + this.request = request; + this.adapter = adapter; + } + + public void verifyArgs(REQ expectedRequest, HttpTagAndSpanNamingAdapter expectedAdapter) { + assertThat(request).isSameAs(expectedRequest); + assertThat(adapter).isSameAs(expectedAdapter); + } + } + + public static class RequestTaggingArgs { + + public final Span span; + public final REQ request; + public final HttpTagAndSpanNamingAdapter adapter; + + private RequestTaggingArgs( + Span span, REQ request, HttpTagAndSpanNamingAdapter adapter + ) { + this.span = span; + this.request = request; + this.adapter = adapter; + } + + public void verifyArgs( + Span expectedSpan, REQ expectedRequest, HttpTagAndSpanNamingAdapter expectedAdapter + ) { + assertThat(span).isSameAs(expectedSpan); + assertThat(request).isSameAs(expectedRequest); + assertThat(adapter).isSameAs(expectedAdapter); + } + } + + public static class ResponseTaggingArgs { + + public final Span span; + public final REQ request; + public final RES response; + public final Throwable error; + public final HttpTagAndSpanNamingAdapter adapter; + + private ResponseTaggingArgs( + Span span, REQ request, RES response, Throwable error, + HttpTagAndSpanNamingAdapter adapter + ) { + this.span = span; + this.request = request; + this.response = response; + this.error = error; + this.adapter = adapter; + } + + public void verifyArgs( + Span expectedSpan, REQ expectedRequest, RES expectedResponse, + Throwable expectedError, HttpTagAndSpanNamingAdapter expectedAdapter + ) { + assertThat(span).isSameAs(expectedSpan); + assertThat(request).isSameAs(expectedRequest); + assertThat(response).isSameAs(expectedResponse); + assertThat(error).isSameAs(expectedError); + assertThat(adapter).isSameAs(expectedAdapter); + } + } +} diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/util/AwsUtilTest.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/util/AwsUtilTest.java new file mode 100644 index 00000000..f0385814 --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/client/asynchttp/util/AwsUtilTest.java @@ -0,0 +1,386 @@ +package com.nike.riposte.client.asynchttp.util; + +import com.nike.internal.util.Pair; +import com.nike.riposte.client.asynchttp.AsyncHttpClientHelper; +import com.nike.riposte.client.asynchttp.AsyncResponseHandler; +import com.nike.riposte.client.asynchttp.RequestBuilderWrapper; +import com.nike.riposte.client.asynchttp.util.AwsUtil; +import com.nike.riposte.server.config.AppInfo; +import com.nike.riposte.server.config.impl.AppInfoImpl; + +import org.asynchttpclient.Response; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import io.netty.handler.codec.http.HttpMethod; + +import static com.nike.riposte.client.asynchttp.util.AwsUtil.AMAZON_METADATA_DOCUMENT_URL; +import static com.nike.riposte.client.asynchttp.util.AwsUtil.AMAZON_METADATA_INSTANCE_ID_URL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests the functionality of {@link AwsUtil}. + * + * @author Nic Munroe + */ +@RunWith(DataProviderRunner.class) +public class AwsUtilTest { + + private AsyncHttpClientHelper asyncClientMock; + + private RequestBuilderWrapper regionRequestBuilderWrapperMock; + private CompletableFuture regionCoreFuture; + + private RequestBuilderWrapper awsInstanceIdRequestBuilderWrapperMock; + private CompletableFuture awsInstanceIdCoreFuture; + + private Response responseMockForAwsMetadataDoc; + private Response responseMockForAwsInstanceId; + + private static final String CUSTOM_REGION = "us-west-" + UUID.randomUUID().toString(); + private static final String EXAMPLE_AWS_METADATA_DOC_RESULT = "{\n" + + " \"devpayProductCodes\" : null,\n" + + " \"privateIp\" : \"123.45.67.89\",\n" + + " \"availabilityZone\" : \"us-west-2b\",\n" + + " \"version\" : \"2010-08-31\",\n" + + " \"accountId\" : \"111222333444\",\n" + + " \"instanceId\" : \"i-aaa11b2c\",\n" + + " \"billingProducts\" : null,\n" + + " \"imageId\" : \"ami-1111a222\",\n" + + " \"instanceType\" : \"m3.medium\",\n" + + " \"kernelId\" : null,\n" + + " \"ramdiskId\" : null,\n" + + " \"architecture\" : \"x86_64\",\n" + + " \"pendingTime\" : \"2015-04-06T19:49:49Z\",\n" + + " \"region\" : \"" + CUSTOM_REGION + "\"\n" + + "}"; + private static final String EXAMPLE_AWS_INSTANCE_ID_RESULT = "i-" + UUID.randomUUID().toString(); + + private void setAppIdAndEnvironemntSystemProperties(String appId, String environment) { + if (appId == null) + System.clearProperty("@appId"); + else + System.setProperty("@appId", appId); + + if (environment == null) + System.clearProperty("@environment"); + else + System.setProperty("@environment", environment); + } + + @Before + public void beforeMethod() throws IOException { + setAppIdAndEnvironemntSystemProperties(null, null); + + asyncClientMock = mock(AsyncHttpClientHelper.class); + + // General-purpose setup + responseMockForAwsMetadataDoc = mock(Response.class); + doReturn(EXAMPLE_AWS_METADATA_DOC_RESULT).when(responseMockForAwsMetadataDoc).getResponseBody(); + + responseMockForAwsInstanceId = mock(Response.class); + doReturn(EXAMPLE_AWS_INSTANCE_ID_RESULT).when(responseMockForAwsInstanceId).getResponseBody(); + + // Setup region call + regionRequestBuilderWrapperMock = mock(RequestBuilderWrapper.class); + regionCoreFuture = new CompletableFuture<>(); + + doReturn(regionRequestBuilderWrapperMock) + .when(asyncClientMock) + .getRequestBuilder(AMAZON_METADATA_DOCUMENT_URL, HttpMethod.GET); + doAnswer(invocation -> { + AsyncResponseHandler handler = (AsyncResponseHandler) invocation.getArguments()[1]; + try { + regionCoreFuture.complete(handler.handleResponse(responseMockForAwsMetadataDoc)); + } catch (Throwable t) { + regionCoreFuture.completeExceptionally(t); + } + return regionCoreFuture; + }).when(asyncClientMock) + .executeAsyncHttpRequest(eq(regionRequestBuilderWrapperMock), any(AsyncResponseHandler.class)); + + // Setup AWS instance ID call + awsInstanceIdRequestBuilderWrapperMock = mock(RequestBuilderWrapper.class); + awsInstanceIdCoreFuture = new CompletableFuture<>(); + + doReturn(awsInstanceIdRequestBuilderWrapperMock) + .when(asyncClientMock) + .getRequestBuilder(AMAZON_METADATA_INSTANCE_ID_URL, HttpMethod.GET); + doAnswer(invocation -> { + AsyncResponseHandler handler = (AsyncResponseHandler) invocation.getArguments()[1]; + try { + awsInstanceIdCoreFuture.complete(handler.handleResponse(responseMockForAwsInstanceId)); + } catch (Throwable t) { + awsInstanceIdCoreFuture.completeExceptionally(t); + } + return awsInstanceIdCoreFuture; + }).when(asyncClientMock) + .executeAsyncHttpRequest(eq(awsInstanceIdRequestBuilderWrapperMock), any(AsyncResponseHandler.class)); + } + + @After + public void afterMethod() { + setAppIdAndEnvironemntSystemProperties(null, null); + } + + @Test + public void code_coverage_hoops() { + // jump! + new AwsUtil(); + } + + private Pair, AsyncResponseHandler> executeGetAwsRegionAndExtractHandler() { + CompletableFuture cf = AwsUtil.getAwsRegion(asyncClientMock); + + verify(asyncClientMock).getRequestBuilder(AMAZON_METADATA_DOCUMENT_URL, HttpMethod.GET); + ArgumentCaptor handlerArgCaptor = ArgumentCaptor.forClass(AsyncResponseHandler.class); + verify(asyncClientMock).executeAsyncHttpRequest(eq(regionRequestBuilderWrapperMock), + handlerArgCaptor.capture()); + + AsyncResponseHandler handler = handlerArgCaptor.getValue(); + + return Pair.of(cf, handler); + } + + @Test + public void getAwsRegion_makes_async_request_to_aws_and_returns_the_resulting_region() throws Throwable { + // when + Pair, AsyncResponseHandler> resultAndHandler = + executeGetAwsRegionAndExtractHandler(); + + // then + assertThat(resultAndHandler.getLeft()).isCompleted(); + String regionResult = resultAndHandler.getLeft().join(); + assertThat(regionResult).isEqualTo(CUSTOM_REGION); + assertThat(resultAndHandler.getRight().handleResponse(responseMockForAwsMetadataDoc)).isEqualTo(CUSTOM_REGION); + } + + @DataProvider(value = { + "true", + "false" + }) + @Test + public void getAwsRegion_returns_AppInfo_UNKNOWN_VALUE_if_response_from_aws_does_not_contain_region_value( + boolean useJunkJson + ) throws Throwable { + // given + String result = (useJunkJson) ? "i am not parseable as json" : "{\"notregion\":\"stillnotregion\"}"; + doReturn(result).when(responseMockForAwsMetadataDoc).getResponseBody(); + + // when + Pair, AsyncResponseHandler> resultAndHandler = + executeGetAwsRegionAndExtractHandler(); + + // then + assertThat(resultAndHandler.getLeft()).isCompleted(); + String regionResult = resultAndHandler.getLeft().join(); + assertThat(regionResult).isEqualTo(AppInfo.UNKNOWN_VALUE); + assertThat(resultAndHandler.getRight().handleResponse(responseMockForAwsMetadataDoc)) + .isEqualTo(AppInfo.UNKNOWN_VALUE); + } + + @DataProvider(value = { + "true", + "false" + }) + @Test + public void getAwsRegion_returns_completable_future_with_fallback_handling_logic(boolean completeExceptionally) { + // given + // Don't complete the core future, just return it, so we can complete it how we want. + doReturn(regionCoreFuture).when(asyncClientMock) + .executeAsyncHttpRequest(eq(regionRequestBuilderWrapperMock), + any(AsyncResponseHandler.class)); + Pair, AsyncResponseHandler> resultAndHandler = + executeGetAwsRegionAndExtractHandler(); + assertThat(regionCoreFuture).isNotDone(); + assertThat(resultAndHandler.getLeft()).isNotDone(); + + // when + if (completeExceptionally) + regionCoreFuture.completeExceptionally(new RuntimeException("kaboom")); + else + regionCoreFuture.complete(null); + + // then + // In either case, we should get AppInfo.UNKNOWN_VALUE as the final result from the future with fallback logic. + assertThat(resultAndHandler.getLeft()).isCompleted(); + assertThat(resultAndHandler.getLeft().join()).isEqualTo(AppInfo.UNKNOWN_VALUE); + } + + private Pair, AsyncResponseHandler> executeGetAwsInstanceIdAndExtractHandler() { + CompletableFuture cf = AwsUtil.getAwsInstanceId(asyncClientMock); + + verify(asyncClientMock).getRequestBuilder(AMAZON_METADATA_INSTANCE_ID_URL, HttpMethod.GET); + ArgumentCaptor handlerArgCaptor = ArgumentCaptor.forClass(AsyncResponseHandler.class); + verify(asyncClientMock).executeAsyncHttpRequest(eq(awsInstanceIdRequestBuilderWrapperMock), + handlerArgCaptor.capture()); + + AsyncResponseHandler handler = handlerArgCaptor.getValue(); + + return Pair.of(cf, handler); + } + + @Test + public void getAwsInstanceId_makes_async_request_to_aws_and_returns_the_resulting_instance_id() throws Throwable { + // when + Pair, AsyncResponseHandler> resultAndHandler = + executeGetAwsInstanceIdAndExtractHandler(); + + // then + assertThat(resultAndHandler.getLeft()).isCompleted(); + String instanceIdResult = resultAndHandler.getLeft().join(); + assertThat(instanceIdResult).isEqualTo(EXAMPLE_AWS_INSTANCE_ID_RESULT); + assertThat(resultAndHandler.getRight().handleResponse(responseMockForAwsInstanceId)) + .isEqualTo(EXAMPLE_AWS_INSTANCE_ID_RESULT); + } + + @DataProvider(value = { + "true", + "false" + }) + @Test + public void getAwsInstanceId_returns_completable_future_with_fallback_handling_logic( + boolean completeExceptionally) { + // given + // Don't complete the core future, just return it, so we can complete it how we want. + doReturn(awsInstanceIdCoreFuture) + .when(asyncClientMock) + .executeAsyncHttpRequest(eq(awsInstanceIdRequestBuilderWrapperMock), any(AsyncResponseHandler.class)); + Pair, AsyncResponseHandler> resultAndHandler = + executeGetAwsInstanceIdAndExtractHandler(); + assertThat(awsInstanceIdCoreFuture).isNotDone(); + assertThat(resultAndHandler.getLeft()).isNotDone(); + + // when + if (completeExceptionally) + awsInstanceIdCoreFuture.completeExceptionally(new RuntimeException("kaboom")); + else + awsInstanceIdCoreFuture.complete(null); + + // then + // In either case, we should get AppInfo.UNKNOWN_VALUE as the final result from the future with fallback logic. + assertThat(resultAndHandler.getLeft()).isCompleted(); + assertThat(resultAndHandler.getLeft().join()).isEqualTo(AppInfo.UNKNOWN_VALUE); + } + + @Test + public void getAppInfoFutureWithAwsInfo_with_all_args_uses_data_from_getAwsRegion_and_getAwsInstanceId_to_build_result() { + // given + String appId = "appid-" + UUID.randomUUID().toString(); + String environment = "environment-" + UUID.randomUUID().toString(); + String expectedDataCenter = AwsUtil.getAwsRegion(asyncClientMock).join(); + String expectedInstanceId = AwsUtil.getAwsInstanceId(asyncClientMock).join(); + + // when + AppInfo result = AwsUtil.getAppInfoFutureWithAwsInfo(appId, environment, asyncClientMock).join(); + + // then + assertThat(result.appId()).isEqualTo(appId); + assertThat(result.environment()).isEqualTo(environment); + assertThat(result.dataCenter()).isEqualTo(expectedDataCenter); + assertThat(result.instanceId()).isEqualTo(expectedInstanceId); + } + + @Test + public void getAppInfoFutureWithAwsInfo_with_all_args_uses_InetAddress_local_hostname_if_getAwsInstanceId_returns_unknown() + throws IOException { + // given + String appId = "appid-" + UUID.randomUUID().toString(); + String environment = "environment-" + UUID.randomUUID().toString(); + String expectedDataCenter = AwsUtil.getAwsRegion(asyncClientMock).join(); + doReturn(null).when(responseMockForAwsInstanceId).getResponseBody(); + String expectedInstanceId = InetAddress.getLocalHost().getHostName(); + + // when + AppInfo result = AwsUtil.getAppInfoFutureWithAwsInfo(appId, environment, asyncClientMock).join(); + + // then + assertThat(result.appId()).isEqualTo(appId); + assertThat(result.environment()).isEqualTo(environment); + assertThat(result.dataCenter()).isEqualTo(expectedDataCenter); + assertThat(result.instanceId()).isEqualTo(expectedInstanceId); + } + + @DataProvider(value = { + "local", + "compiletimetest" + }) + @Test + public void getAppInfoFutureWithAwsInfo_with_all_args_returns_AppInfoImpl_createLocalInstance_if_environment_is_local_or_compiletimetest( + String environment + ) { + // given + String appId = "appid-" + UUID.randomUUID().toString(); + AppInfo expectedResult = AppInfoImpl.createLocalInstance(appId); + + // when + AppInfo result = AwsUtil.getAppInfoFutureWithAwsInfo(appId, environment, asyncClientMock).join(); + + // then + assertThat(result.appId()).isEqualTo(expectedResult.appId()).isEqualTo(appId); + assertThat(result.environment()).isEqualTo(expectedResult.environment()); + assertThat(result.dataCenter()).isEqualTo(expectedResult.dataCenter()); + assertThat(result.instanceId()).isEqualTo(expectedResult.instanceId()); + } + + @Test + public void getAppInfoFutureWithAwsInfo_with_minimal_args_delegates_to_kitchen_sink_overload_method() { + // given + String appId = "appid-" + UUID.randomUUID().toString(); + String environment = "environment-" + UUID.randomUUID().toString(); + String expectedDataCenter = AwsUtil.getAwsRegion(asyncClientMock).join(); + String expectedInstanceId = AwsUtil.getAwsInstanceId(asyncClientMock).join(); + setAppIdAndEnvironemntSystemProperties(appId, environment); + + // when + AppInfo result = AwsUtil.getAppInfoFutureWithAwsInfo(asyncClientMock).join(); + + // then + assertThat(result.appId()).isEqualTo(appId); + assertThat(result.environment()).isEqualTo(environment); + assertThat(result.dataCenter()).isEqualTo(expectedDataCenter); + assertThat(result.instanceId()).isEqualTo(expectedInstanceId); + } + + @Test + public void getAppInfoFutureWithAwsInfo_with_minimal_args_throws_IllegalStateException_if_appId_is_missing() { + // given + setAppIdAndEnvironemntSystemProperties(null, UUID.randomUUID().toString()); + + // when + Throwable ex = catchThrowable(() -> AwsUtil.getAppInfoFutureWithAwsInfo(asyncClientMock)); + + // then + assertThat(ex).isInstanceOf(IllegalStateException.class); + } + + @Test + public void getAppInfoFutureWithAwsInfo_with_minimal_args_throws_IllegalStateException_if_environment_is_missing() { + // given + setAppIdAndEnvironemntSystemProperties(UUID.randomUUID().toString(), null); + + // when + Throwable ex = catchThrowable(() -> AwsUtil.getAppInfoFutureWithAwsInfo(asyncClientMock)); + + // then + assertThat(ex).isInstanceOf(IllegalStateException.class); + } +} \ No newline at end of file diff --git a/riposte-async-http-client2/src/test/java/com/nike/riposte/componenttest/VerifyAsyncHttpClientHelperComponentTest.java b/riposte-async-http-client2/src/test/java/com/nike/riposte/componenttest/VerifyAsyncHttpClientHelperComponentTest.java new file mode 100644 index 00000000..5588b6a9 --- /dev/null +++ b/riposte-async-http-client2/src/test/java/com/nike/riposte/componenttest/VerifyAsyncHttpClientHelperComponentTest.java @@ -0,0 +1,361 @@ +package com.nike.riposte.componenttest; + +import com.nike.backstopper.apierror.ApiError; +import com.nike.backstopper.apierror.ApiErrorBase; +import com.nike.backstopper.exception.ApiException; +import com.nike.riposte.client.asynchttp.AsyncHttpClientHelper; +import com.nike.riposte.client.asynchttp.RequestBuilderWrapper; +import com.nike.riposte.server.Server; +import com.nike.riposte.server.config.ServerConfig; +import com.nike.riposte.server.http.Endpoint; +import com.nike.riposte.server.http.RequestInfo; +import com.nike.riposte.server.http.ResponseInfo; +import com.nike.riposte.server.http.StandardEndpoint; +import com.nike.riposte.util.Matcher; +import com.nike.wingtips.Span; +import com.nike.wingtips.TraceHeaders; +import com.nike.wingtips.Tracer; +import com.nike.wingtips.lifecyclelistener.SpanLifecycleListener; +import com.nike.wingtips.tags.KnownZipkinTags; +import com.nike.wingtips.tags.WingtipsTags; + +import org.asynchttpclient.Response; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; + +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.slf4j.MDC; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.ServerSocket; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpMethod; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +/** + * Component test that verifies essential {@link AsyncHttpClientHelper} functionality. + * + * @author Nic Munroe + */ +@RunWith(DataProviderRunner.class) +public class VerifyAsyncHttpClientHelperComponentTest { + + private static int serverPort; + private static Server server; + private static AppServerConfigForTesting serverConfig; + + private SpanRecorder spanRecorder; + + public static int findFreePort() { + try (ServerSocket serverSocket = new ServerSocket(0)) { + return serverSocket.getLocalPort(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @BeforeClass + public static void setup() throws Exception { + serverPort = findFreePort(); + serverConfig = new AppServerConfigForTesting(singleton(new TestEndpoint()), serverPort); + server = new Server(serverConfig); + server.startup(); + } + + @AfterClass + public static void teardown() throws Exception { + server.shutdown(); + } + + private void resetTracing() { + MDC.clear(); + Tracer.getInstance().unregisterFromThread(); + removeSpanRecorderLifecycleListener(); + spanRecorder = new SpanRecorder(); + Tracer.getInstance().addSpanLifecycleListener(spanRecorder); + } + + private void removeSpanRecorderLifecycleListener() { + List listeners = new ArrayList<>(Tracer.getInstance().getSpanLifecycleListeners()); + for (SpanLifecycleListener listener : listeners) { + if (listener instanceof SpanRecorder) { + Tracer.getInstance().removeSpanLifecycleListener(listener); + } + } + } + + @Before + public void beforeMethod() { + resetTracing(); + } + + @After + public void afterMethod() { + resetTracing(); + } + + @DataProvider(value = { + "true | true", + "true | false", + "false | true", + "false | false" + }, splitBy = "\\|") + @Test + public void verify_basic_functionality(boolean surroundWithSubspan, boolean parentSpanExists) throws Exception { + // given + AsyncHttpClientHelper asyncClient = AsyncHttpClientHelper.builder() + .setPerformSubSpanAroundDownstreamCalls(surroundWithSubspan) + .build(); + + String fullUrl = "http://localhost:" + serverPort + TestEndpoint.MATCHING_PATH + "?foo=bar"; + RequestBuilderWrapper rbw = asyncClient.getRequestBuilder(fullUrl, HttpMethod.GET); + rbw.requestBuilder.setHeader(TestEndpoint.EXPECTED_HEADER_KEY, TestEndpoint.EXPECTED_HEADER_VAL); + rbw.requestBuilder.setBody(TestEndpoint.EXPECTED_REQUEST_PAYLOAD); + + Deque distributedTraceStackForCall = null; + Map mdcContextForCall = null; + Span origSpan = null; + + if (parentSpanExists) { + origSpan = Tracer.getInstance().startRequestWithRootSpan("overallReqSpan"); + distributedTraceStackForCall = Tracer.getInstance().getCurrentSpanStackCopy(); + mdcContextForCall = MDC.getCopyOfContextMap(); + resetTracing(); + } + + // when + Response result = asyncClient.executeAsyncHttpRequest( + rbw, response -> response, distributedTraceStackForCall, mdcContextForCall + ).join(); + + // then + Span subspan = findSubspan(); + + assertThat(result.getStatusCode()).isEqualTo(200); + assertThat(result.getResponseBody()).isEqualTo(TestEndpoint.RESPONSE_PAYLOAD); + + if (surroundWithSubspan) { + assertThat(subspan).isNotNull(); + assertThat(result.getHeader(TraceHeaders.TRACE_ID)).isEqualTo(subspan.getTraceId()); + assertThat(result.getHeader(TraceHeaders.SPAN_ID)).isEqualTo(subspan.getSpanId()); + assertThat(result.getHeader(TraceHeaders.PARENT_SPAN_ID)).isEqualTo( + String.valueOf(subspan.getParentSpanId()) + ); + verifySubspanTags(subspan, fullUrl, "200", false); + } else { + assertThat(subspan).isNull(); + } + + if (parentSpanExists) { + assertThat(result.getHeader(TraceHeaders.TRACE_ID)).isEqualTo(origSpan.getTraceId()); + String expectedParentSpanId = (surroundWithSubspan) ? subspan.getParentSpanId() : "null"; + assertThat(result.getHeader(TraceHeaders.PARENT_SPAN_ID)).isEqualTo(expectedParentSpanId); + String expectedSpanId = (surroundWithSubspan) ? subspan.getSpanId() : origSpan.getSpanId(); + assertThat(result.getHeader(TraceHeaders.SPAN_ID)).isEqualTo(expectedSpanId); + } + + if (!parentSpanExists && !surroundWithSubspan) { + assertThat(result.getHeader(TraceHeaders.TRACE_ID)).isEqualTo("null"); + assertThat(result.getHeader(TraceHeaders.SPAN_ID)).isEqualTo("null"); + assertThat(result.getHeader(TraceHeaders.PARENT_SPAN_ID)).isEqualTo("null"); + } + } + + @DataProvider(value = { + "true | true", + "true | false", + "false | true", + "false | false" + }, splitBy = "\\|") + @Test + public void verify_tags_when_an_error_occurs_preventing_execution( + boolean surroundWithSubspan, boolean parentSpanExists + ) { + // given + AsyncHttpClientHelper asyncClient = AsyncHttpClientHelper.builder() + .setPerformSubSpanAroundDownstreamCalls(surroundWithSubspan) + .build(); + + // The server is not listening on HTTPS, just HTTP, so trying to hit HTTPS will result in an exception. + String fullUrl = "https://localhost:" + serverPort + TestEndpoint.MATCHING_PATH + "?foo=bar"; + RequestBuilderWrapper rbw = asyncClient.getRequestBuilder(fullUrl, HttpMethod.GET); + rbw.requestBuilder.setHeader(TestEndpoint.EXPECTED_HEADER_KEY, TestEndpoint.EXPECTED_HEADER_VAL); + rbw.requestBuilder.setBody(TestEndpoint.EXPECTED_REQUEST_PAYLOAD); + + Deque distributedTraceStackForCall = null; + Map mdcContextForCall = null; + Span origSpan = null; + + if (parentSpanExists) { + origSpan = Tracer.getInstance().startRequestWithRootSpan("overallReqSpan"); + distributedTraceStackForCall = Tracer.getInstance().getCurrentSpanStackCopy(); + mdcContextForCall = MDC.getCopyOfContextMap(); + resetTracing(); + } + + final Deque finalDistributedTraceStackForCall = distributedTraceStackForCall; + final Map finalMdcContextForCall = mdcContextForCall; + + // when + Throwable ex = catchThrowable( + () -> asyncClient.executeAsyncHttpRequest( + rbw, response -> response, finalDistributedTraceStackForCall, finalMdcContextForCall + ).join() + ); + + // then + assertThat(ex) + .isInstanceOf(CompletionException.class) + .hasCauseInstanceOf(ConnectException.class); + + Span subspan = findSubspan(); + + if (surroundWithSubspan) { + if (parentSpanExists) { + assertThat(subspan.getTraceId()).isEqualTo(origSpan.getTraceId()); + assertThat(subspan.getParentSpanId()).isEqualTo(origSpan.getSpanId()); + } else { + assertThat(subspan.getParentSpanId()).isNull(); + } + + verifySubspanTags(subspan, fullUrl, null, true); + } else { + assertThat(subspan).isNull(); + } + } + + private void verifySubspanTags( + Span subspan, String expectedFullUrl, String expectedHttpStatusCode, boolean expectError + ) { + assertThat(subspan.getTags().get(KnownZipkinTags.HTTP_METHOD)).isEqualTo("GET"); + assertThat(subspan.getTags().get(KnownZipkinTags.HTTP_PATH)).isEqualTo(TestEndpoint.MATCHING_PATH); + assertThat(subspan.getTags().get(KnownZipkinTags.HTTP_URL)).isEqualTo(expectedFullUrl); + assertThat(subspan.getTags().get(KnownZipkinTags.HTTP_STATUS_CODE)).isEqualTo(expectedHttpStatusCode); + assertThat(subspan.getTags().get(WingtipsTags.SPAN_HANDLER)).isEqualTo("riposte.asynchttpclienthelper"); + + if (expectError) { + assertThat(subspan.getTags().get(KnownZipkinTags.ERROR)).isNotNull(); + } else { + assertThat(subspan.getTags().get(KnownZipkinTags.ERROR)).isNull(); + } + + // Either there's a status code tag but no error tag, or an error tag but no status code tag. In either + // case we expect 5 tags. + assertThat(subspan.getTags()).hasSize(5); + } + + private Span findSubspan() { + return spanRecorder.completedSpans.stream().filter( + s -> "riposte.asynchttpclienthelper".equals(s.getTags().get(WingtipsTags.SPAN_HANDLER)) + ).findFirst().orElse(null); + } + + private static class TestEndpoint extends StandardEndpoint { + + public static final String MATCHING_PATH = "/some/testEndpoint"; + public static final String EXPECTED_HEADER_KEY = "expected-header"; + public static final String EXPECTED_HEADER_VAL = UUID.randomUUID().toString(); + public static final String EXPECTED_REQUEST_PAYLOAD = UUID.randomUUID().toString(); + public static final String RESPONSE_PAYLOAD = UUID.randomUUID().toString(); + + public static final ApiError MISSING_EXPECTED_REQ_PAYLOAD = + new ApiErrorBase("MISSING_EXPECTED_REQ_PAYLOAD", 42, "Missing expected request payload", 400); + public static final ApiError MISSING_EXPECTED_HEADER = + new ApiErrorBase("MISSING_EXPECTED_HEADER", 42, "Missing expected header", 400); + + @Override + public @NotNull Matcher requestMatcher() { + return Matcher.match(MATCHING_PATH); + } + + @Override + public @NotNull CompletableFuture> execute( + @NotNull RequestInfo request, + @NotNull Executor longRunningTaskExecutor, + @NotNull ChannelHandlerContext ctx + ) { + if (!EXPECTED_REQUEST_PAYLOAD.equals(request.getContent())) + throw new ApiException(MISSING_EXPECTED_REQ_PAYLOAD); + + if (!EXPECTED_HEADER_VAL.equals(request.getHeaders().get(EXPECTED_HEADER_KEY))) + throw new ApiException(MISSING_EXPECTED_HEADER); + + return CompletableFuture.completedFuture( + ResponseInfo.newBuilder(RESPONSE_PAYLOAD) + .withHeaders( + new DefaultHttpHeaders() + .set(TraceHeaders.TRACE_ID, + String.valueOf(request.getHeaders().get(TraceHeaders.TRACE_ID)) + ) + .set(TraceHeaders.SPAN_ID, + String.valueOf(request.getHeaders().get(TraceHeaders.SPAN_ID)) + ) + .set(TraceHeaders.PARENT_SPAN_ID, + String.valueOf(request.getHeaders().get(TraceHeaders.PARENT_SPAN_ID)) + ) + ) + .build() + ); + } + } + + private static class AppServerConfigForTesting implements ServerConfig { + + private final Collection> endpoints; + private final int port; + + private AppServerConfigForTesting(Collection> endpoints, int port) { + this.endpoints = endpoints; + this.port = port; + } + + @Override + public @NotNull Collection<@NotNull Endpoint> appEndpoints() { + return endpoints; + } + + @Override + public int endpointsPort() { + return port; + } + } + + private static class SpanRecorder implements SpanLifecycleListener { + + public final List completedSpans = new ArrayList<>(); + + @Override + public void spanStarted(Span span) { + } + + @Override + public void spanSampled(Span span) { + } + + @Override + public void spanCompleted(Span span) { + completedSpans.add(span); + } + } +} diff --git a/settings.gradle b/settings.gradle index 1802ec67..dbfaa6e5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,6 +10,7 @@ include "riposte-spi", "riposte-metrics-codahale", "riposte-metrics-codahale-signalfx", "riposte-async-http-client", + "riposte-async-http-client2", "riposte-service-registration-eureka", "samples:sample-1-helloworld", "samples:sample-2-kotlin-todoservice"