wallet-core: hide transient pay errors

This commit is contained in:
Florian Dold 2022-09-19 12:13:31 +02:00
parent 548cecca21
commit fd752f3171
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
3 changed files with 76 additions and 12 deletions

View File

@ -70,6 +70,9 @@ export interface DetailsMap {
[TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE]: {}; [TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE]: {};
[TalerErrorCode.WALLET_CORE_NOT_AVAILABLE]: {}; [TalerErrorCode.WALLET_CORE_NOT_AVAILABLE]: {};
[TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR]: {}; [TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR]: {};
[TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR]: {
requestError: TalerErrorDetail;
};
} }
type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : never; type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : never;
@ -79,7 +82,9 @@ export function makeErrorDetail<C extends TalerErrorCode>(
detail: ErrBody<C>, detail: ErrBody<C>,
hint?: string, hint?: string,
): TalerErrorDetail { ): TalerErrorDetail {
// FIXME: include default hint? if (!hint && !(detail as any).hint) {
hint = getDefaultHint(code);
}
return { code, hint, ...detail }; return { code, hint, ...detail };
} }
@ -99,6 +104,15 @@ export function summarizeTalerErrorDetail(ed: TalerErrorDetail): string {
return `Error (${ed.code}/${errName})`; return `Error (${ed.code}/${errName})`;
} }
function getDefaultHint(code: number): string {
const errName = TalerErrorCode[code];
if (errName) {
return `Error (${errName})`;
} else {
return `Error (<unknown>)`;
}
}
export class TalerError<T = any> extends Error { export class TalerError<T = any> extends Error {
errorDetail: TalerErrorDetail & T; errorDetail: TalerErrorDetail & T;
private constructor(d: TalerErrorDetail & T) { private constructor(d: TalerErrorDetail & T) {
@ -113,12 +127,7 @@ export class TalerError<T = any> extends Error {
hint?: string, hint?: string,
): TalerError { ): TalerError {
if (!hint) { if (!hint) {
const errName = TalerErrorCode[code]; hint = getDefaultHint(code);
if (errName) {
hint = `Error (${errName})`;
} else {
hint = `Error (<unknown>)`;
}
} }
return new TalerError<unknown>({ code, hint, ...detail }); return new TalerError<unknown>({ code, hint, ...detail });
} }

View File

@ -83,6 +83,7 @@ import {
EXCHANGE_COINS_LOCK, EXCHANGE_COINS_LOCK,
InternalWalletState, InternalWalletState,
} from "../internal-wallet-state.js"; } from "../internal-wallet-state.js";
import { PendingTaskType } from "../pending-types.js";
import { assertUnreachable } from "../util/assertUnreachable.js"; import { assertUnreachable } from "../util/assertUnreachable.js";
import { import {
CoinSelectionTally, CoinSelectionTally,
@ -105,7 +106,11 @@ import {
RetryTags, RetryTags,
scheduleRetry, scheduleRetry,
} from "../util/retries.js"; } from "../util/retries.js";
import { spendCoins } from "../wallet.js"; import {
spendCoins,
storeOperationError,
storeOperationPending,
} from "../wallet.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { getTotalRefreshCost } from "./refresh.js"; import { getTotalRefreshCost } from "./refresh.js";
import { makeEventId } from "./transactions.js"; import { makeEventId } from "./transactions.js";
@ -1519,10 +1524,43 @@ export async function runPayForConfirmPay(
transactionId: makeEventId(TransactionType.Payment, proposalId), transactionId: makeEventId(TransactionType.Payment, proposalId),
}; };
} }
case OperationAttemptResultType.Error: case OperationAttemptResultType.Error: {
// FIXME: allocate error code! // We hide transient errors from the caller.
throw Error("payment failed"); const opRetry = await ws.db
.mktx((x) => [x.operationRetries])
.runReadOnly(async (tx) =>
tx.operationRetries.get(RetryTags.byPaymentProposalId(proposalId)),
);
const maxRetry = 3;
const numRetry = opRetry?.retryInfo.retryCounter ?? 0;
if (
res.errorDetail.code ===
TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR &&
numRetry < maxRetry
) {
// Pretend the operation is pending instead of reporting
// an error, but only up to maxRetry attempts.
await storeOperationPending(
ws,
RetryTags.byPaymentProposalId(proposalId),
);
return {
type: ConfirmPayResultType.Pending,
lastError: opRetry?.lastError,
transactionId: makeEventId(TransactionType.Payment, proposalId),
};
} else {
// FIXME: allocate error code!
await storeOperationError(
ws,
RetryTags.byPaymentProposalId(proposalId),
res.errorDetail,
);
throw Error("payment failed");
}
}
case OperationAttemptResultType.Pending: case OperationAttemptResultType.Pending:
await storeOperationPending(ws, `${PendingTaskType.Pay}:${proposalId}`);
return { return {
type: ConfirmPayResultType.Pending, type: ConfirmPayResultType.Pending,
transactionId: makeEventId(TransactionType.Payment, proposalId), transactionId: makeEventId(TransactionType.Payment, proposalId),
@ -1536,7 +1574,7 @@ export async function runPayForConfirmPay(
} }
/** /**
* Add a contract to the wallet and sign coins, and send them. * Confirm payment for a proposal previously claimed by the wallet.
*/ */
export async function confirmPay( export async function confirmPay(
ws: InternalWalletState, ws: InternalWalletState,
@ -1698,6 +1736,20 @@ export async function processPurchasePay(
); );
logger.trace(`got resp ${JSON.stringify(resp)}`); logger.trace(`got resp ${JSON.stringify(resp)}`);
if (resp.status >= 500 && resp.status <= 599) {
const errDetails = await readUnexpectedResponseDetails(resp);
return {
type: OperationAttemptResultType.Error,
errorDetail: makeErrorDetail(
TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
{
requestError: errDetails,
},
),
};
}
if (resp.status === HttpStatusCode.BadRequest) { if (resp.status === HttpStatusCode.BadRequest) {
const errDetails = await readUnexpectedResponseDetails(resp); const errDetails = await readUnexpectedResponseDetails(resp);
logger.warn("unexpected 400 response for /pay"); logger.warn("unexpected 400 response for /pay");

View File

@ -205,6 +205,9 @@ export namespace RetryTags {
export function forBackup(backupRecord: BackupProviderRecord): string { export function forBackup(backupRecord: BackupProviderRecord): string {
return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`; return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`;
} }
export function byPaymentProposalId(proposalId: string): string {
return `${PendingTaskType.Pay}:${proposalId}`;
}
} }
export async function scheduleRetryInTx( export async function scheduleRetryInTx(