]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschshc] Handle invalid long poll responses gracefully (#16002)
authorDavid Pace <dev@davidpace.de>
Thu, 4 Jan 2024 07:19:33 +0000 (08:19 +0100)
committerGitHub <noreply@github.com>
Thu, 4 Jan 2024 07:19:33 +0000 (08:19 +0100)
If the long poll response from the Smart Home Controller does not
contain valid JSON, the subscription is gracefully terminated a new one
is initiated after 15 seconds.

closes #15912

Signed-off-by: David Pace <dev@davidpace.de>
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/LongPolling.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/LongPollingTest.java

index fc6ae008fc709829cccb7d1d5bd7c3e2c131f38f..7924702d6be13ca43815e2943bfebbe47110e471 100644 (file)
@@ -34,6 +34,8 @@ import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.google.gson.JsonSyntaxException;
+
 /**
  * Handles the long polling to the Smart Home Controller.
  *
@@ -141,7 +143,8 @@ public class LongPolling {
     }
 
     /**
-     * Start long polling the home controller. Once a long poll resolves, a new one is started.
+     * Start long polling the home controller. Once a long poll resolves, a new one
+     * is started.
      */
     private void longPoll(BoschHttpClient httpClient, String subscriptionId) {
         logger.debug("Sending long poll request");
@@ -150,8 +153,9 @@ public class LongPolling {
         String url = httpClient.getBoschShcUrl("remote/json-rpc");
         Request longPollRequest = httpClient.createRequest(url, POST, requestContent);
 
-        // Long polling responds after 20 seconds with an empty response if no update has happened.
-        // 10 second threshold was added to not time out if response from controller takes a bit longer than 20 seconds.
+        // Long polling responds after 20 seconds with an empty response if no update
+        // has happened. 10 second threshold was added to not time out if response
+        // from controller takes a bit longer than 20 seconds.
         longPollRequest.timeout(30, TimeUnit.SECONDS);
 
         this.request = longPollRequest;
@@ -159,8 +163,9 @@ public class LongPolling {
         longPollRequest.send(new BufferingResponseListener() {
             @Override
             public void onComplete(@Nullable Result result) {
-                // NOTE: This handler runs inside the HTTP thread, so we schedule the response handling in a new thread
-                // because the HTTP thread is terminated after the timeout expires.
+                // NOTE: This handler runs inside the HTTP thread, so we schedule the response
+                // handling in a new thread because the HTTP thread is terminated after the
+                // timeout expires.
                 scheduler.execute(() -> longPolling.onLongPollComplete(httpClient, subscriptionId, result,
                         this.getContentAsString()));
             }
@@ -188,10 +193,28 @@ public class LongPolling {
         if (failure != null) {
             handleLongPollFailure(subscriptionId, failure);
         } else {
-            logger.debug("Long poll response: {}", content);
+            handleLongPollResponse(httpClient, subscriptionId, content);
+        }
+    }
 
-            String nextSubscriptionId = subscriptionId;
+    /**
+     * Attempts to parse and process the long poll response content.
+     * <p>
+     * If the response cannot be parsed as {@link LongPollResult}, an attempt is made to parse a {@link LongPollError}.
+     * In case a {@link LongPollError} is present with the code <code>SUBSCRIPTION_INVALID</code>, a re-subscription is
+     * initiated.
+     * <p>
+     * If the response does not contain syntactically valid JSON, a new subscription is attempted with a delay of 15
+     * seconds.
+     * 
+     * @param httpClient HTTP client which received the response
+     * @param subscriptionId Id of subscription the response is for
+     * @param content Content of the response
+     */
+    private void handleLongPollResponse(BoschHttpClient httpClient, String subscriptionId, String content) {
+        logger.debug("Long poll response: {}", content);
 
+        try {
             LongPollResult longPollResult = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content, LongPollResult.class);
             if (longPollResult != null && longPollResult.result != null) {
                 this.handleResult.accept(longPollResult);
@@ -212,10 +235,14 @@ public class LongPolling {
                     }
                 }
             }
-
-            // Execute next run
-            this.longPoll(httpClient, nextSubscriptionId);
+        } catch (JsonSyntaxException e) {
+            this.handleFailure.accept(
+                    new LongPollingFailedException("Could not deserialize long poll response: '" + content + "'", e));
+            return;
         }
+
+        // Execute next run
+        this.longPoll(httpClient, subscriptionId);
     }
 
     private void handleLongPollFailure(String subscriptionId, Throwable failure) {
index 4db50f71c3c0ed0f0e24226193faa3dcde17cfb9..7b4ea728883a16b42114b95464ab396193dc9c48 100644 (file)
@@ -55,6 +55,7 @@ import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
 
 import com.google.gson.JsonObject;
+import com.google.gson.JsonSyntaxException;
 
 /**
  * Unit tests for {@link LongPolling}.
@@ -405,6 +406,50 @@ class LongPollingTest {
         bufferingResponseListener.onComplete(result);
     }
 
+    /**
+     * Tests a case in which the Smart Home Controller returns a HTML error response that is not parsable as JSON.
+     * <p>
+     * See <a href="https://github.com/openhab/openhab-addons/issues/15912">Issue 15912</a>
+     */
+    @Test
+    void startLongPollingInvalidLongPollResponse()
+            throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
+        when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
+
+        Request subscribeRequest = mock(Request.class);
+        when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
+                argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
+        SubscribeResult subscribeResult = new SubscribeResult();
+        when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
+
+        Request longPollRequest = mock(Request.class);
+        when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
+                argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
+
+        fixture.start(httpClient);
+
+        ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
+        verify(longPollRequest).send(completeListener.capture());
+
+        BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
+
+        String longPollResultContent = "<HTML><HEAD><TITLE>400</TITLE></HEAD><BODY><H1>400 Unsupported HTTP Protocol Version: /remote/json-rpcHTTP/1.1</H1></BODY></HTML>";
+        Response response = mock(Response.class);
+        bufferingResponseListener.onContent(response,
+                ByteBuffer.wrap(longPollResultContent.getBytes(StandardCharsets.UTF_8)));
+
+        Result result = mock(Result.class);
+        bufferingResponseListener.onComplete(result);
+
+        ArgumentCaptor<Throwable> throwableCaptor = ArgumentCaptor.forClass(Throwable.class);
+        verify(failureHandler).accept(throwableCaptor.capture());
+        Throwable t = throwableCaptor.getValue();
+        assertEquals(
+                "Could not deserialize long poll response: '<HTML><HEAD><TITLE>400</TITLE></HEAD><BODY><H1>400 Unsupported HTTP Protocol Version: /remote/json-rpcHTTP/1.1</H1></BODY></HTML>'",
+                t.getMessage());
+        assertTrue(t.getCause() instanceof JsonSyntaxException);
+    }
+
     @AfterEach
     void afterEach() {
         fixture.stop();