commit 27d6013f31a15ccf7b582ea5b90f459757740883
parent a922bf033de0d9db259af4e8b52c8ba513f328c4
Author: Florian Dold <dold@taler.net>
Date: Wed, 29 Jul 2026 13:48:06 +0200
wallet-core: honour the stored retry backoff when a task is woken up
The backoff was only enforced by the wait inside one run of the shepherd loop,
so restarting the wallet or hinting that the application resumed re-issued the
request at once — a client that resumes once a second claimed an order once a
second.
Issue: https://bugs.taler.net/n/11087
Diffstat:
2 files changed, 65 insertions(+), 0 deletions(-)
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -585,6 +585,11 @@ const defaultRetryPolicy: RetryPolicy = {
maxTimeout: Duration.fromSpec({ minutes: 2 }),
};
+/**
+ * Longest delay the retry policy ever asks for, whatever the retry counter.
+ */
+export const MAX_RETRY_DURATION: Duration = defaultRetryPolicy.maxTimeout;
+
export function getRetryDuration(count: number): Duration {
const p = defaultRetryPolicy;
return Duration.min(
diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts
@@ -41,6 +41,7 @@ import {
} from "@gnu-taler/taler-util";
import {
DbRetryInfo,
+ MAX_RETRY_DURATION,
PendingTaskType,
TaskIdStr,
TaskRunResult,
@@ -433,6 +434,18 @@ export class TaskSchedulerImpl implements TaskScheduler {
logger.trace(`Not waiting on ${taskId}, a rerun is already requested`);
return;
}
+ await this.sleep(taskId, info, delay);
+ }
+
+ /**
+ * Sleep for at most the given delay, returning early when the task is
+ * stopped or when a wakeup interrupts the sleep.
+ */
+ private async sleep(
+ taskId: TaskIdStr,
+ info: ShepherdInfo,
+ delay: Duration,
+ ): Promise<void> {
// Limit how long we wait.
// The NodeJS runtime doesn't like very long timeouts.
delay = Duration.min(delay, Duration.fromSpec({ hours: 1 }));
@@ -456,6 +469,42 @@ export class TaskSchedulerImpl implements TaskScheduler {
}
}
+ /**
+ * How much is left of the backoff that the task's last failure earned.
+ *
+ * Only a failing task has one. A task that scheduled its own next run, or
+ * that is parked until the network comes back, has no error stored and must
+ * run as soon as it is woken up.
+ */
+ private async remainingErrorBackoff(
+ taskId: TaskIdStr,
+ ): Promise<Duration | undefined> {
+ const retryRecord = await this.ws.runStandaloneWalletDbTx(async (tx) => {
+ return tx.getOperationRetry(taskId);
+ });
+ if (!retryRecord?.lastError) {
+ return undefined;
+ }
+ const remaining = AbsoluteTime.remaining(
+ timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry),
+ );
+ if (remaining.d_ms !== "forever" && remaining.d_ms <= 0) {
+ return undefined;
+ }
+ // The deadline is wall-clock time read back from the database, while the
+ // policy that wrote it never asks for more than MAX_RETRY_DURATION. A
+ // longer wait therefore means the clock moved backwards since, and honouring
+ // it would park the task for as long as the clock was off. Running now
+ // repairs the record: the next failure stores a deadline under this clock.
+ if (Duration.cmp(remaining, MAX_RETRY_DURATION) > 0) {
+ logger.warn(
+ `backoff of ${taskId} exceeds the maximum, ignoring it (clock change?)`,
+ );
+ return undefined;
+ }
+ return remaining;
+ }
+
private async internalShepherdTask(
taskId: TaskIdStr,
info: ShepherdInfo,
@@ -468,6 +517,17 @@ export class TaskSchedulerImpl implements TaskScheduler {
logger.trace(`Shepherd for ${taskId} got cancelled`);
return;
}
+ // The backoff of a failing task is stored in the database and has to
+ // outlive this loop. Without that, every wakeup and every restart of
+ // the wallet repeats the request immediately, so a client that resumes
+ // the application once a second makes us retry once a second. An
+ // explicit retry still takes effect at once: it clears the error first.
+ const backoff = await this.remainingErrorBackoff(taskId);
+ if (backoff) {
+ logger.trace(`Deferring ${taskId}, still backing off from an error`);
+ await this.sleep(taskId, info, backoff);
+ continue;
+ }
const isThrottled = this.throttler.applyThrottle(taskId);
if (isThrottled) {
logger.warn(