Stripe生产环境成功重定向超时问题排查求助
问题现象
- 测试环境运行正常,生产环境中用户提交支付信息后,Stripe结账页面加载图标持续转动,无法重定向回应用
- Stripe控制台显示客户账户、订阅已成功创建,但Webhook触发失败,错误提示为
Timed out connecting to remote host - 重定向失败会导致Webhook无法触发,进而无法为用户配置对应订阅等级的核心功能
怀疑点
推测createCheckoutSession中的success_url配置可能存在阻塞:
success_url: `${webUrl}/users/${req.user._id}/activations/?session_id={CHECKOUT_SESSION_ID}`,
创建结账会话代码
module.exports.createCheckoutSession = async (req, res) => { const { currency, selectedPriceId } = req.body; const user = await User.findById(req.user.id); const cloakedPriceId = pricing[selectedPriceId].priceId; if (req.isAuthenticated()) { if (((!req.user.stripeId) || (req.user.stripeId.length < 1))) { const new_stripe_customer = await stripe.customers.create({ email: req.user.email, metadata: { user_id: req.user.id.toString(), }, }); let update = {}; await User.findByIdAndUpdate(req.user._id, update); if (!req.user.stripe_customer) { req.user.stripe_customer = {}; } else { req.user.stripe_customer[currency] = new_stripe_customer.id; } user.stripeId = new_stripe_customer.id; await user.save(); try { const session = await stripe.checkout.sessions.create({ mode: "subscription", customer: req.user.stripe_customer[currency], customer_email: req.user.email, line_items: [ { price: cloakedPriceId, quantity: 1, }, ], success_url: `${webUrl}/users/${req.user._id}/activations/?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${webUrl}/canceled.html`, }); return res.redirect(303, session.url); } catch (e) { res.status(400); return res.send({ error: { message: e.message, } }); } } else { try { const session = await stripe.checkout.sessions.create({ mode: "subscription", customer: req.user.stripeId, line_items: [ { price: cloakedPriceId, quantity: 1, }, ], metadata: { user_id: req.user.id.toString(), }, success_url: `${webUrl}/users/${req.user._id}/activations/?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${webUrl}/canceled.html`, }); return res.redirect(303, session.url); } catch (e) { res.status(400); return res.send({ error: { message: e.message, } }); } } } req.flash('error', "You must login before changing billing"); res.render('/login'); };
Webhook代码
module.exports.stripeWebhook = async (req, res) => { let data; // Check if webhook signing is configured. const webhookSecret = stripeWebhookSecret; if (webhookSecret) { // Retrieve the event by verifying the signature using the raw body and secret. let event; let signature = req.headers["stripe-signature"]; console.log("signature ", signature); try { event = stripe.webhooks.constructEvent( req.body, signature, webhookSecret ); } catch (err) { console.log(`⚠️ Webhook signature verification failed.`); return res.sendStatus(400); } // Extract the object from the event. data = event.data; eventType = event.type; } else { // Webhook signing is recommended, but if the secret is not configured in `config.js`, // retrieve the event data directly from the request body. data = req.body.data; eventType = req.body.type; console.log("event type ", eventType); } switch (eventType) { case 'payment_intent.succeeded': { // add name to stripe customer in stripe dashboard const paymentIntent = data.object; // console.log("payment intent ", paymentIntent); const customer = paymentIntent.customer; // console.log("customer ", customer); const name = paymentIntent.metadata.name; // console.log("name ", name); // use stripe customer id to check customer subscription status const customerSubscription = await stripe.customers.retrieve( customer, { expand: ['subscriptions'], } ); console.log("customer subscription ", customerSubscription); // const user = await User.findById(customerSubscription.metadata.user_id); // FIND USER BY email const user = await User.findOne({ email: customerSubscription.email }); console.log("user ", user); // get product name from stripe product api const product = await stripe.products.retrieve( customerSubscription.subscriptions.data[0].items.data[0].price.product ); console.log("product ", product); const productCap = product.name; productName = productCap.toLowerCase(); const subscriptionDetails = customerSubscription.subscriptions.data[0].status; console.log("product name ", productName); user.product = productName; user.subscription.active = subscriptionDetails; await user.save(); break; } case 'checkout.session.completed': // Payment is successful and the subscription is created. // You should provision the subscription and save the customer ID to your database. break; case 'invoice.paid': // Continue to provision the subscription as payments continue to be made. // Store the status in your database and check when a user accesses your service. // This approach helps you avoid hitting rate limits. break; case 'invoice.payment_failed': { // The payment failed or the customer does not have a valid payment method. // The subscription becomes past_due. Notify your customer and send them to the // customer portal to update their payment information. } break; case 'payment_intent.failed': { // The payment failed or the customer does not have a valid payment method. // The subscription becomes past_due. Notify your customer and send them to the // customer portal to update their payment information. } break; default: // Unhandled event type } res.sendStatus(200); };
客户端代码
<title>Checkout</title> <div class="row"> <h2 class="text-center">Confirm plan selection</h2> <p>Confirm plan before entering billing information</p> </div> <form action="/users/stripe/create-checkout-session" method="POST"> <div class="row row-cols-1 row-cols-md-3 g-4"> <div class="col"> <div class="card mb-4 rounded-3 shadow-sm"> <div class="card-header py-3"> <h4 class="my-0 fy-normal">Basic</h4> </div> <div class="card-body"> <h1 class="card-title pricing-card-title">$29.99 <small class="text-muted fw-light">/mo</small> </h1> <ul class="list-unstyled mt-3 mb-4"> <li class="p-1">2 QR links</li> <li class="p-1">Up to 35 participants per activation</li> <li class="p-1">5gb storge</li> <li class="p-1">Cancel or freeze whenever</li> </ul> <button class="button btn-primary btn-lg" type="hidden" id="selectedPriceId" name="selectedPriceId" value="basic">Confirm Basic</button> </div> </div> </div> <div class="col"> <div class="card mb-4 rounded-3 shadow-sm"> <div class="card-header py-3"> <h4 class="my-0 fy-normal">Scaled</h4> </div> <div class="card-body"> <h1 class="card-title pricing-card-title">$99.99 <small class="text-muted fw-light">/mo</small> </h1> <ul class="list-unstyled mt-3 mb-4"> <li class="p-1">7 QR links</li> <li class="p-1">Up to 100 participants per activation</li> <li class="p-1">10gb storge</li> <li class="p-1">Cancel or freeze whenever</li> </ul> <button class="button btn-primary btn-lg" type="hidden" id="selectedPrice" name="selectedPriceId" value="scaled">Confirm Scaled</button> </div> </div> </div> <div class="col"> <div class="card mb-4 rounded-3 shadow-sm"> <div class="card-header py-3"> <h4 class="my-0 fy-normal">Enterprise</h4> </div> <div class="card-body"> <h1 class="card-title pricing-card-title">$199.99 <small class="text-muted fw-light">/mo</small> </h1> <ul class="list-unstyled mt-3 mb-4"> <li class="p-1">10 QR links</li> <li class="p-1">Up to 1000 participants per activation</li> <li class="p-1">Unlimited storge</li> <li class="p-1">Cancel or freeze whenever</li> </ul> <button class="button btn-primary btn-lg" type="hidden" id="selectedPriceId" name="selectedPriceId" value="enterprise">Confirm Enterprise</button> </div> </div> </div> </div> </form>
问题原因分析及解决方案
1. Webhook超时是核心问题
Stripe要求Webhook接收端在10秒内返回响应,但你的Webhook代码中存在多个串行异步操作:调用Stripe API查询客户、订阅、产品,再查询并更新数据库用户,这些操作叠加后很容易超过10秒,导致Stripe判定超时。生产环境的网络延迟、数据库查询速度慢会进一步加剧这个问题,测试环境因数据量小、网络稳定未触发超时。
用户看到结账页面持续加载,是因为Stripe在Webhook超时后会尝试重试,同时延迟重定向逻辑——本质上success_url本身没有阻塞,而是Webhook失败影响了整体流程。
2. 具体修复步骤
(1)优化Webhook响应逻辑
先给Stripe返回200状态码,再异步处理业务逻辑。可以用消息队列(如BullMQ)把耗时操作放到后台执行,避免阻塞请求:
module.exports.stripeWebhook = async (req, res) => { let event; const webhookSecret = stripeWebhookSecret; try { event = stripe.webhooks.constructEvent( req.body, req.headers["stripe-signature"], webhookSecret ); } catch (err) { console.log(`⚠️ Webhook签名验证失败: ${err.message}`); return res.sendStatus(400); } // 立即返回响应,告知Stripe已接收事件 res.sendStatus(200); // 后台异步处理业务逻辑 processStripeEvent(event).catch(err => { console.error(`处理Stripe事件失败: ${err.message}`); // 添加失败重试逻辑 }); }; // 单独的异步处理函数 async function processStripeEvent(event) { const data = event.data; const eventType = event.type; switch (eventType) { case 'checkout.session.completed': { const session = data.object; const customer = await stripe.customers.retrieve(session.customer, { expand: ['subscriptions'] }); const user = await User.findOne({ email: customer.email }); if (!user) return; const product = await stripe.products.retrieve( customer.subscriptions.data[0].items.data[0].price.product ); user.product = product.name.toLowerCase(); user.subscription.active = customer.subscriptions.data[0].status; await user.save(); break; } // 其他事件处理... } }
(2)修正createCheckoutSession中的无效代码
删除无意义的User.findByIdAndUpdate调用(let update = {}; await User.findByIdAndUpdate(req.user._id, update);),避免浪费请求时间;同时统一用户对象更新逻辑,避免数据不一致。
(3)验证success_url可达性
确保生产环境webUrl配置的域名公网可访问,且/users/${req.user._id}/activations/路由无权限拦截或响应慢问题,可直接在浏览器访问测试。
(4)优先使用checkout.session.completed事件
订阅场景下,checkout.session.completed事件在结账完成、订阅创建成功后立即触发,比payment_intent.succeeded更贴合业务逻辑,能避免重复处理。
内容的提问来源于stack exchange,提问作者econobro

