taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-wallet-core/src/common.ts | 5+++++
Mpackages/taler-wallet-core/src/shepherd.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(