Skip to content

Commit 4d2efd7

Browse files
authored
[netatmo] Keep HTTP status and raw error code for unclassified errors (#21429)
* [netatmo] Keep HTTP status and raw error code for unclassified errors Signed-off-by: Martin Littkovsky <2018turtle@proton.me>
1 parent b1688ae commit 4d2efd7

4 files changed

Lines changed: 325 additions & 7 deletions

File tree

bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/NetatmoException.java

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@
2222
* An exception that occurred while communicating with Netatmo server or related processes.
2323
*
2424
* @author Gaël L'hopital - Initial contribution
25+
* @author Martin Littkovsky - Keep HTTP status and raw error code for unclassified errors
2526
*/
2627
@NonNullByDefault
2728
public class NetatmoException extends IOException {
2829
private static final long serialVersionUID = 1513549973502021727L;
2930
private ServiceError statusCode = ServiceError.UNKNOWN;
31+
private int httpStatus = -1;
32+
private @Nullable String rawErrorCode;
3033

3134
public NetatmoException(String format, Object... args) {
3235
super(format.formatted(args));
@@ -45,15 +48,35 @@ public NetatmoException(ApiError error) {
4548
this.statusCode = error.getCode();
4649
}
4750

51+
/**
52+
* Additionally keeps the HTTP status and raw error code, used by {@link #getMessage()} only when {@code error}
53+
* does not classify into a known {@link ServiceError}.
54+
*/
55+
public NetatmoException(ApiError error, int httpStatus, @Nullable String rawErrorCode) {
56+
this(error);
57+
this.httpStatus = httpStatus;
58+
this.rawErrorCode = rawErrorCode;
59+
}
60+
4861
public ServiceError getStatusCode() {
4962
return statusCode;
5063
}
5164

5265
@Override
5366
public @Nullable String getMessage() {
5467
String message = super.getMessage();
55-
return message == null ? null
56-
: ServiceError.UNKNOWN.equals(statusCode) ? message
57-
: "Rest call failed: statusCode=%s, message=%s".formatted(statusCode, message);
68+
if (message == null) {
69+
return null;
70+
}
71+
if (!ServiceError.UNKNOWN.equals(statusCode)) {
72+
return "Rest call failed: statusCode=%s, message=%s".formatted(statusCode, message);
73+
}
74+
if (httpStatus <= 0) {
75+
return message;
76+
}
77+
String rawErrorCode = this.rawErrorCode;
78+
String suffix = "(HTTP %s%s)".formatted(Integer.toString(httpStatus),
79+
rawErrorCode == null ? "" : ", error code %s".formatted(rawErrorCode));
80+
return message.isEmpty() ? suffix : "%s %s".formatted(message, suffix);
5881
}
5982
}

bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,16 @@
9494
import org.slf4j.LoggerFactory;
9595

9696
import com.google.gson.GsonBuilder;
97+
import com.google.gson.JsonElement;
98+
import com.google.gson.JsonParseException;
99+
import com.google.gson.JsonParser;
97100

98101
/**
99102
* {@link ApiBridgeHandler} is the handler for a Netatmo API and connects it to the framework.
100103
*
101104
* @author Gaël L'hopital - Initial contribution
102105
* @author Jacob Laursen - Refactored to use standard OAuth2 implementation
106+
* @author Martin Littkovsky - Keep HTTP status and raw error code for unclassified errors
103107
*/
104108
@NonNullByDefault
105109
public class ApiBridgeHandler extends BaseBridgeHandler {
@@ -352,13 +356,22 @@ public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz,
352356

353357
NetatmoException exception;
354358
try {
355-
exception = new NetatmoException(deserializer.deserialize(ApiError.class, responseBody));
359+
ApiError apiError = deserializer.deserialize(ApiError.class, responseBody);
360+
if (ServiceError.UNKNOWN.equals(apiError.getCode())) {
361+
// HttpStatus.getCode() returns null for non-standard status codes (e.g. 520-527), so pass
362+
// response.getStatus() rather than statusCode
363+
exception = new NetatmoException(apiError, response.getStatus(), extractRawErrorCode(responseBody));
364+
} else {
365+
exception = new NetatmoException(apiError);
366+
}
356367
} catch (NetatmoException e) {
368+
String statusText = statusCode == null ? null : statusCode.getMessage();
369+
String statusMessage = "%s(HTTP %s)".formatted(statusText == null ? "" : statusText + " ",
370+
Integer.toString(response.getStatus()));
357371
if (statusCode == Code.TOO_MANY_REQUESTS) {
358-
exception = new NetatmoException(statusCode.getMessage());
372+
exception = new NetatmoException(statusMessage);
359373
} else {
360-
exception = new NetatmoException(
361-
"Error deserializing error: %s".formatted(statusCode.getMessage()));
374+
exception = new NetatmoException("Error deserializing error: %s".formatted(statusMessage));
362375
}
363376
}
364377
if (statusCode == Code.TOO_MANY_REQUESTS) {
@@ -394,6 +407,29 @@ public synchronized <T> T executeUri(URI uri, HttpMethod method, Class<T> clazz,
394407
}
395408
}
396409

410+
/**
411+
* Recovers the raw error code that {@link ApiError} discards when classifying it into a {@link ServiceError}.
412+
*/
413+
static @Nullable String extractRawErrorCode(String responseBody) {
414+
try {
415+
JsonElement root = JsonParser.parseString(responseBody);
416+
if (!root.isJsonObject()) {
417+
return null;
418+
}
419+
JsonElement errorElement = root.getAsJsonObject().get("error");
420+
if (errorElement == null || !errorElement.isJsonObject()) {
421+
return null;
422+
}
423+
JsonElement code = errorElement.getAsJsonObject().get("code");
424+
if (code == null || !code.isJsonPrimitive()) {
425+
return null;
426+
}
427+
return code.getAsString();
428+
} catch (JsonParseException e) {
429+
return null;
430+
}
431+
}
432+
397433
private void handleRequestCounter() {
398434
if (!isLinked(requestCountChannelUID)) {
399435
return;
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.netatmo.internal.api;
14+
15+
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.mockito.Mockito.mock;
17+
import static org.mockito.Mockito.when;
18+
19+
import java.time.ZoneId;
20+
21+
import org.junit.jupiter.api.BeforeAll;
22+
import org.junit.jupiter.api.Test;
23+
import org.openhab.binding.netatmo.internal.deserialization.NADeserializer;
24+
import org.openhab.core.i18n.TimeZoneProvider;
25+
26+
/**
27+
* @author Martin Littkovsky - Initial contribution
28+
*/
29+
public class NetatmoExceptionTest {
30+
private static NADeserializer gson;
31+
32+
@BeforeAll
33+
public static void init() {
34+
TimeZoneProvider timeZoneProvider = mock(TimeZoneProvider.class);
35+
when(timeZoneProvider.getTimeZone()).thenReturn(ZoneId.systemDefault());
36+
gson = new NADeserializer(timeZoneProvider);
37+
}
38+
39+
@Test
40+
public void testKnownServiceErrorKeepsExistingFormat() throws NetatmoException {
41+
String body = """
42+
{\
43+
"error": {\
44+
"code": 26,\
45+
"message": "Usage max reached"\
46+
}\
47+
}\
48+
""";
49+
ApiError apiError = gson.deserialize(ApiError.class, body);
50+
51+
NetatmoException exception = new NetatmoException(apiError);
52+
53+
assertEquals("Rest call failed: statusCode=MAXIMUM_USAGE_REACHED, message=Usage max reached",
54+
exception.getMessage());
55+
}
56+
57+
@Test
58+
public void testKnownServiceErrorIgnoresHttpContextEvenIfProvided() throws NetatmoException {
59+
String body = """
60+
{\
61+
"error": {\
62+
"code": 26,\
63+
"message": "Usage max reached"\
64+
}\
65+
}\
66+
""";
67+
ApiError apiError = gson.deserialize(ApiError.class, body);
68+
69+
NetatmoException exception = new NetatmoException(apiError, 503, "26");
70+
71+
assertEquals("Rest call failed: statusCode=MAXIMUM_USAGE_REACHED, message=Usage max reached",
72+
exception.getMessage());
73+
}
74+
75+
@Test
76+
public void testUnclassifiedErrorKeepsHttpStatusAndRawCode() throws NetatmoException {
77+
// code 50 does not map to any ServiceError value, so it classifies as UNKNOWN
78+
String body = """
79+
{\
80+
"error": {\
81+
"code": 50,\
82+
"message": "Service temporarily unavailable"\
83+
}\
84+
}\
85+
""";
86+
ApiError apiError = gson.deserialize(ApiError.class, body);
87+
88+
NetatmoException exception = new NetatmoException(apiError, 503, "50");
89+
90+
assertEquals("Service temporarily unavailable (HTTP 503, error code 50)", exception.getMessage());
91+
}
92+
93+
@Test
94+
public void testUnclassifiedErrorWithoutRawCodeOmitsCodeSuffix() throws NetatmoException {
95+
String body = """
96+
{\
97+
"error": {\
98+
"code": 50,\
99+
"message": "Service temporarily unavailable"\
100+
}\
101+
}\
102+
""";
103+
ApiError apiError = gson.deserialize(ApiError.class, body);
104+
105+
NetatmoException exception = new NetatmoException(apiError, 503, null);
106+
107+
assertEquals("Service temporarily unavailable (HTTP 503)", exception.getMessage());
108+
}
109+
110+
@Test
111+
public void testUnclassifiedErrorWithEmptyMessageAvoidsLeadingSpace() throws NetatmoException {
112+
String body = """
113+
{\
114+
"error": {\
115+
"code": 50,\
116+
"message": ""\
117+
}\
118+
}\
119+
""";
120+
ApiError apiError = gson.deserialize(ApiError.class, body);
121+
122+
NetatmoException exception = new NetatmoException(apiError, 503, "50");
123+
124+
assertEquals("(HTTP 503, error code 50)", exception.getMessage());
125+
}
126+
127+
@Test
128+
public void testUnclassifiedErrorWithoutHttpContextKeepsPlainMessage() throws NetatmoException {
129+
String body = """
130+
{\
131+
"error": {\
132+
"code": 50,\
133+
"message": "Service temporarily unavailable"\
134+
}\
135+
}\
136+
""";
137+
ApiError apiError = gson.deserialize(ApiError.class, body);
138+
139+
NetatmoException exception = new NetatmoException(apiError);
140+
141+
assertEquals("Service temporarily unavailable", exception.getMessage());
142+
}
143+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.netatmo.internal.handler;
14+
15+
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.junit.jupiter.api.Assertions.assertNull;
17+
18+
import org.junit.jupiter.api.Test;
19+
20+
/**
21+
* @author Martin Littkovsky - Initial contribution
22+
*/
23+
public class ApiBridgeHandlerTest {
24+
25+
@Test
26+
public void testRawErrorCodeAsNumber() {
27+
String body = "{\"error\":{\"code\":50,\"message\":\"Service temporarily unavailable\"}}";
28+
29+
assertEquals("50", ApiBridgeHandler.extractRawErrorCode(body));
30+
}
31+
32+
@Test
33+
public void testRawErrorCodeAsString() {
34+
String body = "{\"error\":{\"code\":\"50\",\"message\":\"Service temporarily unavailable\"}}";
35+
36+
assertEquals("50", ApiBridgeHandler.extractRawErrorCode(body));
37+
}
38+
39+
@Test
40+
public void testRawErrorCodeMissingReturnsNull() {
41+
String body = "{\"error\":{\"message\":\"Service temporarily unavailable\"}}";
42+
43+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
44+
}
45+
46+
@Test
47+
public void testRawErrorCodeWithEmptyErrorObjectReturnsNull() {
48+
String body = "{\"error\":{}}";
49+
50+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
51+
}
52+
53+
@Test
54+
public void testRawErrorCodeWithEmptyBodyReturnsNull() {
55+
String body = "{}";
56+
57+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
58+
}
59+
60+
@Test
61+
public void testRawErrorCodeWithNullErrorReturnsNull() {
62+
String body = "{\"error\":null}";
63+
64+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
65+
}
66+
67+
@Test
68+
public void testRawErrorCodeWithNonObjectErrorReturnsNull() {
69+
String body = "{\"error\":\"boom\"}";
70+
71+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
72+
}
73+
74+
@Test
75+
public void testRawErrorCodeWithNullCodeReturnsNull() {
76+
String body = "{\"error\":{\"code\":null}}";
77+
78+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
79+
}
80+
81+
@Test
82+
public void testRawErrorCodeWithObjectCodeReturnsNull() {
83+
String body = "{\"error\":{\"code\":{}}}";
84+
85+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
86+
}
87+
88+
@Test
89+
public void testRawErrorCodeWithArrayCodeReturnsNull() {
90+
String body = "{\"error\":{\"code\":[]}}";
91+
92+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
93+
}
94+
95+
@Test
96+
public void testRawErrorCodeWithNonJsonBodyReturnsNull() {
97+
String body = "not valid json";
98+
99+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
100+
}
101+
102+
@Test
103+
public void testRawErrorCodeWithArrayRootReturnsNull() {
104+
String body = "[]";
105+
106+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
107+
}
108+
109+
@Test
110+
public void testRawErrorCodeWithPrimitiveRootReturnsNull() {
111+
// a single bare token is lenient-valid JSON, so this hits the isJsonObject() guard, not the catch
112+
String body = "ServiceUnavailable";
113+
114+
assertNull(ApiBridgeHandler.extractRawErrorCode(body));
115+
}
116+
}

0 commit comments

Comments
 (0)