Skip to content

Commit dd547ae

Browse files
authored
Merge pull request #140 from sorru94/fw-port-exponential-backoff-to-0.7
Forward port exponential backoff fix from v0.6.2
2 parents 62f80c0 + 238a043 commit dd547ae

File tree

4 files changed

+140
-21
lines changed

4 files changed

+140
-21
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ All notable changes to this project will be documented in this file.
1010
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/)
1111
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
1212

13+
## [0.6.2] - 2025-10-28
14+
### Fixed
15+
- An integer overflow in the exponential backoff module. The bug caused the calculated reconnection delay to wrap around to a negative value, leading to zero delay connection attempts during a network outage.
16+
1317
## [0.7.0] - 2025-08-13
1418
### Added
1519
- API calls to fetch stored device properties in the gRPC Astarte device.

private/exponential_backoff.hpp

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,51 +11,93 @@
1111
#include <cstdint>
1212
#include <random>
1313

14+
#include "astarte_device_sdk/exceptions.hpp"
15+
1416
namespace AstarteDeviceSdk {
1517

1618
class ExponentialBackoff {
1719
public:
1820
/**
1921
* @brief Construct an ExponentialBackoff instance.
20-
* @param initial_delay The value for the first backoff delay.
21-
* @param max_delay The upper bound for all the backoff delays.
22+
*
23+
* @details The exponential backoff will will compute an exponential delay using
24+
* 2 as the base for the power operation and @p mul_coeff as the multiplier coefficient.
25+
* The values returned by calls to getNextDelay will follow the formula:
26+
* min( @p mul_coeff * 2 ^ ( number of calls ) , @p cutoff_coeff ) + random jitter
27+
* The random jitter will be in the range [ - @p mul_coeff , + @p mul_coeff ]
28+
*
29+
* @note The jitter will be applied also once the @p cutoff_coeff has been reached. Effectively
30+
* the maximum delay produced will be @p cutoff_coeff + @p mul_coeff.
31+
*
32+
* @param mul_coeff Multiplier coefficient used in the exponential delay calculation.
33+
* @param cutoff_coeff The cut-off coefficient, an upper bound for the exponential curve.
2234
*/
23-
ExponentialBackoff(std::chrono::milliseconds initial_delay, std::chrono::milliseconds max_delay)
24-
: initial_delay_(initial_delay), max_delay_(max_delay) {}
35+
ExponentialBackoff(std::chrono::milliseconds mul_coeff, std::chrono::milliseconds cutoff_coeff)
36+
: mul_coeff_(mul_coeff), cutoff_coeff_(cutoff_coeff) {
37+
if ((mul_coeff <= std::chrono::milliseconds::zero()) ||
38+
(cutoff_coeff <= std::chrono::milliseconds::zero())) {
39+
throw AstarteInvalidInputException("Received zero or negative coefficients.");
40+
}
41+
if (cutoff_coeff < mul_coeff) {
42+
throw AstarteInvalidInputException(
43+
"The multiplier coefficient is larger than the cuttoff coefficient");
44+
}
45+
}
2546

2647
/**
2748
* @brief Calculate and returns the next backoff delay.
28-
* @details Computes the appropriate delay for the current backoff generation and increments the
29-
* internal generation counter for the next call.
49+
* @details See the documentation of the constructor of this class for an explanation on how
50+
* this delay is computed.
3051
* @return The calculated delay duration.
3152
*/
3253
auto getNextDelay() -> std::chrono::milliseconds {
33-
constexpr double BACKOFF_FACTOR = 2.0;
54+
const ChronoMillisRep mul_coeff = mul_coeff_.count();
55+
const ChronoMillisRep max_milliseconds = std::chrono::milliseconds::max().count();
56+
const ChronoMillisRep max_allowed_final_delay = max_milliseconds - mul_coeff;
3457

35-
const auto initial_delay_ms = static_cast<double>(initial_delay_.count());
58+
// Update last delay value with the new value
59+
ChronoMillisRep delay = 0;
60+
if (prev_delay_ == 0) {
61+
delay = mul_coeff;
62+
} else if (prev_delay_ <= (max_allowed_final_delay / 2)) {
63+
delay = 2 * prev_delay_;
64+
} else {
65+
delay = max_allowed_final_delay;
66+
}
3667

37-
const auto delay_ms = initial_delay_ms * std::pow(BACKOFF_FACTOR, generated_delays_);
38-
// Apply a positive jitter (a random value between 0 and initial_delay_)
39-
const auto jitter = dist_(gen_) * initial_delay_ms;
68+
// Bound the delay to the maximum
69+
ChronoMillisRep bounded_delay = std::min(delay, cutoff_coeff_.count());
4070

41-
const auto total_delay_ms = static_cast<int64_t>(delay_ms + jitter);
42-
const auto jittery_delay = std::chrono::milliseconds(total_delay_ms);
71+
// Store the new delay before jitter application
72+
prev_delay_ = bounded_delay;
4373

44-
generated_delays_++;
74+
// Insert some jitter
75+
ChronoMillisRep jitter_minimum = -mul_coeff;
76+
if (bounded_delay - mul_coeff < 0) {
77+
jitter_minimum = 0;
78+
}
79+
ChronoMillisRep jitter_maximum = mul_coeff;
80+
if (bounded_delay > max_milliseconds - mul_coeff) {
81+
jitter_maximum = max_milliseconds - bounded_delay;
82+
}
83+
std::uniform_int_distribution<ChronoMillisRep> dist(jitter_minimum, jitter_maximum);
84+
ChronoMillisRep jittered_delay = bounded_delay + dist(gen_);
4585

46-
return std::min(jittery_delay, max_delay_);
86+
// Convert to a chrono object
87+
return std::chrono::milliseconds(jittered_delay);
4788
}
4889

4990
/** @brief Reset the backoff generator. */
50-
void reset() { generated_delays_ = 0; }
91+
void reset() { prev_delay_ = 0; }
5192

5293
private:
53-
std::chrono::milliseconds initial_delay_;
54-
std::chrono::milliseconds max_delay_;
55-
int generated_delays_{0};
94+
using ChronoMillisRep = std::chrono::milliseconds::rep;
95+
96+
std::chrono::milliseconds mul_coeff_;
97+
std::chrono::milliseconds cutoff_coeff_;
5698
std::random_device rd_;
5799
std::mt19937 gen_{rd_()};
58-
std::uniform_real_distribution<> dist_{0.0, 1.0};
100+
ChronoMillisRep prev_delay_{0};
59101
};
60102

61103
} // namespace AstarteDeviceSdk

unit/CMakeLists.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ FetchContent_MakeAvailable(googletest)
1717

1818
enable_testing()
1919

20-
add_executable(unit_test conversion_test.cpp data_test.cpp msg_test.cpp)
20+
add_executable(
21+
unit_test
22+
conversion_test.cpp
23+
data_test.cpp
24+
exponential_backoff_test.cpp
25+
msg_test.cpp
26+
)
2127

2228
# Add the Astarte sdk root directory
2329
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/.. ${CMAKE_CURRENT_BINARY_DIR}/lib_build)

unit/exponential_backoff_test.cpp

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// (C) Copyright 2025, SECO Mind Srl
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
#include "exponential_backoff.hpp"
6+
7+
#include <gmock/gmock.h>
8+
#include <gtest/gtest.h>
9+
10+
using ::testing::AllOf;
11+
using ::testing::Ge;
12+
using ::testing::Le;
13+
14+
using AstarteDeviceSdk::ExponentialBackoff;
15+
16+
TEST(AstarteTestExponentialBackoff, IncorrectInputs) {
17+
EXPECT_THROW(ExponentialBackoff backoff(std::chrono::minutes(-1), std::chrono::minutes(1)),
18+
std::exception);
19+
EXPECT_THROW(ExponentialBackoff backoff(std::chrono::minutes(1), std::chrono::minutes(-1)),
20+
std::exception);
21+
EXPECT_THROW(
22+
ExponentialBackoff backoff(std::chrono::minutes(1), std::chrono::milliseconds::zero()),
23+
std::exception);
24+
EXPECT_THROW(
25+
ExponentialBackoff backoff(std::chrono::milliseconds::zero(), std::chrono::minutes(1)),
26+
std::exception);
27+
}
28+
29+
TEST(AstarteTestExponentialBackoff, OrdinaryBackoff) {
30+
ExponentialBackoff backoff(std::chrono::minutes(1), std::chrono::minutes(18));
31+
EXPECT_THAT(backoff.getNextDelay(),
32+
AllOf(Ge(std::chrono::milliseconds::zero()), Le(std::chrono::minutes(2))));
33+
EXPECT_THAT(backoff.getNextDelay(),
34+
AllOf(Ge(std::chrono::minutes(1)), Le(std::chrono::minutes(3))));
35+
EXPECT_THAT(backoff.getNextDelay(),
36+
AllOf(Ge(std::chrono::minutes(3)), Le(std::chrono::minutes(5))));
37+
EXPECT_THAT(backoff.getNextDelay(),
38+
AllOf(Ge(std::chrono::minutes(7)), Le(std::chrono::minutes(9))));
39+
EXPECT_THAT(backoff.getNextDelay(),
40+
AllOf(Ge(std::chrono::minutes(15)), Le(std::chrono::minutes(17))));
41+
for (size_t i = 0; i < 1048576u; i++) {
42+
EXPECT_THAT(backoff.getNextDelay(),
43+
AllOf(Ge(std::chrono::minutes(17)), Le(std::chrono::minutes(19))));
44+
}
45+
}
46+
47+
TEST(AstarteTestExponentialBackoff, VeryLargeBackoff) {
48+
ExponentialBackoff backoff(
49+
std::chrono::hours(1),
50+
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::years(100)));
51+
EXPECT_THAT(backoff.getNextDelay(),
52+
AllOf(Ge(std::chrono::milliseconds::zero()), Le(std::chrono::hours(2))));
53+
EXPECT_THAT(backoff.getNextDelay(), AllOf(Ge(std::chrono::hours(1)), Le(std::chrono::hours(3))));
54+
EXPECT_THAT(backoff.getNextDelay(), AllOf(Ge(std::chrono::hours(3)), Le(std::chrono::hours(5))));
55+
EXPECT_THAT(backoff.getNextDelay(), AllOf(Ge(std::chrono::hours(7)), Le(std::chrono::hours(9))));
56+
// A lot of calls in between
57+
for (size_t i = 0; i < 1000000u; i++) {
58+
backoff.getNextDelay();
59+
}
60+
// Check it settled around the proper value
61+
for (size_t i = 0; i < 100u; i++) {
62+
EXPECT_THAT(
63+
backoff.getNextDelay(),
64+
AllOf(Ge(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::years(99))),
65+
Le(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::years(101)))));
66+
}
67+
}

0 commit comments

Comments
 (0)