handle transient pay errors (fixes #6607)

Also add a test case for the behavior.
This commit is contained in:
Florian Dold 2020-11-04 12:07:34 +01:00
parent dffb293f2a
commit df91441296
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
3 changed files with 230 additions and 8 deletions

View File

@ -0,0 +1,169 @@
/*
This file is part of GNU Taler
(C) 2020 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import { runTest, GlobalTestState, MerchantPrivateApi } from "./harness";
import {
withdrawViaBank,
createFaultInjectedMerchantTestkudosEnvironment,
} from "./helpers";
import {
PreparePayResultType,
codecForMerchantOrderStatusUnpaid,
ConfirmPayResultType,
URL,
codecForExchangeKeysJson,
TalerErrorDetails,
TalerErrorCode,
} from "taler-wallet-core";
import axios from "axios";
import { FaultInjectionRequestContext, FaultInjectionResponseContext } from "./faultInjection";
/**
* Run test for a payment where the merchant has a transient
* failure in /pay
*/
runTest(async (t: GlobalTestState) => {
// Set up test environment
const {
wallet,
bank,
exchange,
faultyMerchant,
} = await createFaultInjectedMerchantTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
const merchant = faultyMerchant;
let orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
order: {
summary: "Buy me!",
amount: "TESTKUDOS:5",
fulfillment_url: "https://example.com/article42",
},
});
let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
orderId: orderResp.order_id,
sessionId: "mysession-one",
});
t.assertTrue(orderStatus.order_status === "unpaid");
t.assertTrue(orderStatus.already_paid_order_id === undefined);
let publicOrderStatusUrl = orderStatus.order_status_url;
let publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
validateStatus: () => true,
});
if (publicOrderStatusResp.status != 402) {
throw Error(
`expected status 402 (before claiming), but got ${publicOrderStatusResp.status}`,
);
}
let pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
publicOrderStatusResp.data,
);
console.log(pubUnpaidStatus);
let preparePayResp = await wallet.preparePay({
talerPayUri: pubUnpaidStatus.taler_pay_uri,
});
t.assertTrue(preparePayResp.status === PreparePayResultType.PaymentPossible);
const proposalId = preparePayResp.proposalId;
publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
validateStatus: () => true,
});
if (publicOrderStatusResp.status != 402) {
throw Error(
`expected status 402 (after claiming), but got ${publicOrderStatusResp.status}`,
);
}
pubUnpaidStatus = codecForMerchantOrderStatusUnpaid().decode(
publicOrderStatusResp.data,
);
let faultInjected = false;
faultyMerchant.faultProxy.addFault({
modifyResponse(ctx: FaultInjectionResponseContext) {
console.log("in modifyResponse");
const url = new URL(ctx.request.requestUrl);
console.log("pathname is", url.pathname);
if (!url.pathname.endsWith("/pay")) {
return;
}
if (faultInjected) {
console.log("not injecting pay fault");
return;
}
faultInjected = true;
console.log("injecting pay fault");
const err: TalerErrorDetails = {
code: TalerErrorCode.PAY_DB_FETCH_TRANSACTION_ERROR,
details: {},
hint: "huh",
message: "something went wrong",
};
ctx.responseBody = Buffer.from(JSON.stringify(err));
ctx.statusCode = 500;
}
});
const confirmPayResp = await wallet.confirmPay({
proposalId,
});
console.log(confirmPayResp);
t.assertTrue(confirmPayResp.type === ConfirmPayResultType.Pending);
t.assertTrue(faultInjected);
const confirmPayRespTwo = await wallet.confirmPay({
proposalId,
});
t.assertTrue(confirmPayRespTwo.type === ConfirmPayResultType.Done);
// Now ask the merchant if paid
publicOrderStatusResp = await axios.get(publicOrderStatusUrl, {
validateStatus: () => true,
});
console.log(publicOrderStatusResp.data);
if (publicOrderStatusResp.status != 202) {
console.log(publicOrderStatusResp.data);
throw Error(
`expected status 202 (after paying), but got ${publicOrderStatusResp.status}`,
);
}
});

View File

@ -435,7 +435,7 @@ async function recordConfirmPay(
} else {
sessionId = proposal.downloadSessionId;
}
logger.trace(`recording payment with session ID ${sessionId}`);
logger.trace(`recording payment on ${proposal.orderId} with session ID ${sessionId}`);
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
const t: PurchaseRecord = {
abortStatus: AbortStatus.None,
@ -530,10 +530,6 @@ async function incrementProposalRetry(
}
}
/**
* FIXME: currently pay operations aren't ever automatically retried.
* But we still keep a payRetryInfo around in the database.
*/
async function incrementPurchasePayRetry(
ws: InternalWalletState,
proposalId: string,
@ -947,7 +943,7 @@ async function submitPay(
session_id: purchase.lastSessionId,
};
logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2));
logger.trace("making pay request ... ", JSON.stringify(reqBody, undefined, 2));
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payUrl, reqBody, {
@ -955,6 +951,27 @@ async function submitPay(
}),
);
logger.trace(`got resp ${JSON.stringify(resp)}`);
// Hide transient errors.
if (
purchase.payRetryInfo.retryCounter <= 5 &&
resp.status >= 500 &&
resp.status <= 599
) {
logger.trace("treating /pay error as transient");
const err = makeErrorDetails(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
"/pay failed",
getHttpResponseErrorDetails(resp),
);
incrementPurchasePayRetry(ws, proposalId, undefined);
return {
type: ConfirmPayResultType.Pending,
lastError: err,
};
}
const merchantResp = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantPayResponse(),
@ -989,6 +1006,23 @@ async function submitPay(
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payAgainUrl, reqBody),
);
// Hide transient errors.
if (
purchase.payRetryInfo.retryCounter <= 5 &&
resp.status >= 500 &&
resp.status <= 599
) {
const err = makeErrorDetails(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
"/paid failed",
getHttpResponseErrorDetails(resp),
);
incrementPurchasePayRetry(ws, proposalId, undefined);
return {
type: ConfirmPayResultType.Pending,
lastError: err,
};
}
if (resp.status !== 204) {
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
@ -999,6 +1033,11 @@ async function submitPay(
await storePayReplaySuccess(ws, proposalId, sessionId);
}
ws.notify({
type: NotificationType.PayOperationSuccess,
proposalId: purchase.proposalId,
});
return {
type: ConfirmPayResultType.Done,
contractTerms: JSON.parse(purchase.contractTermsRaw),
@ -1171,7 +1210,7 @@ export async function confirmPay(
let purchase = await ws.db.get(
Stores.purchases,
d.contractData.contractTermsHash,
proposalId,
);
if (purchase) {

View File

@ -53,6 +53,7 @@ export enum NotificationType {
ProposalOperationError = "proposal-error",
TipOperationError = "tip-error",
PayOperationError = "pay-error",
PayOperationSuccess = "pay-operation-success",
WithdrawOperationError = "withdraw-error",
ReserveNotYetFound = "reserve-not-yet-found",
ReserveOperationError = "reserve-error",
@ -220,6 +221,18 @@ export interface ReserveRegisteredWithBankNotification {
type: NotificationType.ReserveRegisteredWithBank;
}
/**
* Notification sent when a pay (or pay replay) operation succeeded.
*
* We send this notification because the confirmPay request can return
* a "confirmed" response that indicates that the payment has been confirmed
* by the user, but we're still waiting for the payment to succeed or fail.
*/
export interface PayOperationSuccessNotification {
type: NotificationType.PayOperationSuccess;
proposalId: string;
}
export type WalletNotification =
| WithdrawOperationErrorNotification
| ReserveOperationErrorNotification
@ -254,4 +267,5 @@ export type WalletNotification =
| PendingOperationProcessedNotification
| ProposalRefusedNotification
| ReserveRegisteredWithBankNotification
| ReserveNotYetFoundNotification;
| ReserveNotYetFoundNotification
| PayOperationSuccessNotification;