Lightweight 0.20260921.0
Loading...
Searching...
No Matches
SqlRetryPolicy.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4
5#include "Api.hpp"
6#include "SqlError.hpp"
7#include "SqlRetryClassifier.hpp"
8#include "SqlServerType.hpp"
9
10#include <chrono>
11#include <cstdint>
12#include <expected>
13#include <functional>
14#include <type_traits>
15#include <utility>
16
17namespace Lightweight
18{
19
20class SqlConnection;
21
22/// @ingroup Retry
23/// Backoff configuration of a @ref SqlRetryPolicy.
24///
25/// This is the descriptor half of the policy: pure data, no behaviour, so it can be read from a
26/// config file, held in a settings struct, or written inline at a call site.
28{
29 /// Maximum number of *retries* — an operation is attempted at most @c maxRetries+1 times.
30 /// Zero disables retrying without disabling the classification machinery.
31 unsigned maxRetries = 3;
32
33 /// Delay before the first retry.
34 std::chrono::milliseconds initialDelay { 500 };
35
36 /// Multiplier applied to the delay after each failed attempt (exponential backoff).
37 double backoffMultiplier = 2.0;
38
39 /// Upper bound on any single delay, so a long budget cannot produce an unbounded wait.
40 std::chrono::milliseconds maxDelay { 30'000 };
41
42 /// Deadline expressed as a budget: once the delays already spent plus the next planned delay
43 /// would exceed this, the policy gives up with @ref SqlRetryGiveUpReason::DelayBudgetExhausted
44 /// instead of waiting. Zero — the default — means "no deadline, bounded only by
45 /// @ref maxRetries".
46 ///
47 /// A budget is used rather than a wall-clock deadline so that the decision stays pure: it
48 /// depends only on the caller-supplied @ref SqlRetryState, never on the current time.
49 std::chrono::milliseconds totalDelayBudget { 0 };
50};
51
52/// @ingroup Retry
53/// How far a retry loop has already got. Threaded through @ref SqlRetryPolicy::Decide so the
54/// decision itself stays a pure function of its inputs.
56{
57 /// Number of retries already consumed. Zero on the first failure.
58 unsigned retriesSoFar = 0;
59
60 /// Sum of the delays already waited out, checked against
61 /// @ref SqlRetrySettings::totalDelayBudget.
62 std::chrono::milliseconds delaySoFar { 0 };
63};
64
65/// @ingroup Retry
66/// What a retry loop should do after an attempt failed.
67enum class SqlRetryAction : std::uint8_t
68{
69 /// Wait for @ref SqlRetryDecision::delay and try again.
70 Retry,
71
72 /// Surface the failure to the caller.
73 GiveUp,
74};
75
76/// @ingroup Retry
77/// Why @ref SqlRetryPolicy::Decide declined to retry. Reported so callers and logs can tell an
78/// exhausted budget apart from an error that was never retryable to begin with.
79enum class SqlRetryGiveUpReason : std::uint8_t
80{
81 /// Not giving up — set when the action is @ref SqlRetryAction::Retry.
82 None,
83
84 /// The error is permanent; another attempt would fail identically.
86
87 /// The error was transient, but @ref SqlRetrySettings::maxRetries is spent.
89
90 /// The error was transient and retries remained, but the next delay would overrun
91 /// @ref SqlRetrySettings::totalDelayBudget.
93};
94
95/// @ingroup Retry
96/// The outcome of @ref SqlRetryPolicy::Decide.
98{
99 /// Whether to retry or to surface the failure.
101
102 /// How long to wait before the next attempt. Zero unless @ref action is
103 /// @ref SqlRetryAction::Retry.
104 std::chrono::milliseconds delay {};
105
106 /// Why the policy gave up. @ref SqlRetryGiveUpReason::None when it did not.
108
109 /// @return @c true when the caller should retry.
110 [[nodiscard]] constexpr explicit operator bool() const noexcept
111 {
113 }
114};
115
116/// @ingroup Retry
117/// Description of one retry about to happen, handed to a @ref SqlRetryPolicy::RetryObserver.
119{
120 /// Which retry this is, counting from one.
121 unsigned retryNumber {};
122
123 /// The configured budget, so an observer can render "retry 2/3" without holding the settings.
124 unsigned maxRetries {};
125
126 /// How long the driver is about to sleep before re-running the operation.
127 std::chrono::milliseconds delay {};
128
129 /// The error that triggered the retry.
131};
132
133/// @ingroup Retry
134/// @brief Supplies the wait between retries.
135///
136/// Injected rather than called directly so a test can drive a full retry loop in microseconds and
137/// assert on the delays that *would* have been waited out, instead of actually sleeping through an
138/// exponential backoff.
139class LIGHTWEIGHT_API SqlRetrySleeper
140{
141 public:
142 SqlRetrySleeper() = default;
143 /// Polymorphic destructor.
144 virtual ~SqlRetrySleeper() = default;
145
146 SqlRetrySleeper(SqlRetrySleeper const&) = delete;
147 SqlRetrySleeper& operator=(SqlRetrySleeper const&) = delete;
149 SqlRetrySleeper& operator=(SqlRetrySleeper&&) = delete;
150
151 /// Blocks the calling thread for the given duration.
152 ///
153 /// @param duration How long to wait.
154 virtual void Sleep(std::chrono::milliseconds duration) = 0;
155};
156
157/// @ingroup Retry
158/// @brief Returns the production sleeper, which forwards to @c std::this_thread::sleep_for.
159[[nodiscard]] LIGHTWEIGHT_API SqlRetrySleeper& ThreadSleeper() noexcept;
160
161/// @ingroup Retry
162/// @brief A reusable retry/backoff policy for transient database failures.
163///
164/// Combines a @ref SqlRetryClassifier (which errors are worth another attempt — a per-DBMS
165/// question) with @ref SqlRetrySettings (how many attempts, how long to wait between them). Every
166/// collaborator is injectable, and none of them is constructed internally: the classifier and the
167/// sleeper are borrowed references with sensible process-wide defaults.
168///
169/// The type separates the *decision* from the *driving*:
170///
171/// - @ref Decide is pure. Given an error and how far the loop has already got, it says retry or
172/// give up, and why. No clock, no sleep, no I/O — so every branch is reachable from a unit test.
173/// - @ref Execute / @ref TryExecute are the thin drivers that call @ref Decide in a loop, wait via
174/// the injected @ref SqlRetrySleeper, and re-run the callable.
175///
176/// @code
177/// auto const policy = SqlRetryPolicy::For(connection);
178/// auto const orderCount = policy.Execute([&] {
179/// return SqlStatement { connection }
180/// .ExecuteDirectScalar<int>("SELECT COUNT(*) FROM orders")
181/// .value_or(0);
182/// });
183/// @endcode
184///
185/// @note The callable must be safe to run more than once. Retrying an operation that already had a
186/// visible side effect is the caller's responsibility; wrap it in a transaction that the
187/// callable itself begins and commits, so a retry starts from a clean slate.
188class [[nodiscard]] SqlRetryPolicy
189{
190 public:
191 /// Notified just before each retry. Used, for instance, to route retry notices into a
192 /// progress reporter or a log.
193 using RetryObserver = std::function<void(SqlRetryAttempt const&)>;
194
195 /// Constructs a policy with the default settings, the dialect-agnostic classifier and the
196 /// real sleeper.
197 SqlRetryPolicy() = default;
198
199 /// Constructs a policy.
200 ///
201 /// @param settings The backoff configuration.
202 /// @param classifier Which errors are retryable; @c nullptr selects @ref GenericRetryOps().
203 /// The referenced classifier must outlive the policy — the dialect
204 /// singletons always do.
205 /// @param sleeper How to wait between attempts; @c nullptr selects @ref ThreadSleeper().
206 /// The referenced sleeper must outlive the policy.
207 /// @param observer Called before each retry; may be empty.
208 LIGHTWEIGHT_API explicit SqlRetryPolicy(SqlRetrySettings settings,
209 SqlRetryClassifier const* classifier = nullptr,
210 SqlRetrySleeper* sleeper = nullptr,
211 RetryObserver observer = {});
212
213 /// Builds a policy whose classifier matches the given server type.
214 ///
215 /// The mapping runs through @c SqlQueryFormatter::Get(), so the per-DBMS knowledge stays at
216 /// the formatter dispatch point. A server type without a formatter of its own falls back to
217 /// @ref GenericRetryOps().
218 ///
219 /// @param serverType The DBMS whose error dialect should be used.
220 /// @param settings The backoff configuration.
221 /// @return The configured policy.
222 [[nodiscard]] LIGHTWEIGHT_API static SqlRetryPolicy For(SqlServerType serverType, SqlRetrySettings settings = {});
223
224 /// Builds a policy whose classifier matches the connection's DBMS.
225 ///
226 /// @param connection The connection whose server type selects the classifier.
227 /// @param settings The backoff configuration.
228 /// @return The configured policy.
229 [[nodiscard]] LIGHTWEIGHT_API static SqlRetryPolicy For(SqlConnection const& connection, SqlRetrySettings settings = {});
230
231 /// @return The backoff configuration in effect.
232 [[nodiscard]] SqlRetrySettings const& Settings() const noexcept
233 {
234 return _settings;
235 }
236
237 /// @return The classifier in effect.
238 [[nodiscard]] SqlRetryClassifier const& Classifier() const noexcept
239 {
240 return *_classifier;
241 }
242
243 /// Installs an observer notified before each retry.
244 ///
245 /// @param observer The observer; pass an empty function to remove a previously set one.
247 {
248 _observer = std::move(observer);
249 }
250
251 /// Computes the backoff delay preceding a given retry.
252 ///
253 /// @param retryIndex Zero-based retry number: @c 0 is the delay before the first retry.
254 /// @return @c initialDelay multiplied by @c backoffMultiplier @p retryIndex times, clamped to
255 /// @c maxDelay.
256 [[nodiscard]] LIGHTWEIGHT_API std::chrono::milliseconds DelayFor(unsigned retryIndex) const noexcept;
257
258 /// Decides what to do after a failed attempt.
259 ///
260 /// Pure: no I/O, no clock, no hidden state.
261 ///
262 /// @param error The error reported by the failed attempt.
263 /// @param state How far the retry loop has already got.
264 /// @return Whether to retry, how long to wait first, and — if not — why not.
265 [[nodiscard]] LIGHTWEIGHT_API SqlRetryDecision Decide(SqlErrorInfo const& error,
266 SqlRetryState const& state) const noexcept;
267
268 /// Runs @p callable, retrying while the policy says the failure is worth another attempt.
269 ///
270 /// Only @c SqlException is treated as a retry candidate; any other exception propagates
271 /// immediately. When the policy gives up, the last @c SqlException is rethrown unchanged, so
272 /// the caller sees the original diagnostics rather than a wrapper.
273 ///
274 /// @tparam Callable A nullary callable, taken by value because it is invoked repeatedly.
275 /// @param callable The operation to run.
276 /// @return Whatever @p callable returns.
277 template <typename Callable>
278 auto Execute(Callable callable) const -> std::invoke_result_t<Callable&>;
279
280 /// Like @ref Execute, but reports a final failure as @c std::unexpected rather than throwing.
281 ///
282 /// @tparam Callable A nullary callable, taken by value because it is invoked repeatedly.
283 /// @param callable The operation to run.
284 /// @return The callable's result, or the @ref SqlErrorInfo of the attempt the policy gave up on.
285 template <typename Callable>
286 [[nodiscard]] auto TryExecute(Callable callable) const -> std::expected<std::invoke_result_t<Callable&>, SqlErrorInfo>;
287
288 private:
289 LIGHTWEIGHT_API void NotifyRetry(SqlRetryAttempt const& attempt) const;
290
291 SqlRetrySettings _settings {};
292 SqlRetryClassifier const* _classifier = &GenericRetryOps();
293 SqlRetrySleeper* _sleeper = &ThreadSleeper();
294 RetryObserver _observer {};
295};
296
297template <typename Callable>
298auto SqlRetryPolicy::Execute(Callable callable) const -> std::invoke_result_t<Callable&>
299{
300 auto state = SqlRetryState {};
301
302 while (true)
303 {
304 try
305 {
306 return callable();
307 }
308 catch (SqlException const& e)
309 {
310 auto const decision = Decide(e.info(), state);
311 if (decision.action == SqlRetryAction::GiveUp)
312 throw;
313
314 state.retriesSoFar += 1;
315 state.delaySoFar += decision.delay;
316
317 NotifyRetry(SqlRetryAttempt { .retryNumber = state.retriesSoFar,
318 .maxRetries = _settings.maxRetries,
319 .delay = decision.delay,
320 .error = e.info() });
321
322 _sleeper->Sleep(decision.delay);
323 }
324 }
325}
326
327template <typename Callable>
328auto SqlRetryPolicy::TryExecute(Callable callable) const -> std::expected<std::invoke_result_t<Callable&>, SqlErrorInfo>
329{
330 using Result = std::invoke_result_t<Callable&>;
331
332 try
333 {
334 if constexpr (std::is_void_v<Result>)
335 {
336 Execute(std::move(callable));
337 return {};
338 }
339 else
340 return Execute(std::move(callable));
341 }
342 catch (SqlException const& e)
343 {
344 return std::unexpected { e.info() };
345 }
346}
347
348} // namespace Lightweight
Represents a connection to a SQL database.
Dialect-specific classification of SQL errors into transient and permanent.
A reusable retry/backoff policy for transient database failures.
LIGHTWEIGHT_API std::chrono::milliseconds DelayFor(unsigned retryIndex) const noexcept
auto TryExecute(Callable callable) const -> std::expected< std::invoke_result_t< Callable & >, SqlErrorInfo >
void SetRetryObserver(RetryObserver observer)
static LIGHTWEIGHT_API SqlRetryPolicy For(SqlConnection const &connection, SqlRetrySettings settings={})
SqlRetrySettings const & Settings() const noexcept
static LIGHTWEIGHT_API SqlRetryPolicy For(SqlServerType serverType, SqlRetrySettings settings={})
LIGHTWEIGHT_API SqlRetryDecision Decide(SqlErrorInfo const &error, SqlRetryState const &state) const noexcept
std::function< void(SqlRetryAttempt const &)> RetryObserver
LIGHTWEIGHT_API SqlRetryPolicy(SqlRetrySettings settings, SqlRetryClassifier const *classifier=nullptr, SqlRetrySleeper *sleeper=nullptr, RetryObserver observer={})
auto Execute(Callable callable) const -> std::invoke_result_t< Callable & >
SqlRetryClassifier const & Classifier() const noexcept
Supplies the wait between retries.
virtual void Sleep(std::chrono::milliseconds duration)=0
virtual ~SqlRetrySleeper()=default
Polymorphic destructor.
@ Execute
SQLExecute of a prepared statement.
LIGHTWEIGHT_API SqlRetryClassifier const & GenericRetryOps() noexcept
Returns the dialect-agnostic classifier.
LIGHTWEIGHT_API SqlRetrySleeper & ThreadSleeper() noexcept
Returns the production sleeper, which forwards to std::this_thread::sleep_for.
@ Retry
Wait for SqlRetryDecision::delay and try again.
@ GiveUp
Surface the failure to the caller.
@ RetriesExhausted
The error was transient, but SqlRetrySettings::maxRetries is spent.
@ NotTransient
The error is permanent; another attempt would fail identically.
@ None
Not giving up — set when the action is SqlRetryAction::Retry.
Represents an ODBC SQL error.
Definition SqlError.hpp:32
unsigned maxRetries
The configured budget, so an observer can render "retry 2/3" without holding the settings.
SqlErrorInfo error
The error that triggered the retry.
std::chrono::milliseconds delay
How long the driver is about to sleep before re-running the operation.
unsigned retryNumber
Which retry this is, counting from one.
std::chrono::milliseconds delay
SqlRetryAction action
Whether to retry or to surface the failure.
SqlRetryGiveUpReason reason
Why the policy gave up. SqlRetryGiveUpReason::None when it did not.
std::chrono::milliseconds initialDelay
Delay before the first retry.
double backoffMultiplier
Multiplier applied to the delay after each failed attempt (exponential backoff).
std::chrono::milliseconds maxDelay
Upper bound on any single delay, so a long budget cannot produce an unbounded wait.
std::chrono::milliseconds totalDelayBudget
unsigned retriesSoFar
Number of retries already consumed. Zero on the first failure.
std::chrono::milliseconds delaySoFar