GAE/P: Transaction safety with API calls
Asked Answered
P

1

9

Suppose you use a transaction to process a Stripe payment and update a user entity:

@ndb.transactional
def process_payment(user_key, amount):
    user = user_key.get()
    user.stripe_payment(amount) # API call to Stripe
    user.balance += amount
    user.put()

It is possible that the Stripe API call succeeds but that the put fails because of contention. The user would then be charged, but his account wouldn't reflect the payment.

You could pull the Stripe API call out of the transaction and do the transaction afterwards, but it seems like you still have the same problem. The charge succeeds but the transaction fails and the user's account isn't credited.

This seems like a really common scenario. How does one properly handle this?

Pontificate answered 27/4, 2019 at 23:9 Comment(2)
Maybe you'll need to implement some form of distributed transaction, e.g. two-phase commit (en.wikipedia.org/wiki/Two-phase_commit_protocol)..?Jugum
@thebjorn, I'll look into that. I was hoping someone had a fairly easy solution using GAE services. Everyone taking payments has to address this so I was hoping to get more activity on this question...Pontificate
G
3

For proper operation the transactional function need to be idempotent. So you can't make the stripe call inside such function as it would make it non-idempotent.

I'd make the stripe call separate and, on API success, I'd call a transactional function to update the user's account balance (which can be safely retried in case of contention).

Maybe even create a separate, independent entity to reflect the stripe API call result? Such entity should have no room for contention since it's only written once - when the stripe transaction takes place. This would allow you to:

  • keep a history of account transactions - pointing to these entities
  • have some sanity checks looking for orphan stripe transaction (if for whatever reason the account transaction call fails even after retries) and do something about it.

@thebjorn's comment is a good one: a multi-step approach could make the process pretty solid:

  • a transactional function updating the account with the intent to execute a stripe transaction, which also transactionally enqueues a push task to perform the stripe API call. The task is enqueued only if the transaction succeds
  • the push queue task makes the stripe API call (eventually creating the stripe transaction entity) and, on success, calls the transactional function to update the account balance
Gingili answered 5/5, 2019 at 22:34 Comment(5)
Stripe has a feature to allow their API calls to be idempotent (you include a UUID with the call).Pontificate
I don't understand the last bullet point. Suppose the transaction to update the account balance fails? Then we don't know if the Stripe API call succeeded or not.Pontificate
For the last point - the call to update the account balance only happens if the stripe call was successful, so you know it was successful :) But with the stripe API idempotent you can simply retry the pair (stripe call plus the account balance update) until both succeed.Gingili
Thanks. Good suggestions. One downside of the above is that tasks can sometimes be slow to execute. It would be nice to avoid tasks to help ensure quicker datastore updates.Pontificate
You don't need the tasks with the stripe API idempotent - you just need to tweak your existing transactional function to handle the UUID for that. The entire function would be retried in case of transaction failure.Gingili

© 2022 - 2024 — McMap. All rights reserved.