wallet: make retries more robust and consistent

This commit is contained in:
Florian Dold 2022-03-29 13:47:32 +02:00
parent be489b6b3e
commit c265e7d019
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
23 changed files with 476 additions and 443 deletions

View File

@ -205,4 +205,12 @@ export class Logger {
); );
} }
} }
reportBreak(): void {
if (!this.shouldLogError()) {
return;
}
const location = new Error("programming error");
this.error(`assertion failed: ${location.stack}`);
}
} }

View File

@ -20,6 +20,7 @@
* Imports. * Imports.
*/ */
import { import {
DEFAULT_REQUEST_TIMEOUT_MS,
Headers, Headers,
HttpRequestLibrary, HttpRequestLibrary,
HttpRequestOptions, HttpRequestOptions,
@ -65,13 +66,16 @@ export class NodeHttpLib implements HttpRequestLibrary {
`request to origin ${parsedUrl.origin} was throttled`, `request to origin ${parsedUrl.origin} was throttled`,
); );
} }
let timeout: number | undefined; let timeoutMs: number | undefined;
if (typeof opt?.timeout?.d_ms === "number") { if (typeof opt?.timeout?.d_ms === "number") {
timeout = opt.timeout.d_ms; timeoutMs = opt.timeout.d_ms;
} else {
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
} }
// FIXME: Use AbortController / etc. to handle cancellation
let resp: AxiosResponse; let resp: AxiosResponse;
try { try {
resp = await Axios({ let respPromise = Axios({
method, method,
url: url, url: url,
responseType: "arraybuffer", responseType: "arraybuffer",
@ -79,9 +83,13 @@ export class NodeHttpLib implements HttpRequestLibrary {
validateStatus: () => true, validateStatus: () => true,
transformResponse: (x) => x, transformResponse: (x) => x,
data: body, data: body,
timeout, timeout: timeoutMs,
maxRedirects: 0, maxRedirects: 0,
}); });
if (opt?.cancellationToken) {
respPromise = opt.cancellationToken.racePromise(respPromise);
}
resp = await respPromise;
} catch (e: any) { } catch (e: any) {
throw TalerError.fromDetail( throw TalerError.fromDetail(
TalerErrorCode.WALLET_NETWORK_ERROR, TalerErrorCode.WALLET_NETWORK_ERROR,
@ -94,11 +102,13 @@ export class NodeHttpLib implements HttpRequestLibrary {
} }
const makeText = async (): Promise<string> => { const makeText = async (): Promise<string> => {
opt?.cancellationToken?.throwIfCancelled();
const respText = new Uint8Array(resp.data); const respText = new Uint8Array(resp.data);
return bytesToString(respText); return bytesToString(respText);
}; };
const makeJson = async (): Promise<any> => { const makeJson = async (): Promise<any> => {
opt?.cancellationToken?.throwIfCancelled();
let responseJson; let responseJson;
const respText = await makeText(); const respText = await makeText();
try { try {
@ -130,6 +140,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
return responseJson; return responseJson;
}; };
const makeBytes = async () => { const makeBytes = async () => {
opt?.cancellationToken?.throwIfCancelled();
if (typeof resp.data.byteLength !== "number") { if (typeof resp.data.byteLength !== "number") {
throw Error("expected array buffer"); throw Error("expected array buffer");
} }
@ -150,6 +161,7 @@ export class NodeHttpLib implements HttpRequestLibrary {
bytes: makeBytes, bytes: makeBytes,
}; };
} }
async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { async get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> {
return this.fetch(url, { return this.fetch(url, {
method: "GET", method: "GET",

View File

@ -77,7 +77,9 @@ export interface ReserveOperations {
processReserve( processReserve(
ws: InternalWalletState, ws: InternalWalletState,
reservePub: string, reservePub: string,
forceNow?: boolean, options?: {
forceNow?: boolean;
},
): Promise<void>; ): Promise<void>;
} }
@ -101,8 +103,10 @@ export interface ExchangeOperations {
updateExchangeFromUrl( updateExchangeFromUrl(
ws: InternalWalletState, ws: InternalWalletState,
baseUrl: string, baseUrl: string,
acceptedFormat?: string[], options?: {
forceNow?: boolean, forceNow?: boolean;
cancellationToken?: CancellationToken;
},
): Promise<{ ): Promise<{
exchange: ExchangeRecord; exchange: ExchangeRecord;
exchangeDetails: ExchangeDetailsRecord; exchangeDetails: ExchangeDetailsRecord;
@ -123,7 +127,9 @@ export interface RecoupOperations {
processRecoupGroup( processRecoupGroup(
ws: InternalWalletState, ws: InternalWalletState,
recoupGroupId: string, recoupGroupId: string,
forceNow?: boolean, options?: {
forceNow?: boolean;
},
): Promise<void>; ): Promise<void>;
} }
@ -201,13 +207,8 @@ export interface InternalWalletState {
memoGetBalance: AsyncOpMemoSingle<BalancesResponse>; memoGetBalance: AsyncOpMemoSingle<BalancesResponse>;
memoProcessRefresh: AsyncOpMemoMap<void>; memoProcessRefresh: AsyncOpMemoMap<void>;
memoProcessRecoup: AsyncOpMemoMap<void>; memoProcessRecoup: AsyncOpMemoMap<void>;
cryptoApi: TalerCryptoInterface;
/** cryptoApi: TalerCryptoInterface;
* Cancellation token for the currently running
* deposit operation, if any.
*/
taskCancellationSourceForDeposit?: CancellationToken.Source;
timerGroup: TimerGroup; timerGroup: TimerGroup;
stopped: boolean; stopped: boolean;

View File

@ -17,7 +17,7 @@ Generally, the code to process a pending operation should first increment the
retryInfo (and reset the lastError) and then process the operation. This way, retryInfo (and reset the lastError) and then process the operation. This way,
it is impossble to forget incrementing the retryInfo. it is impossble to forget incrementing the retryInfo.
For each retriable operation, there are usually `reset<Op>Retry`, `increment<Op>Retry` and For each retriable operation, there are usually `setup<Op>Retry`, `increment<Op>Retry` and
`report<Op>Error` operations. `report<Op>Error` operations.
Note that this means that _during_ some operation, lastError will be cleared. The UI Note that this means that _during_ some operation, lastError will be cleared. The UI

View File

@ -57,7 +57,7 @@ import {
checkLogicInvariant, checkLogicInvariant,
} from "../../util/invariants.js"; } from "../../util/invariants.js";
import { Logger } from "@gnu-taler/taler-util"; import { Logger } from "@gnu-taler/taler-util";
import { initRetryInfo } from "../../util/retries.js"; import { resetRetryInfo } from "../../util/retries.js";
import { InternalWalletState } from "../../internal-wallet-state.js"; import { InternalWalletState } from "../../internal-wallet-state.js";
import { provideBackupState } from "./state.js"; import { provideBackupState } from "./state.js";
import { makeEventId, TombstoneTag } from "../transactions.js"; import { makeEventId, TombstoneTag } from "../transactions.js";
@ -276,7 +276,7 @@ export async function importBackup(
protocolVersionRange: backupExchange.protocol_version_range, protocolVersionRange: backupExchange.protocol_version_range,
}, },
permanent: true, permanent: true,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
lastUpdate: undefined, lastUpdate: undefined,
nextUpdate: TalerProtocolTimestamp.now(), nextUpdate: TalerProtocolTimestamp.now(),
nextRefreshCheck: TalerProtocolTimestamp.now(), nextRefreshCheck: TalerProtocolTimestamp.now(),
@ -464,7 +464,7 @@ export async function importBackup(
timestampReserveInfoPosted: timestampReserveInfoPosted:
backupReserve.bank_info?.timestamp_reserve_info_posted, backupReserve.bank_info?.timestamp_reserve_info_posted,
senderWire: backupReserve.sender_wire, senderWire: backupReserve.sender_wire,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
lastError: undefined, lastError: undefined,
initialWithdrawalGroupId: initialWithdrawalGroupId:
backupReserve.initial_withdrawal_group_id, backupReserve.initial_withdrawal_group_id,
@ -505,7 +505,7 @@ export async function importBackup(
backupWg.raw_withdrawal_amount, backupWg.raw_withdrawal_amount,
), ),
reservePub, reservePub,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
secretSeed: backupWg.secret_seed, secretSeed: backupWg.secret_seed,
timestampStart: backupWg.timestamp_created, timestampStart: backupWg.timestamp_created,
timestampFinish: backupWg.timestamp_finish, timestampFinish: backupWg.timestamp_finish,
@ -618,7 +618,7 @@ export async function importBackup(
cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv], cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
proposalId: backupProposal.proposal_id, proposalId: backupProposal.proposal_id,
repurchaseProposalId: backupProposal.repurchase_proposal_id, repurchaseProposalId: backupProposal.repurchase_proposal_id,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
download, download,
proposalStatus, proposalStatus,
}); });
@ -753,7 +753,7 @@ export async function importBackup(
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv], cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
lastPayError: undefined, lastPayError: undefined,
autoRefundDeadline: TalerProtocolTimestamp.never(), autoRefundDeadline: TalerProtocolTimestamp.never(),
refundStatusRetryInfo: initRetryInfo(), refundStatusRetryInfo: resetRetryInfo(),
lastRefundStatusError: undefined, lastRefundStatusError: undefined,
timestampAccept: backupPurchase.timestamp_accept, timestampAccept: backupPurchase.timestamp_accept,
timestampFirstSuccessfulPay: timestampFirstSuccessfulPay:
@ -763,7 +763,7 @@ export async function importBackup(
lastSessionId: undefined, lastSessionId: undefined,
abortStatus, abortStatus,
// FIXME! // FIXME!
payRetryInfo: initRetryInfo(), payRetryInfo: resetRetryInfo(),
download, download,
paymentSubmitPending: paymentSubmitPending:
!backupPurchase.timestamp_first_successful_pay, !backupPurchase.timestamp_first_successful_pay,
@ -864,7 +864,7 @@ export async function importBackup(
Amounts.parseOrThrow(x.estimated_output_amount), Amounts.parseOrThrow(x.estimated_output_amount),
), ),
refreshSessionPerCoin, refreshSessionPerCoin,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
}); });
} }
} }
@ -890,7 +890,7 @@ export async function importBackup(
merchantBaseUrl: backupTip.exchange_base_url, merchantBaseUrl: backupTip.exchange_base_url,
merchantTipId: backupTip.merchant_tip_id, merchantTipId: backupTip.merchant_tip_id,
pickedUpTimestamp: backupTip.timestamp_finished, pickedUpTimestamp: backupTip.timestamp_finished,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
secretSeed: backupTip.secret_seed, secretSeed: backupTip.secret_seed,
tipAmountEffective: denomsSel.totalCoinValue, tipAmountEffective: denomsSel.totalCoinValue,
tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw), tipAmountRaw: Amounts.parseOrThrow(backupTip.tip_amount_raw),

View File

@ -89,7 +89,7 @@ import {
checkLogicInvariant, checkLogicInvariant,
} from "../../util/invariants.js"; } from "../../util/invariants.js";
import { GetReadWriteAccess } from "../../util/query.js"; import { GetReadWriteAccess } from "../../util/query.js";
import { initRetryInfo, updateRetryInfoTimeout } from "../../util/retries.js"; import { resetRetryInfo, updateRetryInfoTimeout } from "../../util/retries.js";
import { import {
checkPaymentByProposalId, checkPaymentByProposalId,
confirmPay, confirmPay,
@ -434,7 +434,7 @@ async function runBackupCycleForProvider(
// FIXME: Allocate error code for this situation? // FIXME: Allocate error code for this situation?
prov.state = { prov.state = {
tag: BackupProviderStateTag.Retrying, tag: BackupProviderStateTag.Retrying,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
}; };
await tx.backupProvider.put(prov); await tx.backupProvider.put(prov);
}); });
@ -478,7 +478,7 @@ async function incrementBackupRetryInTx(
} else if (pr.state.tag === BackupProviderStateTag.Ready) { } else if (pr.state.tag === BackupProviderStateTag.Ready) {
pr.state = { pr.state = {
tag: BackupProviderStateTag.Retrying, tag: BackupProviderStateTag.Retrying,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
lastError: err, lastError: err,
}; };
} }

View File

@ -47,7 +47,7 @@ export async function guardOperationException<T>(
throw TalerError.fromDetail( throw TalerError.fromDetail(
TalerErrorCode.WALLET_PENDING_OPERATION_FAILED, TalerErrorCode.WALLET_PENDING_OPERATION_FAILED,
{ {
innerError: e.errorDetail, innerError: opErr,
}, },
); );
} }

View File

@ -41,11 +41,11 @@ import {
TrackDepositGroupResponse, TrackDepositGroupResponse,
URL, URL,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { DepositGroupRecord, OperationStatus } from "../db.js"; import { DepositGroupRecord, OperationStatus, WireFee } from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { PayCoinSelection, selectPayCoins } from "../util/coinSelection.js"; import { PayCoinSelection, selectPayCoins } from "../util/coinSelection.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { initRetryInfo, RetryInfo } from "../util/retries.js"; import { resetRetryInfo, RetryInfo } from "../util/retries.js";
import { guardOperationException } from "./common.js"; import { guardOperationException } from "./common.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { import {
@ -63,9 +63,15 @@ import { getTotalRefreshCost } from "./refresh.js";
*/ */
const logger = new Logger("deposits.ts"); const logger = new Logger("deposits.ts");
async function resetDepositGroupRetry( /**
* Set up the retry timeout for a deposit group.
*/
async function setupDepositGroupRetry(
ws: InternalWalletState, ws: InternalWalletState,
depositGroupId: string, depositGroupId: string,
options: {
resetRetry: boolean;
},
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
@ -76,29 +82,19 @@ async function resetDepositGroupRetry(
if (!x) { if (!x) {
return; return;
} }
x.retryInfo = initRetryInfo(); if (options.resetRetry) {
x.retryInfo = resetRetryInfo();
} else {
x.retryInfo = RetryInfo.increment(x.retryInfo);
}
delete x.lastError; delete x.lastError;
await tx.depositGroups.put(x); await tx.depositGroups.put(x);
}); });
} }
async function incrementDepositGroupRetry( /**
ws: InternalWalletState, * Report an error that occurred while processing the deposit group.
depositGroupId: string, */
): Promise<void> {
await ws.db
.mktx((x) => ({ depositGroups: x.depositGroups }))
.runReadWrite(async (tx) => {
const r = await tx.depositGroups.get(depositGroupId);
if (!r) {
return;
}
r.retryInfo = RetryInfo.increment(r.retryInfo);
delete r.lastError;
await tx.depositGroups.put(r);
});
}
async function reportDepositGroupError( async function reportDepositGroupError(
ws: InternalWalletState, ws: InternalWalletState,
depositGroupId: string, depositGroupId: string,
@ -131,9 +127,6 @@ export async function processDepositGroup(
cancellationToken?: CancellationToken; cancellationToken?: CancellationToken;
} = {}, } = {},
): Promise<void> { ): Promise<void> {
if (ws.taskCancellationSourceForDeposit) {
ws.taskCancellationSourceForDeposit.cancel();
}
const onOpErr = (err: TalerErrorDetail): Promise<void> => const onOpErr = (err: TalerErrorDetail): Promise<void> =>
reportDepositGroupError(ws, depositGroupId, err); reportDepositGroupError(ws, depositGroupId, err);
return await guardOperationException( return await guardOperationException(
@ -170,11 +163,7 @@ async function processDepositGroupImpl(
return; return;
} }
if (forceNow) { await setupDepositGroupRetry(ws, depositGroupId, { resetRetry: forceNow });
await resetDepositGroupRetry(ws, depositGroupId);
} else {
await incrementDepositGroupRetry(ws, depositGroupId);
}
const contractData = extractContractData( const contractData = extractContractData(
depositGroup.contractTermsRaw, depositGroup.contractTermsRaw,
@ -315,7 +304,7 @@ export async function trackDepositGroup(
export async function getFeeForDeposit( export async function getFeeForDeposit(
ws: InternalWalletState, ws: InternalWalletState,
req: GetFeeForDepositRequest, req: GetFeeForDepositRequest,
): Promise<DepositFee> { ): Promise<DepositGroupFees> {
const p = parsePaytoUri(req.depositPaytoUri); const p = parsePaytoUri(req.depositPaytoUri);
if (!p) { if (!p) {
throw Error("invalid payto URI"); throw Error("invalid payto URI");
@ -370,7 +359,7 @@ export async function getFeeForDeposit(
throw Error("insufficient funds"); throw Error("insufficient funds");
} }
return await getTotalFeeForDepositAmount( return await getTotalFeesForDepositAmount(
ws, ws,
p.targetType, p.targetType,
amount, amount,
@ -429,14 +418,12 @@ export async function createDepositGroup(
nonce: noncePair.pub, nonce: noncePair.pub,
wire_transfer_deadline: nowRounded, wire_transfer_deadline: nowRounded,
order_id: "", order_id: "",
// This is always the v2 wire hash, as we're the "merchant" and support v2.
h_wire: wireHash, h_wire: wireHash,
// Required for older exchanges.
pay_deadline: AbsoluteTime.toTimestamp( pay_deadline: AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })), AbsoluteTime.addDuration(now, durationFromSpec({ hours: 1 })),
), ),
merchant: { merchant: {
name: "", name: "(wallet)",
}, },
merchant_pub: merchantPair.pub, merchant_pub: merchantPair.pub,
refund_deadline: TalerProtocolTimestamp.zero(), refund_deadline: TalerProtocolTimestamp.zero(),
@ -505,7 +492,7 @@ export async function createDepositGroup(
payto_uri: req.depositPaytoUri, payto_uri: req.depositPaytoUri,
salt: wireSalt, salt: wireSalt,
}, },
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
operationStatus: OperationStatus.Pending, operationStatus: OperationStatus.Pending,
lastError: undefined, lastError: undefined,
}; };
@ -594,8 +581,7 @@ export async function getEffectiveDepositAmount(
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount; return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
} }
// FIXME: rename to DepositGroupFee export interface DepositGroupFees {
export interface DepositFee {
coin: AmountJson; coin: AmountJson;
wire: AmountJson; wire: AmountJson;
refresh: AmountJson; refresh: AmountJson;
@ -605,12 +591,12 @@ export interface DepositFee {
* Get the fee amount that will be charged when trying to deposit the * Get the fee amount that will be charged when trying to deposit the
* specified amount using the selected coins and the wire method. * specified amount using the selected coins and the wire method.
*/ */
export async function getTotalFeeForDepositAmount( export async function getTotalFeesForDepositAmount(
ws: InternalWalletState, ws: InternalWalletState,
wireType: string, wireType: string,
total: AmountJson, total: AmountJson,
pcs: PayCoinSelection, pcs: PayCoinSelection,
): Promise<DepositFee> { ): Promise<DepositGroupFees> {
const wireFee: AmountJson[] = []; const wireFee: AmountJson[] = [];
const coinFee: AmountJson[] = []; const coinFee: AmountJson[] = [];
const refreshFee: AmountJson[] = []; const refreshFee: AmountJson[] = [];
@ -638,8 +624,6 @@ export async function getTotalFeeForDepositAmount(
if (!denom) { if (!denom) {
throw Error("can't find denomination to calculate deposit amount"); throw Error("can't find denomination to calculate deposit amount");
} }
// const cc = pcs.coinContributions[i]
// acc = Amounts.add(acc, cc).amount
coinFee.push(denom.feeDeposit); coinFee.push(denom.feeDeposit);
exchangeSet.add(coin.exchangeBaseUrl); exchangeSet.add(coin.exchangeBaseUrl);
@ -661,16 +645,15 @@ export async function getTotalFeeForDepositAmount(
if (!exchangeDetails) { if (!exchangeDetails) {
continue; continue;
} }
// FIXME/NOTE: the line below _likely_ throws exception const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
// about "find method not found on undefined" when the wireType (x) => {
// is not supported by the Exchange. return AbsoluteTime.isBetween(
const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => { AbsoluteTime.now(),
return AbsoluteTime.isBetween( AbsoluteTime.fromTimestamp(x.startStamp),
AbsoluteTime.now(), AbsoluteTime.fromTimestamp(x.endStamp),
AbsoluteTime.fromTimestamp(x.startStamp), );
AbsoluteTime.fromTimestamp(x.endStamp), },
); )?.wireFee;
})?.wireFee;
if (fee) { if (fee) {
wireFee.push(fee); wireFee.push(fee);
} }

View File

@ -20,6 +20,7 @@
import { import {
AbsoluteTime, AbsoluteTime,
Amounts, Amounts,
CancellationToken,
canonicalizeBaseUrl, canonicalizeBaseUrl,
codecForExchangeKeysJson, codecForExchangeKeysJson,
codecForExchangeWireJson, codecForExchangeWireJson,
@ -61,11 +62,7 @@ import {
readSuccessResponseTextOrThrow, readSuccessResponseTextOrThrow,
} from "../util/http.js"; } from "../util/http.js";
import { DbAccess, GetReadOnlyAccess } from "../util/query.js"; import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
import { import { resetRetryInfo, RetryInfo } from "../util/retries.js";
initRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
import { import {
WALLET_CACHE_BREAKER_CLIENT_VERSION, WALLET_CACHE_BREAKER_CLIENT_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION,
@ -124,9 +121,12 @@ async function reportExchangeUpdateError(
ws.notify({ type: NotificationType.ExchangeOperationError, error: err }); ws.notify({ type: NotificationType.ExchangeOperationError, error: err });
} }
async function resetExchangeUpdateRetry( async function setupExchangeUpdateRetry(
ws: InternalWalletState, ws: InternalWalletState,
baseUrl: string, baseUrl: string,
options: {
reset: boolean;
},
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ exchanges: x.exchanges })) .mktx((x) => ({ exchanges: x.exchanges }))
@ -135,25 +135,12 @@ async function resetExchangeUpdateRetry(
if (!exchange) { if (!exchange) {
return; return;
} }
delete exchange.lastError; if (options.reset) {
exchange.retryInfo = initRetryInfo(); exchange.retryInfo = resetRetryInfo();
await tx.exchanges.put(exchange); } else {
}); exchange.retryInfo = RetryInfo.increment(exchange.retryInfo);
}
async function incrementExchangeUpdateRetry(
ws: InternalWalletState,
baseUrl: string,
): Promise<void> {
await ws.db
.mktx((x) => ({ exchanges: x.exchanges }))
.runReadWrite(async (tx) => {
const exchange = await tx.exchanges.get(baseUrl);
if (!exchange) {
return;
} }
delete exchange.lastError; delete exchange.lastError;
exchange.retryInfo = RetryInfo.increment(exchange.retryInfo);
await tx.exchanges.put(exchange); await tx.exchanges.put(exchange);
}); });
} }
@ -376,8 +363,10 @@ async function downloadExchangeWireInfo(
export async function updateExchangeFromUrl( export async function updateExchangeFromUrl(
ws: InternalWalletState, ws: InternalWalletState,
baseUrl: string, baseUrl: string,
acceptedFormat?: string[], options: {
forceNow = false, forceNow?: boolean;
cancellationToken?: CancellationToken;
} = {},
): Promise<{ ): Promise<{
exchange: ExchangeRecord; exchange: ExchangeRecord;
exchangeDetails: ExchangeDetailsRecord; exchangeDetails: ExchangeDetailsRecord;
@ -385,7 +374,7 @@ export async function updateExchangeFromUrl(
const onOpErr = (e: TalerErrorDetail): Promise<void> => const onOpErr = (e: TalerErrorDetail): Promise<void> =>
reportExchangeUpdateError(ws, baseUrl, e); reportExchangeUpdateError(ws, baseUrl, e);
return await guardOperationException( return await guardOperationException(
() => updateExchangeFromUrlImpl(ws, baseUrl, acceptedFormat, forceNow), () => updateExchangeFromUrlImpl(ws, baseUrl, options),
onOpErr, onOpErr,
); );
} }
@ -409,7 +398,7 @@ async function provideExchangeRecord(
const r: ExchangeRecord = { const r: ExchangeRecord = {
permanent: true, permanent: true,
baseUrl: baseUrl, baseUrl: baseUrl,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
detailsPointer: undefined, detailsPointer: undefined,
lastUpdate: undefined, lastUpdate: undefined,
nextUpdate: AbsoluteTime.toTimestamp(now), nextUpdate: AbsoluteTime.toTimestamp(now),
@ -552,12 +541,15 @@ export async function downloadTosFromAcceptedFormat(
async function updateExchangeFromUrlImpl( async function updateExchangeFromUrlImpl(
ws: InternalWalletState, ws: InternalWalletState,
baseUrl: string, baseUrl: string,
acceptedFormat?: string[], options: {
forceNow = false, forceNow?: boolean;
cancellationToken?: CancellationToken;
} = {},
): Promise<{ ): Promise<{
exchange: ExchangeRecord; exchange: ExchangeRecord;
exchangeDetails: ExchangeDetailsRecord; exchangeDetails: ExchangeDetailsRecord;
}> { }> {
const forceNow = options.forceNow ?? false;
logger.info(`updating exchange info for ${baseUrl}, forced: ${forceNow}`); logger.info(`updating exchange info for ${baseUrl}, forced: ${forceNow}`);
const now = AbsoluteTime.now(); const now = AbsoluteTime.now();
baseUrl = canonicalizeBaseUrl(baseUrl); baseUrl = canonicalizeBaseUrl(baseUrl);
@ -577,11 +569,7 @@ async function updateExchangeFromUrlImpl(
return { exchange, exchangeDetails }; return { exchange, exchangeDetails };
} }
if (forceNow) { await setupExchangeUpdateRetry(ws, baseUrl, { reset: forceNow });
await resetExchangeUpdateRetry(ws, baseUrl);
} else {
await incrementExchangeUpdateRetry(ws, baseUrl);
}
logger.info("updating exchange /keys info"); logger.info("updating exchange /keys info");
@ -617,7 +605,7 @@ async function updateExchangeFromUrlImpl(
ws, ws,
baseUrl, baseUrl,
timeout, timeout,
acceptedFormat, ["text/plain"],
); );
const tosHasBeenAccepted = const tosHasBeenAccepted =
exchangeDetails?.termsOfServiceAcceptedEtag === tosDownload.tosEtag; exchangeDetails?.termsOfServiceAcceptedEtag === tosDownload.tosEtag;

View File

@ -97,7 +97,7 @@ import {
import { GetReadWriteAccess } from "../util/query.js"; import { GetReadWriteAccess } from "../util/query.js";
import { import {
getRetryDuration, getRetryDuration,
initRetryInfo, resetRetryInfo,
RetryInfo, RetryInfo,
updateRetryInfoTimeout, updateRetryInfoTimeout,
} from "../util/retries.js"; } from "../util/retries.js";
@ -428,8 +428,8 @@ async function recordConfirmPay(
proposalId: proposal.proposalId, proposalId: proposal.proposalId,
lastPayError: undefined, lastPayError: undefined,
lastRefundStatusError: undefined, lastRefundStatusError: undefined,
payRetryInfo: initRetryInfo(), payRetryInfo: resetRetryInfo(),
refundStatusRetryInfo: initRetryInfo(), refundStatusRetryInfo: resetRetryInfo(),
refundQueryRequested: false, refundQueryRequested: false,
timestampFirstSuccessfulPay: undefined, timestampFirstSuccessfulPay: undefined,
autoRefundDeadline: undefined, autoRefundDeadline: undefined,
@ -453,7 +453,7 @@ async function recordConfirmPay(
if (p) { if (p) {
p.proposalStatus = ProposalStatus.Accepted; p.proposalStatus = ProposalStatus.Accepted;
delete p.lastError; delete p.lastError;
p.retryInfo = initRetryInfo(); delete p.retryInfo;
await tx.proposals.put(p); await tx.proposals.put(p);
} }
await tx.purchases.put(t); await tx.purchases.put(t);
@ -491,9 +491,12 @@ async function reportProposalError(
ws.notify({ type: NotificationType.ProposalOperationError, error: err }); ws.notify({ type: NotificationType.ProposalOperationError, error: err });
} }
async function incrementProposalRetry( async function setupProposalRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
options: {
reset: boolean;
},
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ proposals: x.proposals })) .mktx((x) => ({ proposals: x.proposals }))
@ -502,47 +505,37 @@ async function incrementProposalRetry(
if (!pr) { if (!pr) {
return; return;
} }
if (!pr.retryInfo) { if (options.reset) {
return; pr.retryInfo = resetRetryInfo();
} else { } else {
pr.retryInfo.retryCounter++; pr.retryInfo = RetryInfo.increment(pr.retryInfo);
updateRetryInfoTimeout(pr.retryInfo);
} }
delete pr.lastError; delete pr.lastError;
await tx.proposals.put(pr); await tx.proposals.put(pr);
}); });
} }
async function resetPurchasePayRetry( async function setupPurchasePayRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
options: {
reset: boolean;
},
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ purchases: x.purchases })) .mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId); const p = await tx.purchases.get(proposalId);
if (p) { if (!p) {
p.payRetryInfo = initRetryInfo();
delete p.lastPayError;
await tx.purchases.put(p);
}
});
}
async function incrementPurchasePayRetry(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const pr = await tx.purchases.get(proposalId);
if (!pr) {
return; return;
} }
pr.payRetryInfo = RetryInfo.increment(pr.payRetryInfo); if (options.reset) {
delete pr.lastPayError; p.payRetryInfo = resetRetryInfo();
await tx.purchases.put(pr); } else {
p.payRetryInfo = RetryInfo.increment(p.payRetryInfo);
}
delete p.lastPayError;
await tx.purchases.put(p);
}); });
} }
@ -572,32 +565,18 @@ async function reportPurchasePayError(
export async function processDownloadProposal( export async function processDownloadProposal(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const onOpErr = (err: TalerErrorDetail): Promise<void> => const onOpErr = (err: TalerErrorDetail): Promise<void> =>
reportProposalError(ws, proposalId, err); reportProposalError(ws, proposalId, err);
await guardOperationException( await guardOperationException(
() => processDownloadProposalImpl(ws, proposalId, forceNow), () => processDownloadProposalImpl(ws, proposalId, options),
onOpErr, onOpErr,
); );
} }
async function resetDownloadProposalRetry(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadWrite(async (tx) => {
const p = await tx.proposals.get(proposalId);
if (p) {
p.retryInfo = initRetryInfo();
delete p.lastError;
await tx.proposals.put(p);
}
});
}
async function failProposalPermanently( async function failProposalPermanently(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -678,8 +657,11 @@ export function extractContractData(
async function processDownloadProposalImpl( async function processDownloadProposalImpl(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
forceNow: boolean, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const forceNow = options.forceNow ?? false;
const proposal = await ws.db const proposal = await ws.db
.mktx((x) => ({ proposals: x.proposals })) .mktx((x) => ({ proposals: x.proposals }))
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
@ -694,11 +676,7 @@ async function processDownloadProposalImpl(
return; return;
} }
if (forceNow) { await setupProposalRetry(ws, proposalId, { reset: forceNow });
await resetDownloadProposalRetry(ws, proposalId);
} else {
await incrementProposalRetry(ws, proposalId);
}
const orderClaimUrl = new URL( const orderClaimUrl = new URL(
`orders/${proposal.orderId}/claim`, `orders/${proposal.orderId}/claim`,
@ -946,7 +924,7 @@ async function startDownloadProposal(
proposalId: proposalId, proposalId: proposalId,
proposalStatus: ProposalStatus.Downloading, proposalStatus: ProposalStatus.Downloading,
repurchaseProposalId: undefined, repurchaseProposalId: undefined,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
lastError: undefined, lastError: undefined,
downloadSessionId: sessionId, downloadSessionId: sessionId,
}; };
@ -994,7 +972,7 @@ async function storeFirstPaySuccess(
purchase.paymentSubmitPending = false; purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined; purchase.lastPayError = undefined;
purchase.lastSessionId = sessionId; purchase.lastSessionId = sessionId;
purchase.payRetryInfo = initRetryInfo(); purchase.payRetryInfo = resetRetryInfo();
purchase.merchantPaySig = paySig; purchase.merchantPaySig = paySig;
if (isFirst) { if (isFirst) {
const protoAr = purchase.download.contractData.autoRefund; const protoAr = purchase.download.contractData.autoRefund;
@ -1002,7 +980,7 @@ async function storeFirstPaySuccess(
const ar = Duration.fromTalerProtocolDuration(protoAr); const ar = Duration.fromTalerProtocolDuration(protoAr);
logger.info("auto_refund present"); logger.info("auto_refund present");
purchase.refundQueryRequested = true; purchase.refundQueryRequested = true;
purchase.refundStatusRetryInfo = initRetryInfo(); purchase.refundStatusRetryInfo = resetRetryInfo();
purchase.lastRefundStatusError = undefined; purchase.lastRefundStatusError = undefined;
purchase.autoRefundDeadline = AbsoluteTime.toTimestamp( purchase.autoRefundDeadline = AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(AbsoluteTime.now(), ar), AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
@ -1033,7 +1011,7 @@ async function storePayReplaySuccess(
} }
purchase.paymentSubmitPending = false; purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined; purchase.lastPayError = undefined;
purchase.payRetryInfo = initRetryInfo(); purchase.payRetryInfo = resetRetryInfo();
purchase.lastSessionId = sessionId; purchase.lastSessionId = sessionId;
await tx.purchases.put(purchase); await tx.purchases.put(purchase);
}); });
@ -1289,7 +1267,7 @@ export async function checkPaymentByProposalId(
p.paymentSubmitPending = true; p.paymentSubmitPending = true;
await tx.purchases.put(p); await tx.purchases.put(p);
}); });
const r = await processPurchasePay(ws, proposalId, true); const r = await processPurchasePay(ws, proposalId, { forceNow: true });
if (r.type !== ConfirmPayResultType.Done) { if (r.type !== ConfirmPayResultType.Done) {
throw Error("submitting pay failed"); throw Error("submitting pay failed");
} }
@ -1466,7 +1444,7 @@ export async function confirmPay(
if (existingPurchase) { if (existingPurchase) {
logger.trace("confirmPay: submitting payment for existing purchase"); logger.trace("confirmPay: submitting payment for existing purchase");
return await processPurchasePay(ws, proposalId, true); return await processPurchasePay(ws, proposalId, { forceNow: true });
} }
logger.trace("confirmPay: purchase record does not exist yet"); logger.trace("confirmPay: purchase record does not exist yet");
@ -1516,18 +1494,20 @@ export async function confirmPay(
sessionIdOverride, sessionIdOverride,
); );
return await processPurchasePay(ws, proposalId, true); return await processPurchasePay(ws, proposalId, { forceNow: true });
} }
export async function processPurchasePay( export async function processPurchasePay(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<ConfirmPayResult> { ): Promise<ConfirmPayResult> {
const onOpErr = (e: TalerErrorDetail): Promise<void> => const onOpErr = (e: TalerErrorDetail): Promise<void> =>
reportPurchasePayError(ws, proposalId, e); reportPurchasePayError(ws, proposalId, e);
return await guardOperationException( return await guardOperationException(
() => processPurchasePayImpl(ws, proposalId, forceNow), () => processPurchasePayImpl(ws, proposalId, options),
onOpErr, onOpErr,
); );
} }
@ -1535,8 +1515,11 @@ export async function processPurchasePay(
async function processPurchasePayImpl( async function processPurchasePayImpl(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
forceNow: boolean, options: {
forceNow?: boolean;
} = {},
): Promise<ConfirmPayResult> { ): Promise<ConfirmPayResult> {
const forceNow = options.forceNow ?? false;
const purchase = await ws.db const purchase = await ws.db
.mktx((x) => ({ purchases: x.purchases })) .mktx((x) => ({ purchases: x.purchases }))
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
@ -1559,11 +1542,7 @@ async function processPurchasePayImpl(
lastError: purchase.lastPayError, lastError: purchase.lastPayError,
}; };
} }
if (forceNow) { await setupPurchasePayRetry(ws, proposalId, { reset: forceNow });
await resetPurchasePayRetry(ws, proposalId);
} else {
await incrementPurchasePayRetry(ws, proposalId);
}
logger.trace(`processing purchase pay ${proposalId}`); logger.trace(`processing purchase pay ${proposalId}`);
const sessionId = purchase.lastSessionId; const sessionId = purchase.lastSessionId;

View File

@ -51,18 +51,17 @@ async function gatherExchangePending(
resp.pendingOperations.push({ resp.pendingOperations.push({
type: PendingTaskType.ExchangeUpdate, type: PendingTaskType.ExchangeUpdate,
givesLifeness: false, givesLifeness: false,
timestampDue: e.lastError timestampDue:
? e.retryInfo.nextRetry e.retryInfo?.nextRetry ?? AbsoluteTime.fromTimestamp(e.nextUpdate),
: AbsoluteTime.fromTimestamp(e.nextUpdate),
exchangeBaseUrl: e.baseUrl, exchangeBaseUrl: e.baseUrl,
lastError: e.lastError, lastError: e.lastError,
}); });
resp.pendingOperations.push({ resp.pendingOperations.push({
type: PendingTaskType.ExchangeCheckRefresh, type: PendingTaskType.ExchangeCheckRefresh,
timestampDue: e.lastError timestampDue:
? e.retryInfo.nextRetry e.retryInfo?.nextRetry ??
: AbsoluteTime.fromTimestamp(e.nextRefreshCheck), AbsoluteTime.fromTimestamp(e.nextRefreshCheck),
givesLifeness: false, givesLifeness: false,
exchangeBaseUrl: e.baseUrl, exchangeBaseUrl: e.baseUrl,
}); });

View File

@ -48,7 +48,11 @@ import {
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { Logger, URL } from "@gnu-taler/taler-util"; import { Logger, URL } from "@gnu-taler/taler-util";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; import {
resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
import { createRefreshGroup, processRefreshGroup } from "./refresh.js"; import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
import { getReserveRequestTimeout, processReserve } from "./reserves.js"; import { getReserveRequestTimeout, processReserve } from "./reserves.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
@ -57,10 +61,36 @@ import { guardOperationException } from "./common.js";
const logger = new Logger("operations/recoup.ts"); const logger = new Logger("operations/recoup.ts");
async function incrementRecoupRetry( async function setupRecoupRetry(
ws: InternalWalletState, ws: InternalWalletState,
recoupGroupId: string, recoupGroupId: string,
err: TalerErrorDetail | undefined, options: {
reset: boolean;
},
): Promise<void> {
await ws.db
.mktx((x) => ({
recoupGroups: x.recoupGroups,
}))
.runReadWrite(async (tx) => {
const r = await tx.recoupGroups.get(recoupGroupId);
if (!r) {
return;
}
if (options.reset) {
r.retryInfo = resetRetryInfo();
} else {
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
delete r.lastError;
await tx.recoupGroups.put(r);
});
}
async function reportRecoupError(
ws: InternalWalletState,
recoupGroupId: string,
err: TalerErrorDetail,
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
@ -72,16 +102,14 @@ async function incrementRecoupRetry(
return; return;
} }
if (!r.retryInfo) { if (!r.retryInfo) {
return; logger.error(
"reporting error for inactive recoup group (no retry info)",
);
} }
r.retryInfo.retryCounter++;
updateRetryInfoTimeout(r.retryInfo);
r.lastError = err; r.lastError = err;
await tx.recoupGroups.put(r); await tx.recoupGroups.put(r);
}); });
if (err) { ws.notify({ type: NotificationType.RecoupOperationError, error: err });
ws.notify({ type: NotificationType.RecoupOperationError, error: err });
}
} }
async function putGroupAsFinished( async function putGroupAsFinished(
@ -111,7 +139,7 @@ async function putGroupAsFinished(
if (allFinished) { if (allFinished) {
logger.info("all recoups of recoup group are finished"); logger.info("all recoups of recoup group are finished");
recoupGroup.timestampFinished = TalerProtocolTimestamp.now(); recoupGroup.timestampFinished = TalerProtocolTimestamp.now();
recoupGroup.retryInfo = initRetryInfo(); recoupGroup.retryInfo = resetRetryInfo();
recoupGroup.lastError = undefined; recoupGroup.lastError = undefined;
if (recoupGroup.scheduleRefreshCoins.length > 0) { if (recoupGroup.scheduleRefreshCoins.length > 0) {
const refreshGroupId = await createRefreshGroup( const refreshGroupId = await createRefreshGroup(
@ -250,7 +278,7 @@ async function recoupWithdrawCoin(
const currency = updatedCoin.currentAmount.currency; const currency = updatedCoin.currentAmount.currency;
updatedCoin.currentAmount = Amounts.getZero(currency); updatedCoin.currentAmount = Amounts.getZero(currency);
updatedReserve.reserveStatus = ReserveRecordStatus.QueryingStatus; updatedReserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
updatedReserve.retryInfo = initRetryInfo(); updatedReserve.retryInfo = resetRetryInfo();
updatedReserve.operationStatus = OperationStatus.Pending; updatedReserve.operationStatus = OperationStatus.Pending;
await tx.coins.put(updatedCoin); await tx.coins.put(updatedCoin);
await tx.reserves.put(updatedReserve); await tx.reserves.put(updatedReserve);
@ -361,33 +389,18 @@ async function recoupRefreshCoin(
}); });
} }
async function resetRecoupGroupRetry(
ws: InternalWalletState,
recoupGroupId: string,
): Promise<void> {
await ws.db
.mktx((x) => ({
recoupGroups: x.recoupGroups,
}))
.runReadWrite(async (tx) => {
const x = await tx.recoupGroups.get(recoupGroupId);
if (x) {
x.retryInfo = initRetryInfo();
await tx.recoupGroups.put(x);
}
});
}
export async function processRecoupGroup( export async function processRecoupGroup(
ws: InternalWalletState, ws: InternalWalletState,
recoupGroupId: string, recoupGroupId: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
await ws.memoProcessRecoup.memo(recoupGroupId, async () => { await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
const onOpErr = (e: TalerErrorDetail): Promise<void> => const onOpErr = (e: TalerErrorDetail): Promise<void> =>
incrementRecoupRetry(ws, recoupGroupId, e); reportRecoupError(ws, recoupGroupId, e);
return await guardOperationException( return await guardOperationException(
async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow), async () => await processRecoupGroupImpl(ws, recoupGroupId, options),
onOpErr, onOpErr,
); );
}); });
@ -396,11 +409,12 @@ export async function processRecoupGroup(
async function processRecoupGroupImpl( async function processRecoupGroupImpl(
ws: InternalWalletState, ws: InternalWalletState,
recoupGroupId: string, recoupGroupId: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
if (forceNow) { const forceNow = options.forceNow ?? false;
await resetRecoupGroupRetry(ws, recoupGroupId); await setupRecoupRetry(ws, recoupGroupId, { reset: forceNow });
}
const recoupGroup = await ws.db const recoupGroup = await ws.db
.mktx((x) => ({ .mktx((x) => ({
recoupGroups: x.recoupGroups, recoupGroups: x.recoupGroups,
@ -444,7 +458,7 @@ async function processRecoupGroupImpl(
} }
for (const r of reserveSet.values()) { for (const r of reserveSet.values()) {
processReserve(ws, r, true).catch((e) => { processReserve(ws, r, { forceNow: true }).catch((e) => {
logger.error(`processing reserve ${r} after recoup failed`); logger.error(`processing reserve ${r} after recoup failed`);
}); });
} }
@ -468,7 +482,7 @@ export async function createRecoupGroup(
lastError: undefined, lastError: undefined,
timestampFinished: undefined, timestampFinished: undefined,
timestampStarted: TalerProtocolTimestamp.now(), timestampStarted: TalerProtocolTimestamp.now(),
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
recoupFinishedPerCoin: coinPubs.map(() => false), recoupFinishedPerCoin: coinPubs.map(() => false),
// Will be populated later // Will be populated later
oldAmountPerCoin: [], oldAmountPerCoin: [],

View File

@ -53,7 +53,11 @@ import {
} from "../util/http.js"; } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js"; import { checkDbInvariant } from "../util/invariants.js";
import { Logger } from "@gnu-taler/taler-util"; import { Logger } from "@gnu-taler/taler-util";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; import {
resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
import { import {
Duration, Duration,
durationFromSpec, durationFromSpec,
@ -130,11 +134,11 @@ function updateGroupStatus(rg: RefreshGroupRecord): void {
if (allDone) { if (allDone) {
if (anyFrozen) { if (anyFrozen) {
rg.frozen = true; rg.frozen = true;
rg.retryInfo = initRetryInfo(); rg.retryInfo = resetRetryInfo();
} else { } else {
rg.timestampFinished = AbsoluteTime.toTimestamp(AbsoluteTime.now()); rg.timestampFinished = AbsoluteTime.toTimestamp(AbsoluteTime.now());
rg.operationStatus = OperationStatus.Finished; rg.operationStatus = OperationStatus.Finished;
rg.retryInfo = initRetryInfo(); rg.retryInfo = resetRetryInfo();
} }
} }
} }
@ -712,7 +716,33 @@ async function refreshReveal(
}); });
} }
async function incrementRefreshRetry( async function setupRefreshRetry(
ws: InternalWalletState,
refreshGroupId: string,
options: {
reset: boolean;
},
): Promise<void> {
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const r = await tx.refreshGroups.get(refreshGroupId);
if (!r) {
return;
}
if (options.reset) {
r.retryInfo = resetRetryInfo();
} else {
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
delete r.lastError;
await tx.refreshGroups.put(r);
});
}
async function reportRefreshError(
ws: InternalWalletState, ws: InternalWalletState,
refreshGroupId: string, refreshGroupId: string,
err: TalerErrorDetail | undefined, err: TalerErrorDetail | undefined,
@ -727,10 +757,10 @@ async function incrementRefreshRetry(
return; return;
} }
if (!r.retryInfo) { if (!r.retryInfo) {
return; logger.error(
"reported error for inactive refresh group (no retry info)",
);
} }
r.retryInfo.retryCounter++;
updateRetryInfoTimeout(r.retryInfo);
r.lastError = err; r.lastError = err;
await tx.refreshGroups.put(r); await tx.refreshGroups.put(r);
}); });
@ -745,44 +775,31 @@ async function incrementRefreshRetry(
export async function processRefreshGroup( export async function processRefreshGroup(
ws: InternalWalletState, ws: InternalWalletState,
refreshGroupId: string, refreshGroupId: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
await ws.memoProcessRefresh.memo(refreshGroupId, async () => { await ws.memoProcessRefresh.memo(refreshGroupId, async () => {
const onOpErr = (e: TalerErrorDetail): Promise<void> => const onOpErr = (e: TalerErrorDetail): Promise<void> =>
incrementRefreshRetry(ws, refreshGroupId, e); reportRefreshError(ws, refreshGroupId, e);
return await guardOperationException( return await guardOperationException(
async () => await processRefreshGroupImpl(ws, refreshGroupId, forceNow), async () => await processRefreshGroupImpl(ws, refreshGroupId, options),
onOpErr, onOpErr,
); );
}); });
} }
async function resetRefreshGroupRetry(
ws: InternalWalletState,
refreshGroupId: string,
): Promise<void> {
await ws.db
.mktx((x) => ({
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const x = await tx.refreshGroups.get(refreshGroupId);
if (x) {
x.retryInfo = initRetryInfo();
await tx.refreshGroups.put(x);
}
});
}
async function processRefreshGroupImpl( async function processRefreshGroupImpl(
ws: InternalWalletState, ws: InternalWalletState,
refreshGroupId: string, refreshGroupId: string,
forceNow: boolean, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const forceNow = options.forceNow ?? false;
logger.info(`processing refresh group ${refreshGroupId}`); logger.info(`processing refresh group ${refreshGroupId}`);
if (forceNow) { await setupRefreshRetry(ws, refreshGroupId, { reset: forceNow });
await resetRefreshGroupRetry(ws, refreshGroupId);
}
const refreshGroup = await ws.db const refreshGroup = await ws.db
.mktx((x) => ({ .mktx((x) => ({
refreshGroups: x.refreshGroups, refreshGroups: x.refreshGroups,
@ -939,7 +956,7 @@ export async function createRefreshGroup(
reason, reason,
refreshGroupId, refreshGroupId,
refreshSessionPerCoin: oldCoinPubs.map(() => undefined), refreshSessionPerCoin: oldCoinPubs.map(() => undefined),
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
inputPerCoin, inputPerCoin,
estimatedOutputPerCoin, estimatedOutputPerCoin,
timestampCreated: TalerProtocolTimestamp.now(), timestampCreated: TalerProtocolTimestamp.now(),
@ -994,7 +1011,9 @@ export async function autoRefresh(
exchangeBaseUrl: string, exchangeBaseUrl: string,
): Promise<void> { ): Promise<void> {
logger.info(`doing auto-refresh check for '${exchangeBaseUrl}'`); logger.info(`doing auto-refresh check for '${exchangeBaseUrl}'`);
await updateExchangeFromUrl(ws, exchangeBaseUrl, undefined, true); await updateExchangeFromUrl(ws, exchangeBaseUrl, {
forceNow: true,
});
let minCheckThreshold = AbsoluteTime.addDuration( let minCheckThreshold = AbsoluteTime.addDuration(
AbsoluteTime.now(), AbsoluteTime.now(),
durationFromSpec({ days: 1 }), durationFromSpec({ days: 1 }),

View File

@ -58,37 +58,54 @@ import {
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js"; import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js"; import { GetReadWriteAccess } from "../util/query.js";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; import {
resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
import { guardOperationException } from "./common.js"; import { guardOperationException } from "./common.js";
const logger = new Logger("refund.ts"); const logger = new Logger("refund.ts");
async function resetPurchaseQueryRefundRetry( /**
* Retry querying and applying refunds for an order later.
*/
async function setupPurchaseQueryRefundRetry(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
options: {
reset: boolean;
},
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
purchases: x.purchases, purchases: x.purchases,
})) }))
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const x = await tx.purchases.get(proposalId); const pr = await tx.purchases.get(proposalId);
if (x) { if (!pr) {
x.refundStatusRetryInfo = initRetryInfo(); return;
await tx.purchases.put(x);
} }
if (options.reset) {
pr.refundStatusRetryInfo = resetRetryInfo();
} else {
pr.refundStatusRetryInfo = RetryInfo.increment(
pr.refundStatusRetryInfo,
);
}
await tx.purchases.put(pr);
}); });
} }
/** /**
* Retry querying and applying refunds for an order later. * Report an error that happending when querying for a purchase's refund.
*/ */
async function incrementPurchaseQueryRefundRetry( async function reportPurchaseQueryRefundError(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
err: TalerErrorDetail | undefined, err: TalerErrorDetail,
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
@ -100,10 +117,10 @@ async function incrementPurchaseQueryRefundRetry(
return; return;
} }
if (!pr.refundStatusRetryInfo) { if (!pr.refundStatusRetryInfo) {
return; logger.error(
"reported error on an inactive purchase (no refund status retry info)",
);
} }
pr.refundStatusRetryInfo.retryCounter++;
updateRetryInfoTimeout(pr.refundStatusRetryInfo);
pr.lastRefundStatusError = err; pr.lastRefundStatusError = err;
await tx.purchases.put(pr); await tx.purchases.put(pr);
}); });
@ -425,7 +442,7 @@ async function acceptRefunds(
if (queryDone) { if (queryDone) {
p.timestampLastRefundStatus = now; p.timestampLastRefundStatus = now;
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(); p.refundStatusRetryInfo = resetRetryInfo();
p.refundQueryRequested = false; p.refundQueryRequested = false;
if (p.abortStatus === AbortStatus.AbortRefund) { if (p.abortStatus === AbortStatus.AbortRefund) {
p.abortStatus = AbortStatus.AbortFinished; p.abortStatus = AbortStatus.AbortFinished;
@ -506,7 +523,7 @@ export async function applyRefund(
} }
p.refundQueryRequested = true; p.refundQueryRequested = true;
p.lastRefundStatusError = undefined; p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo(); p.refundStatusRetryInfo = resetRetryInfo();
await tx.purchases.put(p); await tx.purchases.put(p);
return true; return true;
}); });
@ -515,7 +532,10 @@ export async function applyRefund(
ws.notify({ ws.notify({
type: NotificationType.RefundStarted, type: NotificationType.RefundStarted,
}); });
await processPurchaseQueryRefundImpl(ws, proposalId, true, false); await processPurchaseQueryRefundImpl(ws, proposalId, {
forceNow: true,
waitForAutoRefund: false,
});
} }
purchase = await ws.db purchase = await ws.db
@ -590,12 +610,15 @@ export async function applyRefund(
export async function processPurchaseQueryRefund( export async function processPurchaseQueryRefund(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
forceNow = false, options: {
forceNow?: boolean;
waitForAutoRefund?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const onOpErr = (e: TalerErrorDetail): Promise<void> => const onOpErr = (e: TalerErrorDetail): Promise<void> =>
incrementPurchaseQueryRefundRetry(ws, proposalId, e); reportPurchaseQueryRefundError(ws, proposalId, e);
await guardOperationException( await guardOperationException(
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow, true), () => processPurchaseQueryRefundImpl(ws, proposalId, options),
onOpErr, onOpErr,
); );
} }
@ -603,12 +626,14 @@ export async function processPurchaseQueryRefund(
async function processPurchaseQueryRefundImpl( async function processPurchaseQueryRefundImpl(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
forceNow: boolean, options: {
waitForAutoRefund: boolean, forceNow?: boolean;
waitForAutoRefund?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
if (forceNow) { const forceNow = options.forceNow ?? false;
await resetPurchaseQueryRefundRetry(ws, proposalId); const waitForAutoRefund = options.waitForAutoRefund ?? false;
} await setupPurchaseQueryRefundRetry(ws, proposalId, { reset: forceNow });
const purchase = await ws.db const purchase = await ws.db
.mktx((x) => ({ .mktx((x) => ({
purchases: x.purchases, purchases: x.purchases,
@ -650,7 +675,7 @@ async function processPurchaseQueryRefundImpl(
codecForMerchantOrderStatusPaid(), codecForMerchantOrderStatusPaid(),
); );
if (!orderStatus.refunded) { if (!orderStatus.refunded) {
incrementPurchaseQueryRefundRetry(ws, proposalId, undefined); // Wait for retry ...
return; return;
} }
} }
@ -666,11 +691,6 @@ async function processPurchaseQueryRefundImpl(
h_contract: purchase.download.contractData.contractTermsHash, h_contract: purchase.download.contractData.contractTermsHash,
}); });
logger.trace(
"got json",
JSON.stringify(await request.json(), undefined, 2),
);
const refundResponse = await readSuccessResponseJsonOrThrow( const refundResponse = await readSuccessResponseJsonOrThrow(
request, request,
codecForMerchantOrderRefundPickupResponse(), codecForMerchantOrderRefundPickupResponse(),
@ -777,10 +797,12 @@ export async function abortFailedPayWithRefund(
purchase.paymentSubmitPending = false; purchase.paymentSubmitPending = false;
purchase.abortStatus = AbortStatus.AbortRefund; purchase.abortStatus = AbortStatus.AbortRefund;
purchase.lastPayError = undefined; purchase.lastPayError = undefined;
purchase.payRetryInfo = initRetryInfo(); purchase.payRetryInfo = resetRetryInfo();
await tx.purchases.put(purchase); await tx.purchases.put(purchase);
}); });
processPurchaseQueryRefund(ws, proposalId, true).catch((e) => { processPurchaseQueryRefund(ws, proposalId, {
forceNow: true,
}).catch((e) => {
logger.trace(`error during refund processing after abort pay: ${e}`); logger.trace(`error during refund processing after abort pay: ${e}`);
}); });
} }

View File

@ -57,7 +57,8 @@ import {
import { GetReadOnlyAccess } from "../util/query.js"; import { GetReadOnlyAccess } from "../util/query.js";
import { import {
getRetryDuration, getRetryDuration,
initRetryInfo, resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout, updateRetryInfoTimeout,
} from "../util/retries.js"; } from "../util/retries.js";
import { import {
@ -79,34 +80,15 @@ import { guardOperationException } from "./common.js";
const logger = new Logger("taler-wallet-core:reserves.ts"); const logger = new Logger("taler-wallet-core:reserves.ts");
/** /**
* Reset the retry counter for the reserve * Set up the reserve's retry timeout in preparation for
* and reset the last error. * processing the reserve.
*/ */
async function resetReserveRetry( async function setupReserveRetry(
ws: InternalWalletState,
reservePub: string,
): Promise<void> {
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const x = await tx.reserves.get(reservePub);
if (x) {
x.retryInfo = initRetryInfo();
delete x.lastError;
await tx.reserves.put(x);
}
});
}
/**
* Increment the retry counter for the reserve and
* reset the last eror.
*/
async function incrementReserveRetry(
ws: InternalWalletState, ws: InternalWalletState,
reservePub: string, reservePub: string,
options: {
reset: boolean;
},
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
@ -117,11 +99,10 @@ async function incrementReserveRetry(
if (!r) { if (!r) {
return; return;
} }
if (!r.retryInfo) { if (options.reset) {
r.retryInfo = initRetryInfo(); r.retryInfo = resetRetryInfo();
} else { } else {
r.retryInfo.retryCounter++; r.retryInfo = RetryInfo.increment(r.retryInfo);
updateRetryInfoTimeout(r.retryInfo);
} }
delete r.lastError; delete r.lastError;
await tx.reserves.put(r); await tx.reserves.put(r);
@ -216,7 +197,7 @@ export async function createReserve(
timestampReserveInfoPosted: undefined, timestampReserveInfoPosted: undefined,
bankInfo, bankInfo,
reserveStatus, reserveStatus,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
lastError: undefined, lastError: undefined,
currency: req.amount.currency, currency: req.amount.currency,
operationStatus: OperationStatus.Pending, operationStatus: OperationStatus.Pending,
@ -288,7 +269,7 @@ export async function createReserve(
// Asynchronously process the reserve, but return // Asynchronously process the reserve, but return
// to the caller already. // to the caller already.
processReserve(ws, resp.reservePub, true).catch((e) => { processReserve(ws, resp.reservePub, { forceNow: true }).catch((e) => {
logger.error("Processing reserve (after createReserve) failed:", e); logger.error("Processing reserve (after createReserve) failed:", e);
}); });
@ -316,14 +297,14 @@ export async function forceQueryReserve(
case ReserveRecordStatus.Dormant: case ReserveRecordStatus.Dormant:
reserve.reserveStatus = ReserveRecordStatus.QueryingStatus; reserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
reserve.operationStatus = OperationStatus.Pending; reserve.operationStatus = OperationStatus.Pending;
reserve.retryInfo = initRetryInfo(); reserve.retryInfo = resetRetryInfo();
break; break;
default: default:
break; break;
} }
await tx.reserves.put(reserve); await tx.reserves.put(reserve);
}); });
await processReserve(ws, reservePub, true); await processReserve(ws, reservePub, { forceNow: true });
} }
/** /**
@ -336,13 +317,15 @@ export async function forceQueryReserve(
export async function processReserve( export async function processReserve(
ws: InternalWalletState, ws: InternalWalletState,
reservePub: string, reservePub: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
return ws.memoProcessReserve.memo(reservePub, async () => { return ws.memoProcessReserve.memo(reservePub, async () => {
const onOpError = (err: TalerErrorDetail): Promise<void> => const onOpError = (err: TalerErrorDetail): Promise<void> =>
reportReserveError(ws, reservePub, err); reportReserveError(ws, reservePub, err);
await guardOperationException( await guardOperationException(
() => processReserveImpl(ws, reservePub, forceNow), () => processReserveImpl(ws, reservePub, options),
onOpError, onOpError,
); );
}); });
@ -409,7 +392,7 @@ async function registerReserveWithBank(
if (!r.bankInfo) { if (!r.bankInfo) {
throw Error("invariant failed"); throw Error("invariant failed");
} }
r.retryInfo = initRetryInfo(); r.retryInfo = resetRetryInfo();
await tx.reserves.put(r); await tx.reserves.put(r);
}); });
ws.notify({ type: NotificationType.ReserveRegisteredWithBank }); ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
@ -476,7 +459,7 @@ async function processReserveBankStatus(
r.timestampBankConfirmed = now; r.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.BankAborted; r.reserveStatus = ReserveRecordStatus.BankAborted;
r.operationStatus = OperationStatus.Finished; r.operationStatus = OperationStatus.Finished;
r.retryInfo = initRetryInfo(); r.retryInfo = resetRetryInfo();
await tx.reserves.put(r); await tx.reserves.put(r);
}); });
return; return;
@ -513,7 +496,7 @@ async function processReserveBankStatus(
r.timestampBankConfirmed = now; r.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.QueryingStatus; r.reserveStatus = ReserveRecordStatus.QueryingStatus;
r.operationStatus = OperationStatus.Pending; r.operationStatus = OperationStatus.Pending;
r.retryInfo = initRetryInfo(); r.retryInfo = resetRetryInfo();
} else { } else {
switch (r.reserveStatus) { switch (r.reserveStatus) {
case ReserveRecordStatus.WaitConfirmBank: case ReserveRecordStatus.WaitConfirmBank:
@ -684,7 +667,7 @@ async function updateReserve(
reservePub: reserve.reservePub, reservePub: reserve.reservePub,
rawWithdrawalAmount: remainingAmount, rawWithdrawalAmount: remainingAmount,
timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()), timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
lastError: undefined, lastError: undefined,
denomsSel: denomSelectionInfoToState(denomSelInfo), denomsSel: denomSelectionInfoToState(denomSelInfo),
secretSeed: encodeCrock(getRandomBytes(64)), secretSeed: encodeCrock(getRandomBytes(64)),
@ -717,8 +700,12 @@ async function updateReserve(
async function processReserveImpl( async function processReserveImpl(
ws: InternalWalletState, ws: InternalWalletState,
reservePub: string, reservePub: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const forceNow = options.forceNow ?? false;
await setupReserveRetry(ws, reservePub, { reset: forceNow });
const reserve = await ws.db const reserve = await ws.db
.mktx((x) => ({ .mktx((x) => ({
reserves: x.reserves, reserves: x.reserves,
@ -732,27 +719,17 @@ async function processReserveImpl(
); );
return; return;
} }
if (forceNow) {
await resetReserveRetry(ws, reservePub);
} else if (
reserve.retryInfo &&
!AbsoluteTime.isExpired(reserve.retryInfo.nextRetry)
) {
logger.trace("processReserve retry not due yet");
return;
}
await incrementReserveRetry(ws, reservePub);
logger.trace( logger.trace(
`Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
); );
switch (reserve.reserveStatus) { switch (reserve.reserveStatus) {
case ReserveRecordStatus.RegisteringBank: case ReserveRecordStatus.RegisteringBank:
await processReserveBankStatus(ws, reservePub); await processReserveBankStatus(ws, reservePub);
return await processReserveImpl(ws, reservePub, true); return await processReserveImpl(ws, reservePub, { forceNow: true });
case ReserveRecordStatus.QueryingStatus: case ReserveRecordStatus.QueryingStatus:
const res = await updateReserve(ws, reservePub); const res = await updateReserve(ws, reservePub);
if (res.ready) { if (res.ready) {
return await processReserveImpl(ws, reservePub, true); return await processReserveImpl(ws, reservePub, { forceNow: true });
} }
break; break;
case ReserveRecordStatus.Dormant: case ReserveRecordStatus.Dormant:

View File

@ -43,7 +43,11 @@ import {
} from "../db.js"; } from "../db.js";
import { j2s } from "@gnu-taler/taler-util"; import { j2s } from "@gnu-taler/taler-util";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; import {
resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
import { makeErrorDetail } from "../errors.js"; import { makeErrorDetail } from "../errors.js";
import { updateExchangeFromUrl } from "./exchanges.js"; import { updateExchangeFromUrl } from "./exchanges.js";
import { InternalWalletState } from "../internal-wallet-state.js"; import { InternalWalletState } from "../internal-wallet-state.js";
@ -127,7 +131,7 @@ export async function prepareTip(
createdTimestamp: TalerProtocolTimestamp.now(), createdTimestamp: TalerProtocolTimestamp.now(),
merchantTipId: res.merchantTipId, merchantTipId: res.merchantTipId,
tipAmountEffective: selectedDenoms.totalCoinValue, tipAmountEffective: selectedDenoms.totalCoinValue,
retryInfo: initRetryInfo(), retryInfo: resetRetryInfo(),
lastError: undefined, lastError: undefined,
denomsSel: denomSelectionInfoToState(selectedDenoms), denomsSel: denomSelectionInfoToState(selectedDenoms),
pickedUpTimestamp: undefined, pickedUpTimestamp: undefined,
@ -157,10 +161,10 @@ export async function prepareTip(
return tipStatus; return tipStatus;
} }
async function incrementTipRetry( async function reportTipError(
ws: InternalWalletState, ws: InternalWalletState,
walletTipId: string, walletTipId: string,
err: TalerErrorDetail | undefined, err: TalerErrorDetail,
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ .mktx((x) => ({
@ -172,10 +176,8 @@ async function incrementTipRetry(
return; return;
} }
if (!t.retryInfo) { if (!t.retryInfo) {
return; logger.reportBreak();
} }
t.retryInfo.retryCounter++;
updateRetryInfoTimeout(t.retryInfo);
t.lastError = err; t.lastError = err;
await tx.tips.put(t); await tx.tips.put(t);
}); });
@ -184,15 +186,43 @@ async function incrementTipRetry(
} }
} }
async function setupTipRetry(
ws: InternalWalletState,
walletTipId: string,
options: {
reset: boolean;
},
): Promise<void> {
await ws.db
.mktx((x) => ({
tips: x.tips,
}))
.runReadWrite(async (tx) => {
const t = await tx.tips.get(walletTipId);
if (!t) {
return;
}
if (options.reset) {
t.retryInfo = resetRetryInfo();
} else {
t.retryInfo = RetryInfo.increment(t.retryInfo);
}
delete t.lastError;
await tx.tips.put(t);
});
}
export async function processTip( export async function processTip(
ws: InternalWalletState, ws: InternalWalletState,
tipId: string, tipId: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const onOpErr = (e: TalerErrorDetail): Promise<void> => const onOpErr = (e: TalerErrorDetail): Promise<void> =>
incrementTipRetry(ws, tipId, e); reportTipError(ws, tipId, e);
await guardOperationException( await guardOperationException(
() => processTipImpl(ws, tipId, forceNow), () => processTipImpl(ws, tipId, options),
onOpErr, onOpErr,
); );
} }
@ -208,7 +238,7 @@ async function resetTipRetry(
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const x = await tx.tips.get(tipId); const x = await tx.tips.get(tipId);
if (x) { if (x) {
x.retryInfo = initRetryInfo(); x.retryInfo = resetRetryInfo();
await tx.tips.put(x); await tx.tips.put(x);
} }
}); });
@ -217,8 +247,11 @@ async function resetTipRetry(
async function processTipImpl( async function processTipImpl(
ws: InternalWalletState, ws: InternalWalletState,
walletTipId: string, walletTipId: string,
forceNow: boolean, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const forceNow = options.forceNow ?? false;
if (forceNow) { if (forceNow) {
await resetTipRetry(ws, walletTipId); await resetTipRetry(ws, walletTipId);
} }
@ -293,12 +326,13 @@ async function processTipImpl(
merchantResp.status === 424) merchantResp.status === 424)
) { ) {
logger.trace(`got transient tip error`); logger.trace(`got transient tip error`);
// FIXME: wrap in another error code that indicates a transient error
const err = makeErrorDetail( const err = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
getHttpResponseErrorDetails(merchantResp), getHttpResponseErrorDetails(merchantResp),
"tip pickup failed (transient)", "tip pickup failed (transient)",
); );
await incrementTipRetry(ws, tipRecord.walletTipId, err); await reportTipError(ws, tipRecord.walletTipId, err);
// FIXME: Maybe we want to signal to the caller that the transient error happened? // FIXME: Maybe we want to signal to the caller that the transient error happened?
return; return;
} }
@ -397,7 +431,7 @@ async function processTipImpl(
} }
tr.pickedUpTimestamp = TalerProtocolTimestamp.now(); tr.pickedUpTimestamp = TalerProtocolTimestamp.now();
tr.lastError = undefined; tr.lastError = undefined;
tr.retryInfo = initRetryInfo(); tr.retryInfo = resetRetryInfo();
await tx.tips.put(tr); await tx.tips.put(tr);
for (const cr of newCoinRecords) { for (const cr of newCoinRecords) {
await tx.coins.put(cr); await tx.coins.put(cr);

View File

@ -68,7 +68,11 @@ import {
HttpRequestLibrary, HttpRequestLibrary,
readSuccessResponseJsonOrThrow, readSuccessResponseJsonOrThrow,
} from "../util/http.js"; } from "../util/http.js";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; import {
resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
import { import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION,
@ -792,10 +796,12 @@ export async function updateWithdrawalDenoms(
} }
} }
async function incrementWithdrawalRetry( async function setupWithdrawalRetry(
ws: InternalWalletState, ws: InternalWalletState,
withdrawalGroupId: string, withdrawalGroupId: string,
err: TalerErrorDetail | undefined, options: {
reset: boolean;
},
): Promise<void> { ): Promise<void> {
await ws.db await ws.db
.mktx((x) => ({ withdrawalGroups: x.withdrawalGroups })) .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
@ -804,56 +810,61 @@ async function incrementWithdrawalRetry(
if (!wsr) { if (!wsr) {
return; return;
} }
wsr.retryInfo.retryCounter++; if (options.reset) {
updateRetryInfoTimeout(wsr.retryInfo); wsr.retryInfo = resetRetryInfo();
} else {
wsr.retryInfo = RetryInfo.increment(wsr.retryInfo);
}
await tx.withdrawalGroups.put(wsr);
});
}
async function reportWithdrawalError(
ws: InternalWalletState,
withdrawalGroupId: string,
err: TalerErrorDetail,
): Promise<void> {
await ws.db
.mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
.runReadWrite(async (tx) => {
const wsr = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wsr) {
return;
}
if (!wsr.retryInfo) {
logger.reportBreak();
}
wsr.lastError = err; wsr.lastError = err;
await tx.withdrawalGroups.put(wsr); await tx.withdrawalGroups.put(wsr);
}); });
if (err) { ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
}
} }
export async function processWithdrawGroup( export async function processWithdrawGroup(
ws: InternalWalletState, ws: InternalWalletState,
withdrawalGroupId: string, withdrawalGroupId: string,
forceNow = false, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const onOpErr = (e: TalerErrorDetail): Promise<void> => const onOpErr = (e: TalerErrorDetail): Promise<void> =>
incrementWithdrawalRetry(ws, withdrawalGroupId, e); reportWithdrawalError(ws, withdrawalGroupId, e);
await guardOperationException( await guardOperationException(
() => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow), () => processWithdrawGroupImpl(ws, withdrawalGroupId, options),
onOpErr, onOpErr,
); );
} }
async function resetWithdrawalGroupRetry(
ws: InternalWalletState,
withdrawalGroupId: string,
): Promise<void> {
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const x = await tx.withdrawalGroups.get(withdrawalGroupId);
if (x) {
x.retryInfo = initRetryInfo();
await tx.withdrawalGroups.put(x);
}
});
}
async function processWithdrawGroupImpl( async function processWithdrawGroupImpl(
ws: InternalWalletState, ws: InternalWalletState,
withdrawalGroupId: string, withdrawalGroupId: string,
forceNow: boolean, options: {
forceNow?: boolean;
} = {},
): Promise<void> { ): Promise<void> {
const forceNow = options.forceNow ?? false;
logger.trace("processing withdraw group", withdrawalGroupId); logger.trace("processing withdraw group", withdrawalGroupId);
if (forceNow) { await setupWithdrawalRetry(ws, withdrawalGroupId, { reset: forceNow });
await resetWithdrawalGroupRetry(ws, withdrawalGroupId);
}
const withdrawalGroup = await ws.db const withdrawalGroup = await ws.db
.mktx((x) => ({ withdrawalGroups: x.withdrawalGroups })) .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
.runReadOnly(async (tx) => { .runReadOnly(async (tx) => {
@ -876,7 +887,7 @@ async function processWithdrawGroupImpl(
); );
return; return;
} }
return await ws.reserveOps.processReserve(ws, reservePub, forceNow); return await ws.reserveOps.processReserve(ws, reservePub, { forceNow });
} }
await ws.exchangeOps.updateExchangeFromUrl( await ws.exchangeOps.updateExchangeFromUrl(
@ -948,7 +959,7 @@ async function processWithdrawGroupImpl(
wg.timestampFinish = TalerProtocolTimestamp.now(); wg.timestampFinish = TalerProtocolTimestamp.now();
wg.operationStatus = OperationStatus.Finished; wg.operationStatus = OperationStatus.Finished;
delete wg.lastError; delete wg.lastError;
wg.retryInfo = initRetryInfo(); wg.retryInfo = resetRetryInfo();
} }
await tx.withdrawalGroups.put(wg); await tx.withdrawalGroups.put(wg);

View File

@ -51,6 +51,8 @@ export interface HttpResponse {
bytes(): Promise<ArrayBuffer>; bytes(): Promise<ArrayBuffer>;
} }
export const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
export interface HttpRequestOptions { export interface HttpRequestOptions {
method?: "POST" | "PUT" | "GET"; method?: "POST" | "PUT" | "GET";
headers?: { [name: string]: string }; headers?: { [name: string]: string };

View File

@ -82,7 +82,7 @@ export function getRetryDuration(
}; };
} }
export function initRetryInfo(p: RetryPolicy = defaultRetryPolicy): RetryInfo { export function resetRetryInfo(p: RetryPolicy = defaultRetryPolicy): RetryInfo {
const now = AbsoluteTime.now(); const now = AbsoluteTime.now();
const info = { const info = {
firstTry: now, firstTry: now,
@ -99,7 +99,7 @@ export namespace RetryInfo {
p: RetryPolicy = defaultRetryPolicy, p: RetryPolicy = defaultRetryPolicy,
) { ) {
if (!r) { if (!r) {
return initRetryInfo(p); return resetRetryInfo(p);
} }
const r2 = { ...r }; const r2 = { ...r };
r2.retryCounter++; r2.retryCounter++;

View File

@ -238,51 +238,41 @@ async function processOnePendingOperation(
logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`); logger.trace(`running pending ${JSON.stringify(pending, undefined, 2)}`);
switch (pending.type) { switch (pending.type) {
case PendingTaskType.ExchangeUpdate: case PendingTaskType.ExchangeUpdate:
await updateExchangeFromUrl( await updateExchangeFromUrl(ws, pending.exchangeBaseUrl, {
ws,
pending.exchangeBaseUrl,
undefined,
forceNow, forceNow,
); });
break; break;
case PendingTaskType.Refresh: case PendingTaskType.Refresh:
await processRefreshGroup(ws, pending.refreshGroupId, forceNow); await processRefreshGroup(ws, pending.refreshGroupId, { forceNow });
break; break;
case PendingTaskType.Reserve: case PendingTaskType.Reserve:
await processReserve(ws, pending.reservePub, forceNow); await processReserve(ws, pending.reservePub, { forceNow });
break; break;
case PendingTaskType.Withdraw: case PendingTaskType.Withdraw:
await processWithdrawGroup(ws, pending.withdrawalGroupId, forceNow); await processWithdrawGroup(ws, pending.withdrawalGroupId, { forceNow });
break; break;
case PendingTaskType.ProposalDownload: case PendingTaskType.ProposalDownload:
await processDownloadProposal(ws, pending.proposalId, forceNow); await processDownloadProposal(ws, pending.proposalId, { forceNow });
break; break;
case PendingTaskType.TipPickup: case PendingTaskType.TipPickup:
await processTip(ws, pending.tipId, forceNow); await processTip(ws, pending.tipId, { forceNow });
break; break;
case PendingTaskType.Pay: case PendingTaskType.Pay:
await processPurchasePay(ws, pending.proposalId, forceNow); await processPurchasePay(ws, pending.proposalId, { forceNow });
break; break;
case PendingTaskType.RefundQuery: case PendingTaskType.RefundQuery:
await processPurchaseQueryRefund(ws, pending.proposalId, forceNow); await processPurchaseQueryRefund(ws, pending.proposalId, { forceNow });
break; break;
case PendingTaskType.Recoup: case PendingTaskType.Recoup:
await processRecoupGroup(ws, pending.recoupGroupId, forceNow); await processRecoupGroup(ws, pending.recoupGroupId, { forceNow });
break; break;
case PendingTaskType.ExchangeCheckRefresh: case PendingTaskType.ExchangeCheckRefresh:
await autoRefresh(ws, pending.exchangeBaseUrl); await autoRefresh(ws, pending.exchangeBaseUrl);
break; break;
case PendingTaskType.Deposit: { case PendingTaskType.Deposit: {
const cts = CancellationToken.create(); await processDepositGroup(ws, pending.depositGroupId, {
ws.taskCancellationSourceForDeposit = cts; forceNow,
try { });
await processDepositGroup(ws, pending.depositGroupId, {
cancellationToken: cts.token,
});
} finally {
cts.dispose();
delete ws.taskCancellationSourceForDeposit;
}
break; break;
} }
case PendingTaskType.Backup: case PendingTaskType.Backup:
@ -497,11 +487,8 @@ async function getExchangeTos(
exchangeBaseUrl: string, exchangeBaseUrl: string,
acceptedFormat?: string[], acceptedFormat?: string[],
): Promise<GetExchangeTosResult> { ): Promise<GetExchangeTosResult> {
const { exchangeDetails } = await updateExchangeFromUrl( // FIXME: download ToS in acceptable format if passed!
ws, const { exchangeDetails } = await updateExchangeFromUrl(ws, exchangeBaseUrl);
exchangeBaseUrl,
acceptedFormat,
);
const content = exchangeDetails.termsOfServiceText; const content = exchangeDetails.termsOfServiceText;
const currentEtag = exchangeDetails.termsOfServiceLastEtag; const currentEtag = exchangeDetails.termsOfServiceLastEtag;
const contentType = exchangeDetails.termsOfServiceContentType; const contentType = exchangeDetails.termsOfServiceContentType;
@ -802,12 +789,9 @@ async function dispatchRequestInternal(
} }
case "addExchange": { case "addExchange": {
const req = codecForAddExchangeRequest().decode(payload); const req = codecForAddExchangeRequest().decode(payload);
await updateExchangeFromUrl( await updateExchangeFromUrl(ws, req.exchangeBaseUrl, {
ws, forceNow: req.forceUpdate,
req.exchangeBaseUrl, });
undefined,
req.forceUpdate,
);
return {}; return {};
} }
case "listExchanges": { case "listExchanges": {
@ -919,11 +903,11 @@ async function dispatchRequestInternal(
RefreshReason.Manual, RefreshReason.Manual,
); );
}); });
processRefreshGroup(ws, refreshGroupId.refreshGroupId, true).catch( processRefreshGroup(ws, refreshGroupId.refreshGroupId, {
(x) => { forceNow: true,
logger.error(x); }).catch((x) => {
}, logger.error(x);
); });
return { return {
refreshGroupId, refreshGroupId,
}; };
@ -1170,7 +1154,7 @@ class InternalWalletStateImpl implements InternalWalletState {
memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle(); memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle();
memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
cryptoApi: TalerCryptoInterface; cryptoApi: TalerCryptoInterface;
cryptoDispatcher: CryptoDispatcher; cryptoDispatcher: CryptoDispatcher;

View File

@ -20,7 +20,7 @@
*/ */
import { Balance, parsePaytoUri } from "@gnu-taler/taler-util"; import { Balance, parsePaytoUri } from "@gnu-taler/taler-util";
import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import type { DepositGroupFees } from "@gnu-taler/taler-wallet-core/src/operations/deposits.js";
import { createExample } from "../test-utils.js"; import { createExample } from "../test-utils.js";
import { View as TestedComponent } from "./DepositPage.js"; import { View as TestedComponent } from "./DepositPage.js";
@ -30,7 +30,7 @@ export default {
argTypes: {}, argTypes: {},
}; };
async function alwaysReturnFeeToOne(): Promise<DepositFee> { async function alwaysReturnFeeToOne(): Promise<DepositGroupFees> {
const fee = { const fee = {
currency: "EUR", currency: "EUR",
value: 1, value: 1,

View File

@ -21,7 +21,7 @@ import {
Balance, Balance,
PaytoUri, PaytoUri,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import { DepositGroupFees } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Loading } from "../components/Loading.js"; import { Loading } from "../components/Loading.js";
@ -68,7 +68,7 @@ export function DepositPage({ currency, onCancel, onSuccess }: Props): VNode {
async function getFeeForAmount( async function getFeeForAmount(
p: PaytoUri, p: PaytoUri,
a: AmountJson, a: AmountJson,
): Promise<DepositFee> { ): Promise<DepositGroupFees> {
const account = `payto://${p.targetType}/${p.targetPath}`; const account = `payto://${p.targetType}/${p.targetPath}`;
const amount = Amounts.stringify(a); const amount = Amounts.stringify(a);
return await wxApi.getFeeForDeposit(account, amount); return await wxApi.getFeeForDeposit(account, amount);
@ -106,7 +106,7 @@ interface ViewProps {
onCalculateFee: ( onCalculateFee: (
account: PaytoUri, account: PaytoUri,
amount: AmountJson, amount: AmountJson,
) => Promise<DepositFee>; ) => Promise<DepositGroupFees>;
} }
type State = NoBalanceState | NoAccountsState | DepositState; type State = NoBalanceState | NoAccountsState | DepositState;
@ -135,12 +135,12 @@ export function useComponentState(
onCalculateFee: ( onCalculateFee: (
account: PaytoUri, account: PaytoUri,
amount: AmountJson, amount: AmountJson,
) => Promise<DepositFee>, ) => Promise<DepositGroupFees>,
): State { ): State {
const accountMap = createLabelsForBankAccount(accounts); const accountMap = createLabelsForBankAccount(accounts);
const [accountIdx, setAccountIdx] = useState(0); const [accountIdx, setAccountIdx] = useState(0);
const [amount, setAmount] = useState<number | undefined>(undefined); const [amount, setAmount] = useState<number | undefined>(undefined);
const [fee, setFee] = useState<DepositFee | undefined>(undefined); const [fee, setFee] = useState<DepositGroupFees | undefined>(undefined);
function updateAmount(num: number | undefined) { function updateAmount(num: number | undefined) {
setAmount(num); setAmount(num);
setFee(undefined); setFee(undefined);

View File

@ -59,7 +59,7 @@ import {
RemoveBackupProviderRequest, RemoveBackupProviderRequest,
TalerError, TalerError,
} from "@gnu-taler/taler-wallet-core"; } from "@gnu-taler/taler-wallet-core";
import type { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits"; import type { DepositGroupFees } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import type { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw"; import type { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw";
import { platform, MessageFromBackend } from "./platform/api.js"; import { platform, MessageFromBackend } from "./platform/api.js";
@ -143,7 +143,7 @@ export function resetDb(): Promise<void> {
export function getFeeForDeposit( export function getFeeForDeposit(
depositPaytoUri: string, depositPaytoUri: string,
amount: AmountString, amount: AmountString,
): Promise<DepositFee> { ): Promise<DepositGroupFees> {
return callBackend("getFeeForDeposit", { return callBackend("getFeeForDeposit", {
depositPaytoUri, depositPaytoUri,
amount, amount,