#Race conditions with Stripe Webhooks
1 messages · Page 1 of 1 (latest)
If you're worried about another small race between the check and calling looking at Stripe's API (not an expert there), that may not be doable, but the gist was:io.waitForEvent, you could pattern it differently, but imo that could muddy the logic flow a bit
- have Job A trigger on webhook
invoice.paidand send an eventvalidate-subscription - have Job B trigger on webhook
customer.subscription.createdand send the same eventvalidate-subscription - have Job C trigger on event
validate-subscriptionand load both invoice and subscription information; if both exist, continue with processing, else exit
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.
Lots of people complaining in this thread: https://github.com/stripe/stripe-cli/issues/418
The more information we have the easier it is for us to help. Feel free to remove any sections that might not apply Issue Sometimes webhooks are being sent incorrectly. Specific events: customer.su...
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"] }})
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.
^ 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
You can do this using the retry mechanisms of io.runTask
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
Looks sound to me, now that I remember the retry config eixsts