1
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?

GASからDiscord Webhookへの送信が429エラー(1015)になる原因と、Cloudflare Workersによるプロキシ回避術

1
Posted at

はじめに

Google Apps Script (GAS) で実行する簡易なウェブアプリについて、エラー発生時の通知をDiscordで受信する構成を取ろうと考えました。
ですが、GASからDiscordのWebhookを直接呼び出すと、相当な頻度で429エラーとなりました。
結果として、Cloudflare Workersをプロキシとして挟むことで安定しました。
本記事では、このエラーの原因を紐解き、なぜプロキシが効果的であったかについて記載します。

前提となる環境

バックエンド:GAS
GASのメソッド:UrlFetchApp
DiscordのWebhook:WebhooksRate Limits

発生した事象

GASからDiscordのWebhook URLをurlFetchAppで一度だけ呼び出したにも関わらず、以下の例のような応答となることが頻発しました。

HTTP/1.1 429 Too Many Requests
Server: cloudflare
cf-ray: a0621345fc130d63-ATL
Content-Type: text/plain; charset=UTF-8
Content-Length: 16
Retry-After: 1949

error code: 1015

原因

この応答はRate Limitsに記載のあるCloudflare bansによるものです。

では一度だけ実行しているのになぜCloudflareからBANされているのでしょうか。

GASのUrlFetchAppは、実行される際にGoogle Cloud内部のGWを通ってインターネット側に出ていきます。このGWのIPアドレスは、UrlFetchAppの仕様に明記されている通り、Googleの保有する広範囲のIPレンジを取りうるとされるものの、私のアカウントの環境において観測した限りでは、GWのIPプールは非常に限定的でした。尚、観測結果は附録として後述します。
このGWは他のGASユーザーと共有されるものであり、他ユーザーも同じようにDiscordとの通信を試みているものと考えられます。
そうなった場合、Discord側からすると同一のIPから多量のリクエストか送られてきているように見えているはずで、GWの取りうるIPアドレスのレンジは狭いため十分なローテーションが行われず、Rate Limitの達するものと考えられます。
結果として、私視点では一度のwebhook呼び出しであり、per-routeおよびglobal limitsレベルでは制限を超過していなくとも、GWのIPに対してCloudflare bansが発生しており、429エラーが返されるものです。

解決策

GASとDiscord Webhookの間にプロキシを配置します。Cloudflare WorkersはDiscord Developer DocsのTutorialsにも記載されているように、Discordとの通信には好適です。
尚、Cloudflare Workersはどのエッジからでも単一のIPv6アドレスを使っていて、過去にはこのIPv6アドレスもまたRate Limitに達していた模様ですが、対応してくださっています。

Cloudflare Workersでのコードは下記のイメージで、GAS側にAPIキーを配置して検証しています。この辺りは使っているAIエージェントとご相談の上、実装してください。

export default {
  async fetch(request, env, ctx) {
    // 1. POSTリクエスト以外は弾く
    if (request.method !== "POST") {
      return new Response("Method Not Allowed", { status: 405 });
    }

    // 2. 環境変数(シークレット)から認証キーとWebhook URLを取得
    // Cloudflareのダッシュボードからこれらの環境変数を設定
    const EXPECTED_KEY = env.AUTH_SECRET_KEY;
    const DISCORD_WEBHOOK_URL = env.DISCORD_WEBHOOK_URL;

    // 3. リクエストヘッダーのシークレットを検証
    const providedKey = request.headers.get("X-Custom-Auth-Key");
    if (providedKey !== EXPECTED_KEY) {
      return new Response("Unauthorized", { status: 401 });
    }

    // 4. 検証を通過したらDiscordへ転送
    try {
      const payload = await request.text();

      const response = await fetch(DISCORD_WEBHOOK_URL, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          // WAFに弾かれないようGASからのUser-Agentを引き継ぐか、明示的に設定する
          "User-Agent": request.headers.get("User-Agent") || "GAS-Discord-Proxy/1.0"
        },
        // GASから受け取ったペイロードをそのまま流す
        body: payload,
      });

      // DiscordからのレスポンスをそのままGASへ返す
      return new Response(response.body, {
        status: response.status,
        headers: response.headers,
      });

    } catch (error) {
      return new Response("Internal Server Error", { status: 500 });
    }
  },
};

まとめ

エラーの原因は、狭いIPレンジのGWを他ユーザーと共有することで、DiscordのRate Limitを超過し、Cloudflare bansが発生している状況によるものでした。
一方、Cloudflare WorkersとDiscord間の通信は単なるIPアドレスベースでのBANが発生しないようエンジニアリングされており、実際にDiscordのドキュメントでもCloudflare Workersをホスト先として例示するTutorialsを掲載しています。
従ってGASからDiscordに通信したい場合は、Cloudflare Workersをプロキシとして経由することが望ましいと考えます。

附録|GASの出口IPとCloudflare bansの遭遇率について

観測方法: GASから出口IPとDiscordのwebhook呼び出しを10分おきに定期実行し、スプシに記録
試行回数: 600
結果:

  • 取りえた出口IPの種類は15種。そのうちCloudflare bansでBANされていないIPを引いた場合のみ、DiscordへのPOSTが成功する。
  • POST成功率は約35%。
  • Cloudflare bansの発生状況の時間的な偏りは観測した範囲では認められなかった。
1
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
1
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?