Merge pull request #173 from stripe-ios/davidme/handle-400-better

Retry `challenge_confirm` requests
This commit is contained in:
davidme-stripe 2021-04-06 16:28:47 -07:00 committed by GitHub
commit 2d51328b94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 123 additions and 6 deletions

View File

@ -1013,7 +1013,7 @@ extension STPAPIClient {
// MARK: - ThreeDS2
extension STPAPIClient {
/// Kicks off 3DS2 authentication.
func authenticate3DS2(
@objc func authenticate3DS2(
_ authRequestParams: STDSAuthenticationRequestParameters,
sourceIdentifier sourceID: String,
returnURL returnURLString: String?,
@ -1049,7 +1049,7 @@ extension STPAPIClient {
}
/// Endpoint to call to indicate that the challenge flow for a 3DS2 authentication has finished.
func complete3DS2Authentication(
@objc func complete3DS2Authentication(
forSource sourceID: String, completion: @escaping STPBooleanSuccessBlock
) {

View File

@ -1394,7 +1394,8 @@ public class STPPaymentHandler: NSObject, SFSafariViewControllerDelegate, STPURL
}
}
func _markChallengeCompleted(withCompletion completion: @escaping STPBooleanSuccessBlock) {
static let maxChallengeRetries = 3
func _markChallengeCompleted(withCompletion completion: @escaping STPBooleanSuccessBlock, retryCount: Int = maxChallengeRetries) {
guard let currentAction = currentAction,
let threeDSSourceID = currentAction.nextAction()?.useStripeSDK?.threeDSSourceID
else {
@ -1430,11 +1431,24 @@ public class STPPaymentHandler: NSObject, SFSafariViewControllerDelegate, STPURL
} else {
assert(false, "currentAction is an unknown type or nil intent.")
}
} else {
// This isn't guaranteed to succeed if the ACS isn't ready yet.
// Try it a few more times if it fails with a 400. (RUN_MOBILESDK-126)
if retryCount > 0 && (error as NSError?)?.code == STPErrorCode.invalidRequestError.rawValue {
// Add some backoff time:
let delayTime = TimeInterval(
pow(Double(1 + Self.maxChallengeRetries - retryCount), Double(2))
)
DispatchQueue.main.asyncAfter(deadline: .now() + delayTime) {
self._markChallengeCompleted(withCompletion: completion,
retryCount: retryCount - 1)
}
} else {
completion(success, error)
}
}
}
}
// MARK: - Errors

View File

@ -0,0 +1,9 @@
//
// URLSession+Retry.swift
// StripeiOS
//
// Created by David Estes on 3/26/21.
// Copyright © 2021 Stripe, Inc. All rights reserved.
//
import Foundation

View File

@ -7,7 +7,9 @@
//
import XCTest
import Foundation
@testable import Stripe
@testable import Stripe3DS2
class STPPaymentHandlerTests: XCTestCase {
@ -68,6 +70,60 @@ class STPPaymentHandlerTests: XCTestCase {
wait(for: [paymentHandlerExpectation], timeout: 2*8)
}
func testPaymentHandlerRetriesWithBackoff() {
STPPaymentHandler.sharedHandler.apiClient = PaymentHandlerTestsMockAPIClient()
let paymentHandlerExpectation = expectation(
description: "paymentHandlerFinished")
var inProgress = true
// Meaningless cert, generated for this test
// Expires 3/2/2121: Apologies to future engineers!
let cert = """
MIIBijCB9AIBATANBgkqhkiG9w0BAQUFADANMQswCQYDVQQGEwJVUzAgFw0yMTAz
MjYxODQyNDVaGA8yMTIxMDMwMjE4NDI0NVowDTELMAkGA1UEBhMCVVMwgZ8wDQYJ
KoZIhvcNAQEBBQADgY0AMIGJAoGBAL6rIW6t+8eo1exqhvYt8H1vM+TyHNNychlD
hILw745yXZQAy9ByRG3euYEydE3SFINgWBCUuwWmkNfsZUW7Uci1PBMglBFHJrE8
8ZvtuJgnPkqmu97a9JkyROiaqAmqoMDP95HiZG5i3a1E/QPpPyYA3VJ/El17Qqkl
aHN32qzjAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAUhxbGQ5sQMDUqFTvibU7RzqL
dTaFhdjTDBu5YeIbXXUrJSG2AydXRq7OacRksnQhvNYXimfcgfse46XQG7rKUCfj
kbazRiRxMZylTz8zbePAFcVq6zxJ+RBVrv51D+/JgbCcQ50nZiocllR0J9UL8CKZ
obaUC2OjBbSuCZwF8Ig=
"""
let rootCA = """
MIIBkDCB+gIJAJ3pmjFOkxTXMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT
MB4XDTIxMDMyNjE4NDEzMVoXDTIyMDMyNjE4NDEzMVowDTELMAkGA1UEBhMCVVMw
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAKmFDGPV77Fk/wgUMwbxjQk+bpUY
cTjNBsjK3xMaUWeE17Sry6IguO1iWaXVey9YJ1Dm83PNO/5i9nHh3gmFhEJmc55T
g+0tZQigjTcs5/BfmWtrfPYIWqKvIJqkkHrIEJnwavAS5OFGyDArHLwUtsgJbDmW
tIeQg3EH/8BSWR0BAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEATY2aQvZZJLPgUr1/
oDvRy6KZ6p7n3+jXF8DNvVOIaQRD4Ndk5NfStteIT5XvzfmD6QqpG3nlJ6Wy3oSP
03KvO4GWIyP9cuP/QLaEmxJIYKwPrdxLkUHFfzyy8tN54xOWPxN4Up9gVN6pSdVk
KWrsPfhPs3G57wir370Q69lV/8A=
"""
let iauss = STPIntentActionUseStripeSDK(encryptionInfo: ["certificate": cert, "directory_server_id": "0000000000", "root_certificate_authorities": [rootCA]], directoryServerName: "none", directoryServerKeyID: "none", serverTransactionID: "none", threeDSSourceID: "none", allResponseFields: [:])
let action = STPIntentAction(type: .useStripeSDK, redirectToURL: nil, alipayHandleRedirect: nil, useStripeSDK: iauss, oxxoDisplayDetails: nil, allResponseFields: [:])
let setupIntent = STPSetupIntent(stripeID: "test", clientSecret: "test", created: Date(), customerID: nil, stripeDescription: nil, livemode: false, nextAction: action, paymentMethodID: "test", paymentMethodTypes: [], status: .requiresAction, usage: .none, lastSetupError: nil, allResponseFields: [:])
// We expect this request to retry a few times with exponential backoff before calling the completion handler.
STPPaymentHandler.sharedHandler._handleNextAction(for: setupIntent, with: self, returnURL: nil) { (status, si, error) in
XCTAssertEqual(status, .failed)
inProgress = false
paymentHandlerExpectation.fulfill()
}
let checkedStillInProgress = expectation(description: "Checked that we're still in progress after 2s")
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0) {
// Make sure we're still in progress after 2 seconds
// This shows that we're retrying the 3DS2 request a few times
// while applying an appropriate amount of backoff.
XCTAssertEqual(inProgress, true)
checkedStillInProgress.fulfill()
}
wait(for: [paymentHandlerExpectation, checkedStillInProgress], timeout: 30)
STPPaymentHandler.sharedHandler.apiClient = STPAPIClient.shared
}
}
extension STPPaymentHandlerTests: STPAuthenticationContext {
@ -77,3 +133,41 @@ extension STPPaymentHandlerTests: STPAuthenticationContext {
}
class PaymentHandlerTestsMockAPIClient: STPAPIClient {
override init() {
super.init()
}
override func authenticate3DS2(_ authRequestParams: STDSAuthenticationRequestParameters, sourceIdentifier sourceID: String, returnURL returnURLString: String?, maxTimeout: Int, completion: @escaping STP3DS2AuthenticateCompletionBlock) {
let jsonText = """
{
"state": "challenge_required",
"livemode": "false",
"ares" : {
"dsTransID": "4e4750e7-6ab5-45a4-accf-9c668ed3b5a7",
"acsTransID": "fa695a82-a48c-455d-9566-a652058dda27",
"p_messageVersion": "1.0.5",
"acsOperatorID": "acsOperatorUL",
"sdkTransID": "D77EB83F-F317-4E29-9852-EBAAB55515B7",
"eci": "00",
"dsReferenceNumber": "3DS_LOA_DIS_PPFU_020100_00010",
"acsReferenceNumber": "3DS_LOA_ACS_PPFU_020100_00009",
"threeDSServerTransID": "fc7a39de-dc41-4b65-ba76-a322769b2efc",
"messageVersion": "2.1.0",
"authenticationValue": "AABBCCDDEEFFAABBCCDDEEFFAAA=",
"messageType": "pArs",
"transStatus": "C",
"acsChallengeMandated": "NO"
}
}
"""
let json = try! JSONSerialization.jsonObject(with: jsonText.data(using: .utf8)!, options: []) as! [AnyHashable: Any]
let response = STP3DS2AuthenticateResponse.decodedObject(fromAPIResponse: json)
completion(response, nil)
}
override func complete3DS2Authentication(forSource sourceID: String, completion: @escaping STPBooleanSuccessBlock) {
return completion(false, NSError(domain: STPError.stripeDomain, code: STPErrorCode.invalidRequestError.rawValue, userInfo: [:]))
}
}