From f2a3231f0ce016d62d2cdd34486d4d4b33b348f8 Mon Sep 17 00:00:00 2001 From: Matt Jones Date: Thu, 13 Aug 2026 21:37:10 -0500 Subject: [PATCH] feat: add 429 awareness --- .github/dependabot.yml | 14 +++++ pom.xml | 11 ++-- .../bigboxer23/switch_bot/SwitchBotApi.java | 2 +- .../switch_bot/SwitchBotDeviceApi.java | 21 ++++++-- .../switch_bot/SwitchBotDeviceApiTest.java | 53 ++++++++++++++++++- 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8f2fc3f..d16c745 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,20 @@ updates: directory: "/" registries: - maven-github + schedule: + interval: "weekly" + commit-message: + prefix: "deps" + groups: + minor-patch-dependencies: + update-types: + - "minor" + - "patch" + major-dependencies: + update-types: + - "major" + - package-ecosystem: "github-actions" + directory: "/" schedule: interval: "weekly" commit-message: diff --git a/pom.xml b/pom.xml index be7ff4f..cc766f2 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.bigboxer23 switchbotapi-java - 1.2.5 + 1.2.6 switchbotapi-java https://github.com/bigboxer23/switchbotapi-java @@ -44,7 +44,7 @@ com.bigboxer23 utils - 2.4.0 + 2.5.0 org.projectlombok @@ -192,13 +192,8 @@ 3.9.0 - - 1.17.0 - - true - - 2.35.0 + 2.96.0 true diff --git a/src/main/java/com/bigboxer23/switch_bot/SwitchBotApi.java b/src/main/java/com/bigboxer23/switch_bot/SwitchBotApi.java index 8698624..662ad24 100644 --- a/src/main/java/com/bigboxer23/switch_bot/SwitchBotApi.java +++ b/src/main/java/com/bigboxer23/switch_bot/SwitchBotApi.java @@ -36,7 +36,7 @@ private SwitchBotApi(String token, String secret) { deviceApi = new SwitchBotDeviceApi(this); } - public static SwitchBotApi getInstance(String token, String secret) { + public static synchronized SwitchBotApi getInstance(String token, String secret) { if (token == null || secret == null) { log.error("need to define token and secret values."); throw new RuntimeException("need to define token and secret values."); diff --git a/src/main/java/com/bigboxer23/switch_bot/SwitchBotDeviceApi.java b/src/main/java/com/bigboxer23/switch_bot/SwitchBotDeviceApi.java index 92c60c8..b658cd6 100644 --- a/src/main/java/com/bigboxer23/switch_bot/SwitchBotDeviceApi.java +++ b/src/main/java/com/bigboxer23/switch_bot/SwitchBotDeviceApi.java @@ -1,6 +1,7 @@ package com.bigboxer23.switch_bot; import com.bigboxer23.switch_bot.data.*; +import com.bigboxer23.utils.command.NonRetryableException; import com.bigboxer23.utils.http.OkHttpUtil; import com.bigboxer23.utils.time.ITimeConstants; import java.io.IOException; @@ -20,10 +21,16 @@ public class SwitchBotDeviceApi { private final SwitchBotApi provider; + protected static final int HTTP_TOO_MANY_REQUESTS = 429; + + protected static final long DEVICE_NAME_REFRESH_BACKOFF = ITimeConstants.MINUTE * 5; + private Map deviceIdToNames; protected long deviceIdToNamesCacheTime = -1; + protected long deviceIdToNamesRetryTime = -1; + protected SwitchBotDeviceApi(SwitchBotApi provider) { this.provider = provider; } @@ -39,15 +46,19 @@ private synchronized void refreshDeviceNameMap() { if (deviceIdToNames != null && (System.currentTimeMillis() - ITimeConstants.HOUR) < deviceIdToNamesCacheTime) { return; } + if (System.currentTimeMillis() < deviceIdToNamesRetryTime) { + return; + } try { log.info("Refreshing device id/name map..."); deviceIdToNames = Collections.unmodifiableMap( getDevices().stream().collect(Collectors.toMap(Device::getDeviceId, Device::getDeviceName))); deviceIdToNamesCacheTime = System.currentTimeMillis(); + deviceIdToNamesRetryTime = -1; } catch (IOException e) { - log.error("Failed to refresh device names.", e); - deviceIdToNames = null; - deviceIdToNamesCacheTime = -1; + log.error( + "Failed to refresh device names, retrying no sooner than " + DEVICE_NAME_REFRESH_BACKOFF + "ms", e); + deviceIdToNamesRetryTime = System.currentTimeMillis() + DEVICE_NAME_REFRESH_BACKOFF; } } @@ -105,6 +116,10 @@ public IApiResponse sendDeviceControlCommands(String deviceId, DeviceCommand com * @throws IOException */ private T parseResponse(Response response, Class clazz) throws IOException { + if (response != null && response.code() == HTTP_TOO_MANY_REQUESTS) { + log.error("rate limited by the switchbot api: " + response.code() + " " + response.message()); + throw new NonRetryableException("rate limited " + response.code() + " " + response.message()); + } IApiResponse apiResponse = provider.checkForError(response, (Optional) OkHttpUtil.getBody(response, clazz)); if (!apiResponse.isSuccess()) { diff --git a/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java b/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java index 3831967..a4b1a25 100644 --- a/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java +++ b/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java @@ -264,6 +264,57 @@ public void testGetDeviceNameFromIdWithCacheRefreshFailure() throws IOException String result = spyDeviceApi.getDeviceNameFromId("test-device"); assertEquals("test-device", result); - assertEquals(-1, spyDeviceApi.deviceIdToNamesCacheTime); + assertTrue(spyDeviceApi.deviceIdToNamesRetryTime > System.currentTimeMillis()); + } + + /** + * A failed refresh used to drop the cache, so every later lookup asked for the device list + * again. That turns a rate limited api into a request loop, exactly when we can least afford it. + */ + @Test + public void testCacheRefreshFailureDoesNotRequestDeviceListOnEveryLookup() throws IOException { + SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); + doThrow(new IOException("429 rate limited")).when(spyDeviceApi).getDevices(); + + for (int i = 0; i < 10; i++) { + assertEquals("test-device", spyDeviceApi.getDeviceNameFromId("test-device")); + } + + verify(spyDeviceApi, times(1)).getDevices(); + } + + @Test + public void testCacheRefreshFailureRetainsPreviouslyCachedNames() throws IOException { + Device device = new Device(); + device.setDeviceId("device1"); + device.setDeviceName("Living Room Light"); + + SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); + doReturn(List.of(device)).when(spyDeviceApi).getDevices(); + assertEquals("Living Room Light", spyDeviceApi.getDeviceNameFromId("device1")); + + // expire the cache, then fail the refresh + spyDeviceApi.deviceIdToNamesCacheTime = System.currentTimeMillis() - (ITimeConstants.HOUR * 2); + doThrow(new IOException("429 rate limited")).when(spyDeviceApi).getDevices(); + + assertEquals("Living Room Light", spyDeviceApi.getDeviceNameFromId("device1")); + } + + @Test + public void testCacheRefreshResumesAfterBackoffWindow() throws IOException { + Device device = new Device(); + device.setDeviceId("device1"); + device.setDeviceName("Living Room Light"); + + SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); + doThrow(new IOException("429 rate limited")).when(spyDeviceApi).getDevices(); + assertEquals("device1", spyDeviceApi.getDeviceNameFromId("device1")); + + // pretend the backoff window has passed + spyDeviceApi.deviceIdToNamesRetryTime = System.currentTimeMillis() - 1; + doReturn(List.of(device)).when(spyDeviceApi).getDevices(); + + assertEquals("Living Room Light", spyDeviceApi.getDeviceNameFromId("device1")); + assertEquals(-1, spyDeviceApi.deviceIdToNamesRetryTime); } }