2323import static java .time .ZoneOffset .UTC ;
2424import java .time .ZonedDateTime ;
2525import java .time .format .DateTimeFormatter ;
26+ import java .time .temporal .ChronoUnit ;
2627import static java .time .temporal .ChronoUnit .DAYS ;
2728import static java .time .temporal .ChronoUnit .MINUTES ;
2829import static java .time .temporal .ChronoUnit .NANOS ;
@@ -120,6 +121,7 @@ public abstract class FitbitPollingRoute implements PollingRequestRoute {
120121 private final Map <String , Integer > forbidden403Counter ;
121122 private int maxForbiddenResponses ;
122123 private Duration forbidden403Cooldown ;
124+ private String cooldownStrategy ;
123125
124126 public FitbitPollingRoute (
125127 FitbitRequestGenerator generator ,
@@ -147,6 +149,7 @@ public void initialize(RestSourceConnectorConfig config) {
147149 this .maxForbiddenResponses = fitbitConfig .getMaxForbidden ();
148150 this .converter ().initialize (fitbitConfig );
149151 this .forbidden403Cooldown = Duration .ofSeconds (fitbitConfig .getForbiddenBackoff ());
152+ this .cooldownStrategy = fitbitConfig .getCooldownStrategy ();
150153 }
151154
152155 @ Override
@@ -164,6 +167,9 @@ public void requestEmpty(RestRequest request) {
164167 lastPollPerUser .put (((FitbitRestRequest ) request ).getUser ().getId (), lastPoll );
165168 FitbitRestRequest fitbitRequest = (FitbitRestRequest ) request ;
166169 Instant endOffset = fitbitRequest .getDateRange ().end ().toInstant ();
170+ // When having polled a date range for a route for HISTORICAL_TIME_DAYS days and
171+ // the response has no data, consider this data not to exist by considering
172+ // the end of the date range as the last successful data retrieval.
167173 if (DAYS .between (endOffset , lastPoll ) >= HISTORICAL_TIME_DAYS ) {
168174 String key = fitbitRequest .getUser ().getVersionedId ();
169175 offsets .put (key , endOffset );
@@ -175,19 +181,19 @@ public void requestFailed(RestRequest request, Response response) {
175181 if (response != null && response .code () == 429 ) {
176182 User user = ((FitbitRestRequest ) request ).getUser ();
177183 tooManyRequestsForUser .add (user );
178- String cooldownString = response .header ("Retry-After" );
179- Duration cooldown = getTooManyRequestsCooldown ();
180- if (cooldownString != null ) {
181- try {
182- cooldown = Duration .ofSeconds (Long .parseLong (cooldownString ));
183- } catch (NumberFormatException ex ) {
184- cooldown = getTooManyRequestsCooldown ();
185- }
184+
185+ Instant backOff ;
186+ if ("TOP_OF_HOUR" .equalsIgnoreCase (cooldownStrategy )) {
187+ backOff = calculateTopOfHourBackoff ();
188+ logger .info ("Too many requests for user {}. Using TOP_OF_HOUR strategy, backing off until: {}" ,
189+ user , backOff );
190+ } else {
191+ backOff = calculateRollingWindowBackoff (response );
192+ logger .info ("Too many requests for user {}. Using ROLLING_WINDOW strategy, backing off until: {}" ,
193+ user , backOff .plus (getPollIntervalPerUser ()));
186194 }
187- Instant backOff = lastPoll . plus ( cooldown );
195+
188196 lastPollPerUser .put (user .getId (), backOff );
189- logger .info ("Too many requests for user {}. Backing off until {}" ,
190- user , backOff .plus (getPollIntervalPerUser ()));
191197 } else if (response != null && response .code () == 403 ) {
192198 User user = ((FitbitRestRequest ) request ).getUser ();
193199 String userId = user .getId ();
@@ -206,6 +212,46 @@ public void requestFailed(RestRequest request, Response response) {
206212 }
207213 }
208214
215+ /**
216+ * Calculate backoff using top-of-hour strategy.
217+ * Waits until the top of the next hour to align with Fitbit's rate limit reset.
218+ */
219+ private Instant calculateTopOfHourBackoff () {
220+ Instant now = Instant .now ();
221+ Instant topOfNextHour = calculateTopOfNextHour (now ).plus (ONE_SECOND );
222+ return topOfNextHour ;
223+ }
224+
225+ /**
226+ * Calculate backoff using rolling window strategy.
227+ * Uses configured cooldown duration from when the error occurred.
228+ */
229+ private Instant calculateRollingWindowBackoff (Response response ) {
230+ String cooldownString = response .header ("Retry-After" );
231+ Duration cooldown = getTooManyRequestsCooldown ();
232+
233+ if (cooldownString != null ) {
234+ try {
235+ cooldown = Duration .ofSeconds (Long .parseLong (cooldownString ));
236+ } catch (NumberFormatException ex ) {
237+ cooldown = getTooManyRequestsCooldown ();
238+ }
239+ }
240+
241+ return lastPoll .plus (cooldown );
242+ }
243+
244+ /**
245+ * Calculate the top of the next hour from the given instant.
246+ */
247+ private Instant calculateTopOfNextHour (Instant instant ) {
248+ ZonedDateTime zonedDateTime = instant .atZone (UTC );
249+ ZonedDateTime topOfNextHour = zonedDateTime
250+ .plusHours (1 )
251+ .truncatedTo (ChronoUnit .HOURS );
252+ return topOfNextHour .toInstant ();
253+ }
254+
209255 /**
210256 * Actually construct requests, based on the current offset
211257 * @param user Fitbit user
0 commit comments