Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
retry.hpp
Go to the documentation of this file.
1#pragma once
2
3// retry / retry_if / retry_with_backoff — coroutine-friendly retry combinators.
4//
5// Inspired by RxCpp's retry / retryWhen but expressed as plain co_await.
6//
7// // Try up to 3 times. Re-throws the last exception on final failure.
8// auto profile = co_await retry(3, []() { return http::get(url); });
9//
10// // Exponential backoff: 100ms, 200ms, 400ms, 800ms ...
11// auto profile = co_await retry_with_backoff(
12// /*max_attempts=*/5,
13// /*initial=*/100ms,
14// timer,
15// [&]() { return http::get(url); });
16//
17// // Custom predicate — only retry on transient network errors.
18// auto profile = co_await retry_if(
19// /*max_attempts=*/3,
20// [](const std::exception& e) {
21// return std::string{e.what()}.starts_with("network:");
22// },
23// [&]() { return http::get(url); });
24
25#include "aria/async/task.hpp"
26#include "aria/async/cancellation.hpp" // OperationCancelled — never retried
28#include "aria/async/virtual_time_executor.hpp" // also serves as IDelayedScheduler
29
30#include <algorithm>
31#include <atomic>
32#include <chrono>
33#include <exception>
34#include <functional>
35#include <limits>
36#include <memory>
37#include <optional>
38#include <stdexcept>
39#include <type_traits>
40#include <utility>
41
42namespace aria::async {
43
44namespace detail {
45 template<typename Factory>
46 using factory_value_t =
47 typename std::invoke_result_t<Factory>::promise_type::value_type;
48
51 struct DelayAwaiter {
52 IDelayedScheduler& scheduler;
53 std::chrono::milliseconds delay;
54 bool await_ready() const noexcept { return false; }
55 void await_suspend(std::coroutine_handle<> h) const {
56 scheduler.post_after(delay, [h]() mutable { h.resume(); });
57 }
58 void await_resume() const noexcept {}
59 };
60
64 struct CancellableDelayAwaiter {
65 struct State {
66 // 0 = registering, 1 = suspended, 2 = signalled.
67 std::atomic<int> phase{0};
68 std::coroutine_handle<> handle;
69
70 void signal(bool from_cancellation) {
71 if (phase.exchange(2, std::memory_order_acq_rel) != 1) return;
72 if (from_cancellation) schedule_deferred_resume(handle);
73 else handle.resume();
74 }
75 };
76 IDelayedScheduler& scheduler;
77 std::chrono::milliseconds delay;
78 CancellationToken token;
79 std::shared_ptr<State> state = std::make_shared<State>();
80
81 bool await_ready() const noexcept { return token.is_cancelled(); }
82 bool await_suspend(std::coroutine_handle<> h) {
83 auto shared = state;
84 shared->handle = h;
85 scheduler.post_after(delay, [shared] { shared->signal(false); });
86 token.on_cancel([shared] { shared->signal(true); });
87 int expected = 0;
88 return shared->phase.compare_exchange_strong(
89 expected, 1, std::memory_order_acq_rel);
90 }
91 void await_resume() const { token.throw_if_cancelled(); }
92 };
93
94 inline std::chrono::milliseconds retry_delay_(
95 std::chrono::milliseconds initial, int attempt) noexcept {
96 using Rep = std::chrono::milliseconds::rep;
97 if (initial.count() <= 0) return std::chrono::milliseconds{0};
98 const auto multiplier = Rep{1} << std::clamp(attempt, 0, 20);
99 constexpr auto maximum = std::numeric_limits<Rep>::max();
100 return std::chrono::milliseconds{
101 initial.count() > maximum / multiplier
102 ? maximum : initial.count() * multiplier};
103 }
104
114 template<typename Factory, typename ShouldRetry, typename NextDelay>
115 auto retry_impl_(int max_attempts,
116 ShouldRetry should_retry,
117 NextDelay next_delay,
118 IDelayedScheduler* timer,
119 Factory factory,
120 std::optional<CancellationToken> token = std::nullopt)
121 -> Task<factory_value_t<Factory>>
122 {
123 using R = factory_value_t<Factory>;
124 if (max_attempts <= 0) {
125 throw std::invalid_argument("retry: max_attempts must be positive");
126 }
127 std::exception_ptr last;
128 for (int attempt = 0; attempt < max_attempts; ++attempt) {
129 try {
130 if (token) token->throw_if_cancelled();
131 if constexpr (std::is_void_v<R>) {
132 co_await factory();
133 co_return;
134 } else {
135 co_return co_await factory();
136 }
137 } catch (const OperationCancelled&) {
138 // Cancellation is NOT a retryable failure. `OperationCancelled`
139 // derives from std::exception, so without this arm it would be
140 // swallowed by the generic handler below and — because the
141 // default `should_retry` returns true unconditionally — the
142 // operation the caller just cancelled would be retried until
143 // `max_attempts` was exhausted. Rethrow immediately so
144 // cancellation propagates to the awaiting frame intact.
145 throw;
146 } catch (const std::exception& e) {
147 last = std::current_exception();
148 const bool last_attempt = (attempt + 1 == max_attempts);
149 if (last_attempt || !should_retry(e)) {
150 std::rethrow_exception(last);
151 }
152 } catch (...) {
153 // Non-std::exception: out of contract for should_retry,
154 // give up immediately.
155 throw;
156 }
157
158 // Optional inter-attempt delay.
159 if (timer) {
160 const auto delay = next_delay(attempt);
161 if (delay.count() > 0) {
162 if (token) {
163 co_await CancellableDelayAwaiter{*timer, delay, *token};
164 } else {
165 co_await DelayAwaiter{*timer, delay};
166 }
167 }
168 }
169 }
170 std::rethrow_exception(last);
171 }
172}
173
174// ── primary: blind retry N times, no delay ──────────────────────────────────
175template<typename Factory>
176auto retry(int max_attempts, Factory factory)
178{
179 return detail::retry_impl_(
180 max_attempts,
181 [](const std::exception&) { return true; },
182 [](int) { return std::chrono::milliseconds{0}; },
183 /*timer=*/nullptr,
184 std::move(factory));
185}
186
187// ── retry_if: predicate decides whether to retry on a given exception ───────
188template<typename Predicate, typename Factory>
189auto retry_if(int max_attempts, Predicate should_retry, Factory factory)
191{
192 return detail::retry_impl_(
193 max_attempts,
194 std::move(should_retry),
195 [](int) { return std::chrono::milliseconds{0}; },
196 /*timer=*/nullptr,
197 std::move(factory));
198}
199
200// ── retry_with_backoff: 100ms, 200ms, 400ms, 800ms ... ──────────────────────
201template<typename Factory>
202auto retry_with_backoff(int max_attempts,
203 std::chrono::milliseconds initial,
204 IDelayedScheduler& timer,
205 Factory factory)
207{
208 return detail::retry_impl_(
209 max_attempts,
210 [](const std::exception&) { return true; },
211 [initial](int attempt) {
212 return detail::retry_delay_(initial, attempt);
213 },
214 &timer,
215 std::move(factory));
216}
217
218} // namespace aria::async
Tiny interface — anything that can post a function to run after a delay.
Definition property_ops.hpp:62
Definition task.hpp:78
Definition async_command.hpp:118
auto retry_with_backoff(int max_attempts, std::chrono::milliseconds initial, IDelayedScheduler &timer, Factory factory) -> Task< detail::factory_value_t< Factory > >
Definition retry.hpp:202
auto retry_if(int max_attempts, Predicate should_retry, Factory factory) -> Task< detail::factory_value_t< Factory > >
Definition retry.hpp:189
auto retry(int max_attempts, Factory factory) -> Task< detail::factory_value_t< Factory > >
Definition retry.hpp:176