import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Collectors;
import javax.ws.rs.core.MediaType;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.http.MetaData;
import org.eclipse.jetty.http.MetaData.Response;
+import org.eclipse.jetty.http2.ErrorCode;
import org.eclipse.jetty.http2.api.Session;
import org.eclipse.jetty.http2.api.Stream;
import org.eclipse.jetty.http2.client.HTTP2Client;
private abstract class BaseStreamListenerAdapter<T> extends Stream.Listener.Adapter {
protected final CompletableFuture<T> completable = new CompletableFuture<T>();
private String contentType = "UNDEFINED";
+ private int status;
protected T awaitResult() throws ExecutionException, InterruptedException, TimeoutException {
return completable.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
return contentType;
}
- protected void handleHttp2Error(Http2Error error) {
+ /**
+ * Return the HTTP status code.
+ *
+ * @return status code e.g. 200
+ */
+ protected int getStatus() {
+ return status;
+ }
+
+ /**
+ * Handle an HTTP2 error.
+ *
+ * @param error the type of error.
+ * @param session the session on which the error occurred.
+ */
+ protected void handleHttp2Error(Http2Error error, Session session) {
Http2Exception e = new Http2Exception(error);
if (Http2Error.UNAUTHORIZED.equals(error)) {
// for external error handling, abstract authorization errors into a separate exception
} else {
completable.completeExceptionally(e);
}
- fatalErrorDelayed(this, e);
+ fatalErrorDelayed(this, e, session);
}
/**
*/
@Override
public void onHeaders(@Nullable Stream stream, @Nullable HeadersFrame frame) {
+ Objects.requireNonNull(stream);
Objects.requireNonNull(frame);
MetaData metaData = frame.getMetaData();
if (metaData.isResponse()) {
Response responseMetaData = (Response) metaData;
- int httpStatus = responseMetaData.getStatus();
- switch (httpStatus) {
+ contentType = responseMetaData.getFields().get(HttpHeader.CONTENT_TYPE).toLowerCase();
+ status = responseMetaData.getStatus();
+ switch (status) {
case HttpStatus.UNAUTHORIZED_401:
case HttpStatus.FORBIDDEN_403:
- handleHttp2Error(Http2Error.UNAUTHORIZED);
+ handleHttp2Error(Http2Error.UNAUTHORIZED, stream.getSession());
default:
}
- contentType = responseMetaData.getFields().get(HttpHeader.CONTENT_TYPE).toLowerCase();
}
}
}
@Override
public boolean onIdleTimeout(@Nullable Stream stream, @Nullable Throwable x) {
- handleHttp2Error(Http2Error.IDLE);
+ Objects.requireNonNull(stream);
+ handleHttp2Error(Http2Error.IDLE, stream.getSession());
return true;
}
@Override
public void onTimeout(@Nullable Stream stream, @Nullable Throwable x) {
- handleHttp2Error(Http2Error.TIMEOUT);
+ Objects.requireNonNull(stream);
+ handleHttp2Error(Http2Error.TIMEOUT, stream.getSession());
}
}
@Override
public void onClosed(@Nullable Stream stream) {
- handleHttp2Error(Http2Error.CLOSED);
+ Objects.requireNonNull(stream);
+ handleHttp2Error(Http2Error.CLOSED, stream.getSession());
}
@Override
@Override
public void onReset(@Nullable Stream stream, @Nullable ResetFrame frame) {
- handleHttp2Error(Http2Error.RESET);
+ Objects.requireNonNull(stream);
+ handleHttp2Error(Http2Error.RESET, stream.getSession());
}
}
@Override
public void onClose(@Nullable Session session, @Nullable GoAwayFrame frame) {
- fatalErrorDelayed(this, new Http2Exception(Http2Error.CLOSED));
+ Objects.requireNonNull(session);
+ fatalErrorDelayed(this, new Http2Exception(Http2Error.CLOSED), session);
}
@Override
public void onFailure(@Nullable Session session, @Nullable Throwable failure) {
- fatalErrorDelayed(this, new Http2Exception(Http2Error.FAILURE));
+ Objects.requireNonNull(session);
+ fatalErrorDelayed(this, new Http2Exception(Http2Error.FAILURE), session);
}
+ /**
+ * The Hue bridge uses the 'nginx' web server which sends HTTP2 GO_AWAY frames after a certain number (normally
+ * 999) of GET/PUT calls. This is normal behaviour so we just start a new thread to close and reopen the
+ * session.
+ */
@Override
public void onGoAway(@Nullable Session session, @Nullable GoAwayFrame frame) {
- fatalErrorDelayed(this, new Http2Exception(Http2Error.GO_AWAY));
+ Objects.requireNonNull(session);
+ if (http2Session == session) {
+ Thread recreateThread = new Thread(() -> recreateSession());
+ Clip2Bridge.this.recreateThread = recreateThread;
+ recreateThread.start();
+ }
}
@Override
@Override
public void onPing(@Nullable Session session, @Nullable PingFrame frame) {
- checkAliveOk();
- if (Objects.nonNull(session) && Objects.nonNull(frame) && !frame.isReply()) {
- session.ping(new PingFrame(true), Callback.NOOP);
+ Objects.requireNonNull(session);
+ Objects.requireNonNull(frame);
+ if (http2Session == session) {
+ checkAliveOk();
+ if (!frame.isReply()) {
+ session.ping(new PingFrame(true), Callback.NOOP);
+ }
}
}
@Override
public void onReset(@Nullable Session session, @Nullable ResetFrame frame) {
- fatalErrorDelayed(this, new Http2Exception(Http2Error.RESET));
+ Objects.requireNonNull(session);
+ fatalErrorDelayed(this, new Http2Exception(Http2Error.RESET), session);
+ }
+ }
+
+ /**
+ * Synchronizer for accessing the HTTP2 session object. This method wraps the 'sessionUseCreateLock' ReadWriteLock
+ * so that GET/PUT methods can access the session on multiple concurrent threads via the 'read' access lock, yet are
+ * forced to wait if the session is being created via its single thread access 'write' lock.
+ */
+ private class SessionSynchronizer implements AutoCloseable {
+ private final Optional<Lock> lockOptional;
+
+ SessionSynchronizer(boolean requireExclusiveAccess) throws InterruptedException {
+ Lock lock = requireExclusiveAccess ? sessionUseCreateLock.writeLock() : sessionUseCreateLock.readLock();
+ lockOptional = lock.tryLock(TIMEOUT_SECONDS, TimeUnit.SECONDS) ? Optional.of(lock) : Optional.empty();
+ }
+
+ @Override
+ public void close() {
+ lockOptional.ifPresent(lock -> lock.unlock());
}
}
private final String applicationKey;
private final Clip2BridgeHandler bridgeHandler;
private final Gson jsonParser = new Gson();
- private final Semaphore streamMutex = new Semaphore(MAX_CONCURRENT_STREAMS, true);
+ private final Semaphore streamMutex = new Semaphore(MAX_CONCURRENT_STREAMS, true); // i.e. fair
+ private final ReadWriteLock sessionUseCreateLock = new ReentrantReadWriteLock(true); // i.e. fair
+ private final Map<Integer, Future<?>> fatalErrorTasks = new ConcurrentHashMap<>();
+ private boolean recreatingSession;
private boolean closing;
private State onlineState = State.CLOSED;
private Optional<Instant> lastRequestTime = Optional.empty();
private Instant sessionExpireTime = Instant.MAX;
- private @Nullable Session http2Session;
+ private @Nullable Session http2Session;
+ private @Nullable Thread recreateThread;
private @Nullable Future<?> checkAliveTask;
- private Map<Integer, Future<?>> fatalErrorTasks = new ConcurrentHashMap<>();
/**
* Constructor.
http2Client = httpClientFactory.createHttp2Client("hue-clip2", httpClient.getSslContextFactory());
http2Client.setConnectTimeout(Clip2Bridge.TIMEOUT_SECONDS * 1000);
http2Client.setIdleTimeout(-1);
- try {
- http2Client.start();
- } catch (Exception e) {
- throw new ApiException("Error starting HTTP/2 client", e);
- }
+ startHttp2Client();
this.bridgeHandler = bridgeHandler;
this.hostName = hostName;
this.applicationKey = applicationKey;
Session session = http2Session;
if (Objects.nonNull(session)) {
session.ping(new PingFrame(false), Callback.NOOP);
- }
- if (Instant.now().isAfter(sessionExpireTime)) {
- fatalError(this, new Http2Exception(Http2Error.TIMEOUT));
+ if (Instant.now().isAfter(sessionExpireTime)) {
+ fatalError(this, new Http2Exception(Http2Error.TIMEOUT), session.hashCode());
+ }
}
}
@Override
public void close() {
closing = true;
+ Thread recreateThread = this.recreateThread;
+ if (Objects.nonNull(recreateThread) && recreateThread.isAlive()) {
+ recreateThread.interrupt();
+ }
close2();
try {
- http2Client.stop();
- } catch (Exception e) {
+ stopHttp2Client();
+ } catch (ApiException e) {
}
}
private void close2() {
synchronized (this) {
LOGGER.debug("close2()");
- boolean notifyHandler = onlineState == State.ACTIVE && !closing;
+ boolean notifyHandler = onlineState == State.ACTIVE && !closing && !recreatingSession;
onlineState = State.CLOSED;
synchronized (fatalErrorTasks) {
fatalErrorTasks.values().forEach(task -> cancelTask(task, true));
}
cancelTask(checkAliveTask, true);
checkAliveTask = null;
+ closeEventStream();
closeSession();
if (notifyHandler) {
bridgeHandler.onConnectionOffline();
}
}
+ /**
+ * Close the event stream(s) if necessary.
+ */
+ private void closeEventStream() {
+ Session session = http2Session;
+ if (Objects.nonNull(session)) {
+ final int sessionId = session.hashCode();
+ session.getStreams().stream().filter(s -> Objects.nonNull(s.getAttribute(EVENT_STREAM_ID)) && !s.isReset())
+ .forEach(s -> {
+ int streamId = s.getId();
+ LOGGER.debug("closeEventStream() sessionId:{}, streamId:{}", sessionId, streamId);
+ s.reset(new ResetFrame(streamId, ErrorCode.CANCEL_STREAM_ERROR.code), Callback.NOOP);
+ });
+ }
+ }
+
/**
* Close the HTTP 2 session if necessary.
*/
private void closeSession() {
- LOGGER.debug("closeSession()");
Session session = http2Session;
- if (Objects.nonNull(session) && !session.isClosed()) {
- session.close(0, null, Callback.NOOP);
+ if (Objects.nonNull(session)) {
+ LOGGER.debug("closeSession() sessionId:{}, openStreamCount:{}", session.hashCode(),
+ session.getStreams().size());
+ session.close(ErrorCode.NO_ERROR.code, "closeSession", Callback.NOOP);
}
http2Session = null;
}
/**
- * Method that is called back in case of fatal stream or session events. Note: under normal operation, the Hue
- * Bridge sends a 'soft' GO_AWAY command every nine or ten hours, so we handle such soft errors by attempting to
- * silently close and re-open the connection without notifying the handler of an actual 'hard' error.
+ * Close the given stream.
+ *
+ * @param stream to be closed.
+ */
+ private void closeStream(@Nullable Stream stream) {
+ if (Objects.nonNull(stream) && !stream.isReset()) {
+ stream.reset(new ResetFrame(stream.getId(), ErrorCode.NO_ERROR.code), Callback.NOOP);
+ }
+ }
+
+ /**
+ * Method that is called back in case of fatal stream or session events. The error is only processed if the
+ * connection is online, not in process of closing, and the identities of the current session and the session that
+ * caused the error are the same. In other words it ignores errors relating to expired sessions.
*
* @param listener the entity that caused this method to be called.
- * @param cause the exception that caused the error.
+ * @param cause the type of exception that caused the error.
+ * @param sessionId the identity of the session on which the error occurred.
*/
- private synchronized void fatalError(Object listener, Http2Exception cause) {
+ private synchronized void fatalError(Object listener, Http2Exception cause, int sessionId) {
if (onlineState == State.CLOSED || closing) {
return;
}
- String causeId = listener.getClass().getSimpleName();
+ Session session = http2Session;
+ if (Objects.isNull(session) || session.hashCode() != sessionId) {
+ return;
+ }
+ String listenerId = listener.getClass().getSimpleName();
if (listener instanceof ContentStreamListenerAdapter) {
// on GET / PUT requests the caller handles errors and closes the stream; the session is still OK
- LOGGER.debug("fatalError() {} {} ignoring", causeId, cause.error);
+ LOGGER.debug("fatalError() listener:{}, sessionId:{}, error:{} => ignoring", listenerId, sessionId,
+ cause.error);
} else {
if (LOGGER.isDebugEnabled()) {
- LOGGER.debug("fatalError() {} {} closing", causeId, cause.error, cause);
+ LOGGER.debug("fatalError() listener:{}, sessionId:{}, error:{} => closing", listenerId, sessionId,
+ cause.error, cause);
} else {
- LOGGER.warn("Fatal error {} {} => closing session.", causeId, cause.error);
+ LOGGER.warn("Fatal error '{}' from '{}' => closing session.", cause.error, listenerId);
}
close2();
}
* delay in order to prevent sequencing issues.
*
* @param listener the entity that caused this method to be called.
- * @param cause the exception that caused the error.
+ * @param cause the type of exception that caused the error.
+ * @param session the session on which the error occurred.
*/
- protected void fatalErrorDelayed(Object listener, Http2Exception cause) {
+ protected void fatalErrorDelayed(Object listener, Http2Exception cause, Session session) {
synchronized (fatalErrorTasks) {
final int index = fatalErrorTasks.size();
+ final int sessionId = session.hashCode();
fatalErrorTasks.put(index, bridgeHandler.getScheduler().schedule(() -> {
- fatalError(listener, cause);
+ fatalError(listener, cause, sessionId);
fatalErrorTasks.remove(index);
}, 1, TimeUnit.SECONDS));
}
* @throws InterruptedException
*/
public Resources getResources(ResourceReference reference) throws ApiException, InterruptedException {
- if (onlineState == State.CLOSED) {
- throw new ApiException("getResources() offline");
+ if (onlineState == State.CLOSED && !recreatingSession) {
+ throw new ApiException("Connection is closed");
}
return getResourcesImpl(reference);
}
/**
- * Internal method to send an HTTP 2 GET request to the Hue Bridge and process its response.
+ * Internal method to send an HTTP 2 GET request to the Hue Bridge and process its response. Uses a Throttler to
+ * prevent too many concurrent calls, and to prevent too frequent calls on the Hue bridge server. Also uses a
+ * SessionSynchronizer to delay accessing the session while it is being recreated.
*
* @param reference the Reference class to get.
* @return a Resource object containing either a list of Resources or a list of Errors.
*/
private Resources getResourcesImpl(ResourceReference reference)
throws HttpUnauthorizedException, ApiException, InterruptedException {
- Session session = http2Session;
- if (Objects.isNull(session) || session.isClosed()) {
- throw new ApiException("HTTP 2 session is null or closed");
+ // work around for issue #15468 (and similar)
+ ResourceType resourceType = reference.getType();
+ if (resourceType == ResourceType.ERROR) {
+ LOGGER.warn("Resource '{}' type '{}' unknown => GET aborted", reference.getId(), resourceType);
+ return new Resources();
}
- try (Throttler throttler = new Throttler(1)) {
+ Stream stream = null;
+ try (Throttler throttler = new Throttler(1);
+ SessionSynchronizer sessionSynchronizer = new SessionSynchronizer(false)) {
+ Session session = getSession();
String url = getUrl(reference);
LOGGER.trace("GET {} HTTP/2", url);
HeadersFrame headers = prepareHeaders(url, MediaType.APPLICATION_JSON);
ContentStreamListenerAdapter contentStreamListener = new ContentStreamListenerAdapter();
session.newStream(headers, streamPromise, contentStreamListener);
// wait for stream to be opened
- Objects.requireNonNull(streamPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ stream = Objects.requireNonNull(streamPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
// wait for HTTP response contents
String contentJson = contentStreamListener.awaitResult();
String contentType = contentStreamListener.getContentType();
- LOGGER.trace("HTTP/2 200 OK (Content-Type: {}) << {}", contentType, contentJson);
+ int status = contentStreamListener.getStatus();
+ LOGGER.trace("HTTP/2 {} (Content-Type: {}) << {}", status, contentType, contentJson);
+ if (status != HttpStatus.OK_200) {
+ throw new ApiException(String.format("Unexpected HTTP status '%d'", status));
+ }
if (!MediaType.APPLICATION_JSON.equals(contentType)) {
throw new ApiException("Unexpected Content-Type: " + contentType);
}
throw new ApiException("Error sending request", e);
} catch (TimeoutException e) {
throw new ApiException("Error sending request", e);
+ } finally {
+ closeStream(stream);
}
}
+ /**
+ * Safe access to the session object.
+ *
+ * @return the session.
+ * @throws ApiException if session is null or closed.
+ */
+ private Session getSession() throws ApiException {
+ Session session = http2Session;
+ if (Objects.isNull(session) || session.isClosed()) {
+ throw new ApiException("HTTP/2 session is null or closed");
+ }
+ return session;
+ }
+
/**
* Build a full path to a server end point, based on a Reference class instance. If the reference contains only
* a resource type, the method returns the end point url to get all resources of the given resource type. Whereas if
* @param data the incoming (presumed to be JSON) text.
*/
protected void onEventData(String data) {
- if (onlineState != State.ACTIVE) {
+ if (onlineState != State.ACTIVE && !recreatingSession) {
return;
}
if (LOGGER.isTraceEnabled()) {
* @throws InterruptedException
*/
private void openEventStream() throws ApiException, InterruptedException {
- Session session = http2Session;
- if (Objects.isNull(session) || session.isClosed()) {
- throw new ApiException("HTTP 2 session is null or closed");
- }
+ Session session = getSession();
if (session.getStreams().stream().anyMatch(stream -> Objects.nonNull(stream.getAttribute(EVENT_STREAM_ID)))) {
return;
}
- LOGGER.debug("openEventStream()");
- HeadersFrame headers = prepareHeaders(eventUrl, MediaType.SERVER_SENT_EVENTS);
LOGGER.trace("GET {} HTTP/2", eventUrl);
Stream stream = null;
try {
+ HeadersFrame headers = prepareHeaders(eventUrl, MediaType.SERVER_SENT_EVENTS);
Completable<@Nullable Stream> streamPromise = new Completable<>();
EventStreamListenerAdapter eventStreamListener = new EventStreamListenerAdapter();
session.newStream(headers, streamPromise, eventStreamListener);
stream.setAttribute(EVENT_STREAM_ID, session);
// wait for "hi" from the bridge
eventStreamListener.awaitResult();
+ LOGGER.debug("openEventStream() sessionId:{} streamId:{}", session.hashCode(), stream.getId());
} catch (ExecutionException | TimeoutException e) {
- if (Objects.nonNull(stream)) {
- stream.reset(new ResetFrame(stream.getId(), 0), Callback.NOOP);
+ if (Objects.nonNull(stream) && !stream.isReset()) {
+ stream.reset(new ResetFrame(stream.getId(), ErrorCode.HTTP_CONNECT_ERROR.code), Callback.NOOP);
}
throw new ApiException("Error opening event stream", e);
}
if (Objects.nonNull(session) && !session.isClosed()) {
return;
}
- LOGGER.debug("openSession()");
- InetSocketAddress address = new InetSocketAddress(hostName, 443);
try {
+ InetSocketAddress address = new InetSocketAddress(hostName, 443);
SessionListenerAdapter sessionListener = new SessionListenerAdapter();
Completable<@Nullable Session> sessionPromise = new Completable<>();
http2Client.connect(http2Client.getBean(SslContextFactory.class), address, sessionListener, sessionPromise);
// wait for the (SSL) session to be opened
- http2Session = Objects.requireNonNull(sessionPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ session = Objects.requireNonNull(sessionPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ LOGGER.debug("openSession() sessionId:{}", session.hashCode());
+ http2Session = session;
checkAliveOk(); // initialise the session timeout window
} catch (ExecutionException | TimeoutException e) {
- throw new ApiException("Error opening HTTP 2 session", e);
+ throw new ApiException("Error opening HTTP/2 session", e);
}
}
}
/**
- * Use an HTTP/2 PUT command to send a resource to the server. Note: the Hue Bridge can get confused by parallel
- * overlapping PUT resp. GET commands which cause it to respond with an HTML error page. So this method acquires all
- * of the stream access permits (given by MAX_CONCURRENT_STREAMS) in order to prevent such overlaps.
+ * Use an HTTP/2 PUT command to send a resource to the server. Uses a Throttler to prevent too many concurrent
+ * calls, and to prevent too frequent calls on the Hue bridge server. Also uses a SessionSynchronizer to delay
+ * accessing the session while it is being recreated.
*
* @param resource the resource to put.
* @throws ApiException if something fails.
* @throws InterruptedException
*/
public void putResource(Resource resource) throws ApiException, InterruptedException {
- if (onlineState == State.CLOSED) {
- return;
- }
- Session session = http2Session;
- if (Objects.isNull(session) || session.isClosed()) {
- throw new ApiException("HTTP 2 session is null or closed");
- }
- try (Throttler throttler = new Throttler(MAX_CONCURRENT_STREAMS)) {
+ Stream stream = null;
+ try (Throttler throttler = new Throttler(MAX_CONCURRENT_STREAMS);
+ SessionSynchronizer sessionSynchronizer = new SessionSynchronizer(false)) {
+ Session session = getSession();
String requestJson = jsonParser.toJson(resource);
ByteBuffer requestBytes = ByteBuffer.wrap(requestJson.getBytes(StandardCharsets.UTF_8));
String url = getUrl(new ResourceReference().setId(resource.getId()).setType(resource.getType()));
ContentStreamListenerAdapter contentStreamListener = new ContentStreamListenerAdapter();
session.newStream(headers, streamPromise, contentStreamListener);
// wait for stream to be opened
- Stream stream = Objects.requireNonNull(streamPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ stream = Objects.requireNonNull(streamPromise.get(TIMEOUT_SECONDS, TimeUnit.SECONDS));
stream.data(new DataFrame(stream.getId(), requestBytes, true), Callback.NOOP);
// wait for HTTP response
String contentJson = contentStreamListener.awaitResult();
String contentType = contentStreamListener.getContentType();
- LOGGER.trace("HTTP/2 200 OK (Content-Type: {}) << {}", contentType, contentJson);
+ int status = contentStreamListener.getStatus();
+ LOGGER.trace("HTTP/2 {} (Content-Type: {}) << {}", status, contentType, contentJson);
+ if (status != HttpStatus.OK_200) {
+ throw new ApiException(String.format("Unexpected HTTP status '%d'", status));
+ }
if (!MediaType.APPLICATION_JSON.equals(contentType)) {
throw new ApiException("Unexpected Content-Type: " + contentType);
}
throw new ApiException("Parsing error", e);
}
} catch (ExecutionException | TimeoutException e) {
- throw new ApiException("putResource() error sending request", e);
+ throw new ApiException("Error sending PUT request", e);
+ } finally {
+ closeStream(stream);
+ }
+ }
+
+ /**
+ * Close and re-open the session. Called when the server sends a GO_AWAY message. Acquires a SessionSynchronizer
+ * 'write' lock to ensure single thread access while the new session is being created. Therefore it waits for any
+ * already running GET/PUT method calls, which have a 'read' lock, to complete. And also causes any new GET/PUT
+ * method calls to wait until this method releases the 'write' lock again. Whereby such GET/PUT calls are postponed
+ * to the new session.
+ */
+ private synchronized void recreateSession() {
+ try (SessionSynchronizer sessionSynchronizer = new SessionSynchronizer(true)) {
+ LOGGER.debug("recreateSession()");
+ recreatingSession = true;
+ State onlineState = this.onlineState;
+ close2();
+ stopHttp2Client();
+ //
+ startHttp2Client();
+ openPassive();
+ if (onlineState == State.ACTIVE) {
+ openActive();
+ }
+ } catch (ApiException | InterruptedException e) {
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("recreateSession() exception", e);
+ } else {
+ LOGGER.warn("recreateSession() {}: {}", e.getClass().getSimpleName(), e.getMessage());
+ }
+ } finally {
+ recreatingSession = false;
+ LOGGER.debug("recreateSession() done");
}
}
json = contentResponse.getContentAsString().trim();
LOGGER.trace("HTTP/1.1 {} {} << {}", httpStatus, contentResponse.getReason(), json);
if (httpStatus != HttpStatus.OK_200) {
- throw new ApiException("HTTP bad response");
+ throw new ApiException(String.format("HTTP bad response '%d'", httpStatus));
}
try {
List<SuccessResponse> entries = jsonParser.fromJson(json, SuccessResponse.GSON_TYPE);
throw new HttpUnauthorizedException("Application key registration failed");
}
+ private void startHttp2Client() throws ApiException {
+ try {
+ http2Client.start();
+ } catch (Exception e) {
+ throw new ApiException("Error starting HTTP/2 client", e);
+ }
+ }
+
+ private void stopHttp2Client() throws ApiException {
+ try {
+ http2Client.stop();
+ } catch (Exception e) {
+ throw new ApiException("Error stopping HTTP/2 client", e);
+ }
+ }
+
/**
* Test the Hue Bridge connection state by attempting to connect and trying to execute a basic command that requires
* authentication.