#Race conditions with Stripe Webhooks

1 messages · Page 1 of 1 (latest)

trim sinew
#

I'd set up a job on the customer.subscription.created webhook to fire an event (i.e. subscription-created)

Then, you can have your logic check once for subscription, and if it doesn't exist, call io.waitForEvent to avoid manually checking a bunch

#

If you're worried about another small race between the check and calling io.waitForEvent, you could pattern it differently, but imo that could muddy the logic flow a bit looking at Stripe's API (not an expert there), that may not be doable, but the gist was:

  • have Job A trigger on webhook invoice.paid and send an event validate-subscription
  • have Job B trigger on webhook customer.subscription.created and send the same event validate-subscription
  • have Job C trigger on event validate-subscription and load both invoice and subscription information; if both exist, continue with processing, else exit
lapis granite
#

Uh yeah this is really annoying, we have the same problem too. This is a really common issue and Stripe don't guarantee the order the webhooks are received.

#

Basically don't rely on the info from the webhook, you need to call their API to check the status of the subscription after you've received the webhook.

I'd do what @trim sinew is suggesting and do SDK calls to Stripe from inside Job C

#

We have a billing-events Slack channel where we send messages for all events, which allows us to keep an eye on if something seems wrong.

#

These are the triggers we have jobs linked to:

  • stripe.onCustomerSubscriptionCreated()
  • stripe.onCustomerSubscriptionUpdated()
  • stripe.onCustomerSubscriptionDeleted()
  • stripe.onInvoiceCreated()
  • stripe.onInvoiceFinalized()
  • stripe.onInvoicePaid()
  • stripe.onInvoicePaymentFailed({ filter: { status: ["void", "uncollectible"] }})
lapis granite
#

This won't work as you expect because the cacheKey check-subscription-${randomID} will always be different. Wait exits and then re-executes the entire run function, so each time it'll run that task again and attempts will always be 1. So the error will never get thrown. If the subscription does exist in the database at some point it will continue beyond that while loop and continue. But it'll never stop executing if there's an issue.

trim sinew
#

^ attempts could be saved in io.cache.run so if/when execution breaks, it can pick up what iteration it's on

or just use a fixed id for the cache keys and it will iterate through them instantly

lapis granite
#

You can do this using the retry mechanisms of io.runTask

trim sinew
#

🤔

#

Run check inside of a task and throw an error if the subscription doesn't exist?

lapis granite
#

Just writing some code…

#

This should work. I haven't tested this but the logic makes sense:

const subscriptionData = await io.runTask("get-subscription", async () => {
  const randomID = Math.random().toString(36).substring(7)
  const { data: subscriptionData } = await io.supabase.runTask(
    `check-subscription-${randomID}`,
    async (db) => {
      return db
        .from("stripe_subscriptions")
        .select("")
        .eq("stripe_subscription_id", subscriptionId)
        .single();
    }
  );
  subscriptionExists = !!subscriptionData;

  if (!subscriptionExists) {
    throw new Error("Subscription not found, retrying...");
  }

  return subscriptionData;
}, {
  //this retries every 2 seconds for 10 times
  retry: {
    limit: 10,
    factor: 1,
    minTimeoutInMs: 2000,
    maxTimeoutInMs: 2000,
    randomize: false,
  }
});
#

If you throw an error from inside runTask and you have specified the retry options, it will retry (up to the limit)

#

You could ditch the use io.supabase.runTask and just do a direct query of your supabase database without using the integration, which would mean you wouldn't need to have the random id. But this should work.

#

I've not tested it but the theory behind this definitely works

trim sinew
#

Looks sound to me, now that I remember the retry config eixsts