Skip to content
This repository has been archived by the owner on Sep 21, 2020. It is now read-only.

Commit

Permalink
[Nest] Reopen new EventSource on disconnection and exception handling…
Browse files Browse the repository at this point in the history
… improvements (#2940)

* Reopen EventSource on disconnects
* Properly cache redirect URL for PUT requests to reduce going offline due to FailedResolvingNestUrlExceptions
* Increase PUT timeout from 5 to 30 seconds to reduce going offline due to FailedSendingNestDataExceptions
* Schedule reconnect when offline to get online again
* Schedule job for sending pending requests when going online
* Introduce NestRedirectUrlSupplier for resolving redirect URLs
* Reset cached redirect URL on Exceptions that may be caused by an offline host
* Don't reopen EventSource when it is already being reopened
* Fix potential NPE when updating configuration when redirectUrlSupplier is null
* Schedule initialize job to fix warnings on initialize which may take more than 5 seconds
* Cancel scheduled jobs in dispose

Fixes #2845

Signed-off-by: Wouter Born <eclipse@maindrain.net>
  • Loading branch information
wborn authored and martinvw committed Dec 16, 2017
1 parent ee9ea5b commit 61318b3
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 102 deletions.
Expand Up @@ -25,8 +25,6 @@
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.openhab.binding.nest.internal.config.NestBridgeConfiguration;
import org.openhab.binding.nest.internal.exceptions.FailedResolvingNestUrlException;
import org.openhab.binding.nest.internal.exceptions.InvalidAccessTokenException;

/**
* Tests cases for {@link NestBridgeHandler}.
Expand All @@ -46,15 +44,18 @@ public class NestBridgeHandlerTest {
@Mock
private Configuration configuration;

@Mock
private NestRedirectUrlSupplier redirectUrlSupplier;

@Before
public void setUp() {
initMocks(this);

handler = new NestBridgeHandler(bridge) {
@Override
protected String getOrResolveRedirectUrl()
throws FailedResolvingNestUrlException, InvalidAccessTokenException {
protected NestRedirectUrlSupplier getRedirectUrlSupplier() {
// we don't want to put extra load on real Nest servers when running unit tests
return "https://localhost";
return redirectUrlSupplier;
}
};
handler.setCallback(callback);
Expand Down
Expand Up @@ -22,13 +22,6 @@
import java.util.concurrent.TimeUnit;

import org.apache.commons.lang.StringUtils;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.eclipse.smarthome.config.core.Configuration;
import org.eclipse.smarthome.core.thing.Bridge;
import org.eclipse.smarthome.core.thing.ChannelUID;
Expand Down Expand Up @@ -68,6 +61,8 @@
* @author Wouter Born - Improve exception and URL redirect handling
*/
public class NestBridgeHandler extends BaseBridgeHandler implements NestStreamingDataListener {
private static final int REQUEST_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(30);

private final Logger logger = LoggerFactory.getLogger(NestBridgeHandler.class);

private final List<NestDeviceDataListener> listeners = new CopyOnWriteArrayList<>();
Expand All @@ -76,8 +71,9 @@ public class NestBridgeHandler extends BaseBridgeHandler implements NestStreamin

private NestAuthorizer authorizer;
private NestBridgeConfiguration config;
private ScheduledFuture<?> initializeJob;
private ScheduledFuture<?> transmitJob;
private String redirectUrl;
private NestRedirectUrlSupplier redirectUrlSupplier;
private NestStreamingRestClient streamingRestClient;

/**
Expand All @@ -98,52 +94,46 @@ public void initialize() {

config = getConfigAs(NestBridgeConfiguration.class);
authorizer = new NestAuthorizer(config);
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Starting poll query");

logger.debug("Product ID {}", config.productId);
logger.debug("Product Secret {}", config.productSecret);
logger.debug("Pincode {}", config.pincode);

try {
logger.debug("Access Token {}", getExistingOrNewAccessToken());
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "Starting poll query");
} catch (InvalidAccessTokenException e) {
logger.debug("Invalid access token", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Token is invalid and could not be refreshed: " + e.getMessage());
}

restartStreamingUpdates();
initializeJob = scheduler.schedule(() -> {
try {
logger.debug("Product ID {}", config.productId);
logger.debug("Product Secret {}", config.productSecret);
logger.debug("Pincode {}", config.pincode);
logger.debug("Access Token {}", getExistingOrNewAccessToken());
redirectUrlSupplier = new NestRedirectUrlSupplier(getHttpHeaders());
restartStreamingUpdates();
} catch (InvalidAccessTokenException e) {
logger.debug("Invalid access token", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Token is invalid and could not be refreshed: " + e.getMessage());
}
}, 0, TimeUnit.SECONDS);

logger.debug("Finished initializing Nest bridge handler");
}

/**
* Do something useful when the configuration update happens. Triggers changing
* polling intervals as well as re-doing the access token.
* Allows the NestRedirectUrlSupplier to be overriden in tests.
*
* @return the NestRedirectUrlSupplier
*/
@Override
public void updateConfiguration(Configuration configuration) {
logger.debug("Config update");
super.updateConfiguration(configuration);
restartStreamingUpdates();
protected NestRedirectUrlSupplier getRedirectUrlSupplier() {
return this.redirectUrlSupplier;
}

private void startStreamingUpdates() {
synchronized (this) {
try {
streamingRestClient = new NestStreamingRestClient(getExistingOrNewAccessToken(),
getOrResolveRedirectUrl(), scheduler);
getRedirectUrlSupplier(), scheduler);
streamingRestClient.addStreamingDataListener(this);
streamingRestClient.start();
} catch (InvalidAccessTokenException e) {
logger.debug("Invalid access token", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Token is invalid and could not be refreshed: " + e.getMessage());
} catch (FailedResolvingNestUrlException e) {
logger.debug("Unable to resolve redirect URL", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
logger.debug("Reattempting to resolve redirect URL in 5 seconds");
scheduler.schedule(this::startStreamingUpdates, 5, SECONDS);
}
}
}
Expand Down Expand Up @@ -172,8 +162,20 @@ private void restartStreamingUpdates() {
public void dispose() {
logger.debug("Nest bridge disposed");
stopStreamingUpdates();

if (initializeJob != null && !initializeJob.isCancelled()) {
initializeJob.cancel(true);
initializeJob = null;
}

if (transmitJob != null && !transmitJob.isCancelled()) {
transmitJob.cancel(true);
transmitJob = null;
}

this.authorizer = null;
this.redirectUrl = null;
this.redirectUrlSupplier = null;
this.streamingRestClient = null;
}

/**
Expand Down Expand Up @@ -280,7 +282,11 @@ public boolean removeDeviceDataListener(NestDeviceDataListener listener) {
*/
void addUpdateRequest(NestUpdateRequest request) {
nestUpdateRequests.add(request);
if (transmitJob == null || transmitJob.isDone()) {
scheduleTransmitJobForPendingRequests();
}

private void scheduleTransmitJobForPendingRequests() {
if (!nestUpdateRequests.isEmpty() && (transmitJob == null || transmitJob.isDone())) {
transmitJob = scheduler.schedule(this::transmitQueue, 0, SECONDS);
}
}
Expand All @@ -306,24 +312,28 @@ private void transmitQueue() {
} catch (FailedResolvingNestUrlException e) {
logger.debug("Unable to resolve redirect URL", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
} catch (FailedSendingNestDataException e) {
logger.debug("Error sending data", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
scheduler.schedule(this::restartStreamingUpdates, 5, SECONDS);
getRedirectUrlSupplier().resetCache();
}
}

private void jsonToPutUrl(NestUpdateRequest request)
throws FailedSendingNestDataException, InvalidAccessTokenException, FailedResolvingNestUrlException {
try {
String url = request.getUpdateUrl().replaceFirst(NestBindingConstants.NEST_URL, getOrResolveRedirectUrl());
String url = request.getUpdateUrl().replaceFirst(NestBindingConstants.NEST_URL,
getRedirectUrlSupplier().getRedirectUrl());
logger.debug("Putting data to: {}", url);

String jsonContent = gson.toJson(request.getValues());
logger.debug("PUT content: {}", jsonContent);

ByteArrayInputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8));
String jsonResponse = HttpUtil.executeUrl("PUT", url, getHttpHeaders(), inputStream, JSON_CONTENT_TYPE,
5000);
REQUEST_TIMEOUT);
logger.debug("PUT response: {}", jsonResponse);

ErrorData error = gson.fromJson(jsonResponse, ErrorData.class);
Expand All @@ -343,57 +353,6 @@ private Properties getHttpHeaders() throws InvalidAccessTokenException {
return httpHeaders;
}

protected String getOrResolveRedirectUrl() throws FailedResolvingNestUrlException, InvalidAccessTokenException {
return redirectUrl != null ? redirectUrl : resolveRedirectUrl();
}

/**
* Resolves the redirect URL for calls using the {@link NestBindingConstants#NEST_URL}.
*
* The Jetty client used by {@link HttpUtil} will not pass the Authorization header after a redirect resulting in
* "401 Unauthorized error" issues.
*
* Note that this workaround currently does not use any configured proxy like {@link HttpUtil} does.
*
* @see https://developers.nest.com/documentation/cloud/how-to-handle-redirects
*/
private String resolveRedirectUrl() throws FailedResolvingNestUrlException, InvalidAccessTokenException {
HttpClient httpClient = new HttpClient(new SslContextFactory());
httpClient.setFollowRedirects(false);

Request request = httpClient.newRequest(NestBindingConstants.NEST_URL).method(HttpMethod.GET).timeout(5,
TimeUnit.SECONDS);
Properties httpHeaders = getHttpHeaders();
for (String httpHeaderKey : httpHeaders.stringPropertyNames()) {
request.header(httpHeaderKey, httpHeaders.getProperty(httpHeaderKey));
}

ContentResponse response;
try {
httpClient.start();
response = request.send();
httpClient.stop();
} catch (Exception e) {
throw new FailedResolvingNestUrlException("Failed to resolve redirect URL: " + e.getMessage(), e);
}

int status = response.getStatus();
String redirectUrl = response.getHeaders().get(HttpHeader.LOCATION);

if (status != HttpStatus.TEMPORARY_REDIRECT_307) {
logger.debug("Redirect status: {}", status);
logger.debug("Redirect response: {}", response.getContentAsString());
throw new FailedResolvingNestUrlException("Failed to get redirect URL, expected status "
+ HttpStatus.TEMPORARY_REDIRECT_307 + " but was " + status);
} else if (StringUtils.isEmpty(redirectUrl)) {
throw new FailedResolvingNestUrlException("Redirect URL is empty");
}

redirectUrl = redirectUrl.endsWith("/") ? redirectUrl.substring(0, redirectUrl.length() - 1) : redirectUrl;
logger.debug("Redirect URL: {}", redirectUrl);
return redirectUrl;
}

/**
* Called to start the discovery scan. Forces a data refresh.
*/
Expand All @@ -410,6 +369,7 @@ public void onAuthorizationRevoked(String token) {
@Override
public void onConnected() {
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Streaming data connection established");
scheduleTransmitJobForPendingRequests();
}

@Override
Expand Down
@@ -0,0 +1,104 @@
/**
* Copyright (c) 2010-2017 by the respective copyright holders.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*/
package org.openhab.binding.nest.handler;

import java.util.Properties;
import java.util.concurrent.TimeUnit;

import org.apache.commons.lang.StringUtils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.eclipse.smarthome.io.net.http.HttpUtil;
import org.openhab.binding.nest.NestBindingConstants;
import org.openhab.binding.nest.internal.exceptions.FailedResolvingNestUrlException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Supplies resolved redirect URLs of {@link NestBindingConstants#NEST_URL} so they can be used with HTTP clients that
* do not pass Authorization headers after redirects like the Jetty client used by {@link HttpUtil}.
*
* @author Wouter Born - Extract resolving redirect URL from NestBridgeHandler into NestRedirectUrlSupplier
*/
@NonNullByDefault
public class NestRedirectUrlSupplier {

private final Logger logger = LoggerFactory.getLogger(NestRedirectUrlSupplier.class);

private String cachedUrl = "";

private Properties httpHeaders;

NestRedirectUrlSupplier(Properties httpHeaders) {
this.httpHeaders = httpHeaders;
}

public String getRedirectUrl() throws FailedResolvingNestUrlException {
if (cachedUrl.isEmpty()) {
cachedUrl = resolveRedirectUrl();
}
return cachedUrl;
}

public void resetCache() {
cachedUrl = "";
}

/**
* Resolves the redirect URL for calls using the {@link NestBindingConstants#NEST_URL}.
*
* The Jetty client used by {@link HttpUtil} will not pass the Authorization header after a redirect resulting in
* "401 Unauthorized error" issues.
*
* Note that this workaround currently does not use any configured proxy like {@link HttpUtil} does.
*
* @see https://developers.nest.com/documentation/cloud/how-to-handle-redirects
*/
private String resolveRedirectUrl() throws FailedResolvingNestUrlException {
HttpClient httpClient = new HttpClient(new SslContextFactory());
httpClient.setFollowRedirects(false);

Request request = httpClient.newRequest(NestBindingConstants.NEST_URL).method(HttpMethod.GET).timeout(30,
TimeUnit.SECONDS);
for (String httpHeaderKey : httpHeaders.stringPropertyNames()) {
request.header(httpHeaderKey, httpHeaders.getProperty(httpHeaderKey));
}

ContentResponse response;
try {
httpClient.start();
response = request.send();
httpClient.stop();
} catch (Exception e) {
throw new FailedResolvingNestUrlException("Failed to resolve redirect URL: " + e.getMessage(), e);
}

int status = response.getStatus();
String redirectUrl = response.getHeaders().get(HttpHeader.LOCATION);

if (status != HttpStatus.TEMPORARY_REDIRECT_307) {
logger.debug("Redirect status: {}", status);
logger.debug("Redirect response: {}", response.getContentAsString());
throw new FailedResolvingNestUrlException("Failed to get redirect URL, expected status "
+ HttpStatus.TEMPORARY_REDIRECT_307 + " but was " + status);
} else if (StringUtils.isEmpty(redirectUrl)) {
throw new FailedResolvingNestUrlException("Redirect URL is empty");
}

redirectUrl = redirectUrl.endsWith("/") ? redirectUrl.substring(0, redirectUrl.length() - 1) : redirectUrl;
logger.debug("Redirect URL: {}", redirectUrl);
return redirectUrl;
}
}

0 comments on commit 61318b3

Please sign in to comment.