
com.urbanairship.connect.client.StreamConnection Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of connect-client Show documentation
Show all versions of connect-client Show documentation
The UA Connect Java client library
The newest version!
/*
Copyright 2015-2022 Airship and Contributors
*/
package com.urbanairship.connect.client;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.net.HttpHeaders;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.gson.Gson;
import io.netty.handler.codec.http.CookieDecoder;
import io.netty.handler.codec.http.cookie.Cookie;
import org.asynchttpclient.AsyncCompletionHandler;
import org.asynchttpclient.AsyncHandler;
import org.asynchttpclient.AsyncHttpClient;
import org.asynchttpclient.BoundRequestBuilder;
import org.asynchttpclient.ListenableFuture;
import com.urbanairship.connect.client.consume.ConnectionRetryStrategy;
import com.urbanairship.connect.client.consume.FullBodyConsumer;
import com.urbanairship.connect.client.consume.MobileEventStreamBodyConsumer;
import com.urbanairship.connect.client.consume.MobileEventStreamConnectFuture;
import com.urbanairship.connect.client.consume.MobileEventStreamResponseHandler;
import com.urbanairship.connect.client.consume.StatusAndHeaders;
import com.urbanairship.connect.client.model.Creds;
import com.urbanairship.connect.client.model.GsonUtil;
import com.urbanairship.connect.client.model.StreamQueryDescriptor;
import com.urbanairship.connect.client.model.request.StartPosition;
import com.urbanairship.connect.client.model.request.StreamRequestPayload;
import com.urbanairship.connect.java8.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sun.net.www.protocol.http.HttpURLConnection;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Provides the abstraction through which events are streamed from the Airship Real-Time Data Streaming endpoint to a caller.
*
* Usage should follow the pattern:
*
* try (StreamConnection conn = new StreamConnection(...)) {
* conn.read(...)
* }
*
*
* Proper use of this class means that only a single thread will call {@link #read(Optional)} and a call will only be
* made once. A call to {@link #close()} can be made by any other thread and at any time and resources will be appropriately
* cleaned up and cause any call to {@link #read(Optional)} to exit.
*/
public class StreamConnection implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(StreamConnection.class);
public static final String X_UA_APPKEY = "X-UA-Appkey";
public static final String ACCEPT_HEADER = "application/vnd.urbanairship+x-ndjson; version=3;";
private static final Gson GSON = GsonUtil.getGson();
private final StreamQueryDescriptor descriptor;
private final AsyncHttpClient client;
private final ConnectionRetryStrategy connectionRetryStrategy;
private final Consumer eventConsumer;
private final String url;
private final AtomicBoolean gate = new AtomicBoolean(false);
private volatile Connection connection = null;
private volatile CountDownLatch bodyConsumeLatch = null;
private volatile boolean closed = false;
private final Object transitionLock = new Object();
public StreamConnection(StreamQueryDescriptor descriptor,
AsyncHttpClient client,
ConnectionRetryStrategy connectionRetryStrategy,
Consumer eventConsumer,
String url) {
this.descriptor = descriptor;
this.client = client;
this.connectionRetryStrategy = connectionRetryStrategy;
this.eventConsumer = eventConsumer;
this.url = url;
}
public StreamConnection(StreamQueryDescriptor descriptor,
AsyncHttpClient client,
ConnectionRetryStrategy connectionRetryStrategy,
Consumer eventConsumer) {
this(descriptor, client, connectionRetryStrategy, eventConsumer, descriptor.getEndpointUrl());
}
/**
* Opens up a connection to Airship Real-Time Data Streaming and begins consuming data and passing it to the configured consumer
* starting at the position specified by the startPosition parameter.
*
* @param startPosition optionally specifies the starting position to consume from.
*
* @throws ConnectionException thrown if a connection cannot be successfully made and indicates a problem with either
* the request or unexpected behavior from the API.
* @throws InterruptedException this method is blocking and this will be thrown if the underlying blocking calls are
* interrupted.
*/
public void read(Optional startPosition) throws ConnectionException, InterruptedException {
if (!gate.compareAndSet(false, true)) {
throw new IllegalStateException("Stream is already consuming!");
}
boolean connected = false;
boolean retry;
int attempt = 0;
Optional extends Exception> failure = Optional.absent();
do {
attempt++;
// The sync is shared with the cleanup method and ensures we don't miss a "close" signal and potentially setup
// resources after the close and thus don't have those resources cleaned up in the case of a race between a call
// to cleanup and this method.
synchronized (transitionLock) {
if (closed) {
break;
}
failure = begin(startPosition, attempt);
connected = !failure.isPresent();
}
retry = false;
if (!connected && !closed) {
retry = connectionRetryStrategy.shouldRetry(attempt);
if (retry) {
Thread.sleep(connectionRetryStrategy.getPauseMillis(attempt));
}
}
} while (!connected && retry);
if (!connected && !closed) {
Exception lastFailure = failure.get();
String message = String.format("Failed to establish connection to event stream after %d attempts", attempt);
if (lastFailure instanceof ConnectionException) {
ConnectionException lastConnectionException = (ConnectionException) lastFailure;
throw new ConnectionException(message, lastConnectionException.getErrorCode(), lastConnectionException);
}
throw new RuntimeException(message, lastFailure);
}
if (connected) {
consume();
}
}
private Optional extends Exception> begin(Optional startPosition, int attempt) throws InterruptedException {
try {
connection = connect(Collections.emptyList(), startPosition);
}
catch (InterruptedException e) {
throw e;
}
catch (ConnectionException e) {
return Optional.of(e);
}
catch (Exception e) {
return Optional.of(e);
}
bodyConsumeLatch = new CountDownLatch(1);
connection.consume(bodyConsumeLatch, eventConsumer);
return Optional.absent();
}
private void consume() throws InterruptedException {
try {
bodyConsumeLatch.await();
Optional error = connection.getConsumeError();
if (error.isPresent()) {
throw new RuntimeException("Error occurred consuming stream for app " + getAppKey(), error.get());
}
}
finally {
cleanup();
}
}
private void cleanup() {
// The sync ensures consistency in read of "closed" between the cleanup a potential call to read (the method).
// We need to ensure that in a race between a call to close and a call to read, it's not possible for the close
// to miss the setup of the latch and connection.
synchronized (transitionLock) {
if (closed) {
return;
}
closed = true;
}
if (bodyConsumeLatch != null) {
bodyConsumeLatch.countDown();
}
if (connection != null) {
connection.close();
}
}
@Override
public void close() throws Exception {
cleanup();
}
private Connection connect(Collection cookies, Optional startPosition) throws InterruptedException, ExecutionException, ConnectionException {
BoundRequestBuilder request = buildRequest(cookies, startPosition);
MobileEventStreamConnectFuture connectFuture = new MobileEventStreamConnectFuture();
MobileEventStreamResponseHandler responseHandler = new MobileEventStreamResponseHandler(connectFuture);
ListenableFuture future = request.execute(responseHandler);
StatusAndHeaders statusAndHeaders;
try {
statusAndHeaders = connectFuture.get();
}
catch (InterruptedException | ExecutionException e) {
responseHandler.stop();
future.done();
throw e;
}
int status = statusAndHeaders.getStatusCode();
if (status == HttpURLConnection.HTTP_OK) {
return new Connection(future, responseHandler);
}
if (status != 307) {
throw buildErrorException(responseHandler, future, status);
}
return handleRedirect(statusAndHeaders, startPosition);
}
private ConnectionException buildErrorException(MobileEventStreamResponseHandler responseHandler,
ListenableFuture future,
int status)
throws InterruptedException, ExecutionException {
// The response indicates an error of some sort. Read the response body so we can detail any information
// about the error from the server out through an exception message
FullBodyConsumer bodyReader = new FullBodyConsumer();
try {
responseHandler.consumeBody(bodyReader);
future.get();
}
finally {
responseHandler.stop();
future.done();
}
String body = bodyReader.get();
// 400s indicate a bad request
if (399 < status && status < 500) {
return new ConnectionException(String.format("Received status code (%d) from a bad request for app %s. Response body: %s", status, getAppKey(), body), status);
}
return new ConnectionException(String.format("Received unexpected status code (%d) from request for stream for app %s. Response body: %s", status, getAppKey(), body), status);
}
private BoundRequestBuilder buildRequest(Collection cookies, Optional startPosition) {
byte[] query = getQuery(startPosition);
BoundRequestBuilder request = client.preparePost(url)
.addHeader(HttpHeaders.ACCEPT, ACCEPT_HEADER)
.addHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(query.length));
Map authHeaders = getAuthHeaders(descriptor.getCreds());
for (Map.Entry entry : authHeaders.entrySet()) {
request.addHeader(entry.getKey(), entry.getValue());
}
for (Cookie cookie : cookies) {
request.addCookie(cookie);
}
request.setBody(query);
return request;
}
private Connection handleRedirect(StatusAndHeaders statusAndHeaders, Optional startPosition) throws InterruptedException, ExecutionException, ConnectionException {
String value = statusAndHeaders.getHeaders().get("Set-Cookie");
if (value == null) {
throw new ConnectionException("Received redirect response with no 'Set-Cookie' header in response!", statusAndHeaders.getStatusCode());
}
Set cookies = CookieDecoder.decode(value);
if (cookies == null) {
throw new ConnectionException("Received redirect response with unparsable 'Set-Cookie' value - " + value, statusAndHeaders.getStatusCode());
}
try {
return connect(new ArrayList(cookies), startPosition);
} catch (Exception e) {
throw e;
}
}
private Map getAuthHeaders(Creds creds) {
return ImmutableMap.of(
HttpHeaders.AUTHORIZATION, "Bearer " + creds.getToken(),
X_UA_APPKEY, creds.getAppKey()
);
}
private byte[] getQuery(Optional position) {
StreamRequestPayload payload =
new StreamRequestPayload(descriptor.getFilters(), descriptor.getSubset(), position, descriptor.offsetUpdatesEnabled());
String json = GSON.toJson(payload);
return json.getBytes(StandardCharsets.UTF_8);
}
private String getAppKey() {
return descriptor.getCreds().getAppKey();
}
private static final class Connection {
private final ListenableFuture future;
private final MobileEventStreamResponseHandler handler;
private Connection(ListenableFuture future, MobileEventStreamResponseHandler handler) {
this.future = future;
this.handler = handler;
}
public void consume(final CountDownLatch doneLatch, Consumer eventConsumer) {
Runnable doneLatchCountDownRunnable = new Runnable() {
@Override
public void run() {
doneLatch.countDown();
}
};
future.addListener(doneLatchCountDownRunnable, MoreExecutors.directExecutor());
MobileEventStreamBodyConsumer bodyConsumer = new MobileEventStreamBodyConsumer(eventConsumer);
handler.consumeBody(bodyConsumer);
}
public Optional getConsumeError() {
return handler.getError();
}
public void close() {
try {
handler.stop();
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
future.done();
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy