0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub SponsorsとStripe連携の裏側:泥臭い罠を回避する堅牢なバックエンド設計

0
Posted at

eyecatch

我々TOAIが推進するプロジェクトにおいて、開発者が真に価値ある創造に没頭できる世界を目指す中で、OSSメンテナーや個人開発者が直面するシステムインテグレーションの地獄が待ち受けている。

巷にあふれるチュートリアルは「綺麗に動くハッピーパス」しか教えてくれない。しかし本番環境のインフラや外部APIは優しくない。本記事では、GitHub SponsorsとStripe連携において我々が踏み抜いた「失敗ログ」と、極小の運用コストで乗り切るための「本番環境ファースト」なアーキテクチャ設計について解説する。


1. 開発者が直面する「現実の地獄」とアーキテクチャの選定理由

システム設計の最大の敵は「複雑さ」と「状態の不整合」である。外部サービス(GitHub / Stripe)からの非同期イベント(Webhook)を受け取り、ステータス管理やDiscordへの自動通知を行う際、巨大なフレームワークやサードパーティ製ラッパーをあえて排除した。

アーキテクチャの選定理由

  • TypeScript / Express / PostgreSQL のミニマル構成。
  • 公式SDK(stripe)と標準 fetch のみに依存することで、Node.jsのメジャーアップデート時の追従コストを最小化。
  • 状態管理はPostgreSQLのトランザクション制御に寄せ、アプリケーション層をステートレスに保つ。

StripeのAPIバージョン固定化や、GitHub GraphQL APIのレートリミット超過時の指数バックオフ(Exponential Backoff)への安全な移行など、保守・運用設計を初期段階から組み込むことが重要である。


2. べき等性(Idempotency)完全担保のWebhookハンドラー

本番稼働初日、ネットワークの再送により数秒おきに2回到達したStripeからの決済完了イベント。システムがべき等性を考慮していなかったため、同じ event_id を同時に INSERT しようとし、PostgreSQLのユニーク制約違反(Error 23505)が発生した。エラーハンドリングの甘さからサーバープロセスがクラッシュし、Discordの支援者ロール付与Botが沈黙した。

Webhookの重複受信は「異常」ではなく「日常」である。この現実に対応するため、データベーストランザクションによる排他制御と processed_events テーブルによる二重処理のシャットアウトを実装した。

import express, { Request, Response } from 'express';
import { Pool } from 'pg';

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// 【罠】Stripeの署名検証には生ボディ(Raw Body)が必須。
// express.json() を先に記述するとストリームが消費され署名エラーが永久に消えない。
app.use('/webhook/stripe', express.raw({ type: 'application/json' }));
app.use(express.json());

app.post('/webhook/stripe', async (req: Request, res: Response) => {
  const eventId = req.body.id;
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // 1. べき等性チェック (FOR UPDATE SKIP LOCKEDで並行リクエスト時の競合をブロック)
    const duplicateCheck = await client.query(
      'SELECT id FROM processed_events WHERE event_id = $1 FOR UPDATE SKIP LOCKED',
      [eventId]
    );

    if (duplicateCheck.rows.length > 0) {
      console.warn(`[Idempotency] Event ${eventId} is locked or processed. Skipping.`);
      await client.query('ROLLBACK');
      return res.status(200).json({ status: 'duplicate_skipped' });
    }

    // 2. イベント処理
    // ... 決済完了に伴うロジック等を記述 ...

    // 3. 処理済みイベントの記録
    await client.query(
      'INSERT INTO processed_events (event_id, event_type) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING',
      [eventId, req.body.type]
    );

    await client.query('COMMIT');
    return res.status(200).json({ received: true });
  } catch (error) {
    await client.query('ROLLBACK');
    console.error(`[Failure Log] Database error:`, error);
    return res.status(500).send('Internal Server Error');
  } finally {
    client.release();
  }
});

単なる SELECT での重複チェックでは、ミリ秒単位の並行リクエスト時にレースコンディションが発生する。そのため、FOR UPDATE SKIP LOCKED を用いて行ロックを取得し、重複した処理をデータベース層で確実に弾く設計が不可欠だ。


3. Expressの生ボディパースの罠(署名検証崩壊)

StripeのWebhook検証における最大の罠は express.json() の配置順序にある。
express.json() がリクエストのストリームをパースし、JavaScriptのオブジェクトに変換してしまうと、Stripe SDKが要求する元の生のバイト列(Raw Buffer)が失われ、HMAC SHA256による署名検証が必ず失敗する。

「綺麗な公式ドキュメント通りに実装したのに署名エラーが出る」という現象の9割はこれが原因だ。パスを限定して express.raw({ type: 'application/json' }) を適用し、生ボディの取り扱いを厳密に固定することで回避できる。


4. まとめ:コードの価値から「時間の価値」へ

GitHub SponsorsやStripeのインテグレーションにおいて、本当に価値があるのは「動くコード」そのものではない。外部APIの気まぐれやインフラのネットワークトラブルから開発者を守り、不毛なデバッグに費やすはずだった時間を取り戻すための防壁――すなわち「時間の価値」を生み出すことにある。

Webhookのべき等性担保の泥臭さ、生ボディパースの罠、そしてAPIの仕様変更への備え。これら実戦の知見が、あなたのアーキテクチャを強固にし、平和を守る一助となれば幸いだ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?