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?

Webhook 送信失敗の原因|プロキシ/タイムアウト/リトライ(HttpClient)【救急E07】

0
Last updated at Posted at 2026-01-20

連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index

外部通知は種類が多いが、このページで扱うのは「アプリ外へHTTP等で投げる通知」に限定する。
対象を次の3系統へ寄せる。

  • Webhook通知: Slack/Teams/Discord などの Incoming Webhook(HTTP POST)
  • 社内API連携: 管理サーバや業務APIへ HTTP で投げるイベント通知
  • メール送信: SMTP/HTTP API など(到達確認が遅い・失敗の見え方が割れやすい)

このページが狙うのは「通知が来ない」を直すことだけではない。
通知送信が詰まることで UIが固まる処理が待ち続ける環境差で落ち方が割れる といった二次被害まで含めて、観測点と止血の順序を揃える。

よくある見え方は次の4つ。

  • 通知だけ来ない(業務処理は進む)
  • 送信タイミングで固まる(UIフリーズ、待ち続ける)
  • 社内だけ失敗する(自宅は通る、端末差が出る)
  • しばらく動いた後に失敗が増える(接続枯渇や増幅が疑わしい)

0. 30秒で結論

外へ投げる通知が止まったら、先に「型」を決めると短くなる。

  • 固まる: UIスレッドで .Result/.Wait が混ざる可能性が高い
  • 社内だけ失敗: プロキシ/証明書/TLS/FW の差が濃い
  • たまに失敗: タイムアウト未設計、接続枯渇、リトライ増幅を疑う

止血の基本セットは次。

  • ログと計測(経過ms / host / HTTPコード / 例外 / 試行回数)を揃える
  • タイムアウトとキャンセル を通し、無限待ちを止める
  • awaitへ寄せる(UIで同期待ちを作らない)
  • HttpClientは再利用 へ寄せる(都度new連打を避ける)

1. 見え方で切り分ける

見え方から 最初に疑う混入点 を決めると、調査が散りにくい。

見え方 具体例 最初に疑う混入点
通知だけ来ない 画面操作は普通、通知だけ飛ばない URL/認証/HTTPコード/プロキシ
固まる 送信操作で無反応、応答なし寄り UIスレッドで同期待ち(.Result/.Wait
端末差が出る 社内NG、自宅OK プロキシ/証明書/TLS/FW
たまに失敗 10回に1回失敗 タイムアウト、接続枯渇、リトライ増幅
時間で悪化 最初はOK、徐々に失敗増 接続枯渇、並列過多、無制限リトライ

補足: async は「別スレッド」と同義ではない。await 後にどこへ戻るかは呼び出し元の文脈で決まる。


2. 原因は5つの型に収束する

通知停止は次の5型へ寄ることが多い。型が決まると、見る場所と直し方が決まる。

典型 何を見るか 直し方の方向性
同期ブロック .Result/.Wait で待つ UIスレッドのCall Stack await へ寄せ、UI合流点を決める
タイムアウト未設計 無限待ち/長時間待ち 経過ms、キャンセル有無 タイムアウトとキャンセルを入れる
プロキシ/証明書 社内だけ失敗 例外、HTTPコード、端末設定 環境差を表にし、必要なら明示設定へ寄せる
リトライ増幅 失敗時に連打で悪化 試行回数/間隔/同時数 上限、指数バックオフ、停止条件を決める
接続枯渇 時間経過で失敗増 Socket系例外、待ち行列 HttpClient再利用、並列制御へ寄せる

3. 最短手順

3-1. 事実を揃える

目的は「止まった」から「どの型か」へ落とすこと。
最初に揃えるのは推測ではなく、比較できる事実。

  • 経過ms
  • 送信先 host(パスやクエリは秘匿情報を避けたい場面がある)
  • HTTPコード(取れる場合)
  • 例外(型とメッセージ、可能なら内側も)
  • 試行回数(リトライがある場合)
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

// ★狙い: 送信の成否だけでなく「遅い/止まる/失敗する」を区別できる材料を残す
// ★観測点: 経過ms / host / HTTPコード(取れれば) / 例外型
public async Task SendWithObserveAsync(Uri url, CancellationToken ct)
{
    var sw = Stopwatch.StartNew(); // 経過時間を一貫した方法で取る

    try
    {
        // 実体は別メソッドへ寄せてもよい。ここでは単純化してPostを直接呼ぶ。
        using var res = await _http.PostAsync(url, content: null, ct);

        _logger.LogInformation(
            "notify ok elapsed={ElapsedMs}ms host={Host} status={Status}",
            sw.ElapsedMilliseconds,
            url.Host,
            (int)res.StatusCode);

        // 成功/失敗の基準を揃える(4xx/5xxを失敗として扱うならEnsureへ寄せる)
        res.EnsureSuccessStatusCode();
    }
    catch (Exception ex)
    {
        _logger.LogError(
            ex,
            "notify ng elapsed={ElapsedMs}ms host={Host} exType={ExType}",
            sw.ElapsedMilliseconds,
            url.Host,
            ex.GetType().Name);

        // 上位で方針(再試行/画面表示/握り潰し)を決めるためにthrowを維持する
        throw;
    }
}

3-2. 止血

止血は「固まらない」「無限待ちを作らない」「連打で増幅しない」へ寄せる。

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

// ★狙い: UI側は「止まらない」「増幅しない」「失敗が見える」へ寄せる
// ★補足: WinFormsイベントはシグネチャ都合でasync voidになりやすいので、例外をここで捕まえてログへ残す
private async void btnSend_Click(object sender, EventArgs e)
{
    btnSend.Enabled = false; // 連打で同時送信が増える形を止める

    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // 無限待ちの止血

    try
    {
        await _notifier.SendAsync(cts.Token); // awaitへ寄せ、UIスレッドで同期待ちを作らない
        lblStatus.Text = "OK";
    }
    catch (OperationCanceledException)
    {
        lblStatus.Text = "Timeout"; // タイムアウトとネット断を分けて見える化する
        _logger.LogWarning("notify timeout");
    }
    catch (HttpRequestException ex)
    {
        lblStatus.Text = "Network NG"; // DNS/TLS/プロキシ等がここへ寄ることが多い
        _logger.LogWarning(ex, "notify network ng");
    }
    catch (Exception ex)
    {
        lblStatus.Text = "NG";
        _logger.LogError(ex, "notify failed");
    }
    finally
    {
        btnSend.Enabled = true;
    }
}

3-3. 恒久

恒久は「送信の部品化」で再発率を下げる。

  • HttpClientを再利用へ寄せる
  • リトライは上限と間隔を必ず決める
  • 同時送信数を制御できる形へ寄せる

4. 失敗パターン別

4-1. 同期ブロック

見え方
送信操作の直後にUIが固まる。

見つけ方
Break AllでUIスレッドのCall Stackを見る。Task.Wait / .Result / GetAwaiter().GetResult() が混ざるとこの型へ寄る。

// ★壊れ方: UIスレッドで同期待ち→固まりやすい
var res = _http.PostAsync(url, content).Result; // .Result が混入点
// ★直し方: awaitへ寄せ、UIスレッドで同期待ちを作らない
var res = await _http.PostAsync(url, content, ct);

4-2. タイムアウト未設計

見え方
たまに極端に遅い、戻ってこない、障害時に待ちが伸びる。

見つけ方
経過msをログへ揃える。タイムアウトが無いと「待ち続ける」へ寄りやすい。

// ★狙い: 操作単位でタイムアウトを決め、障害時に戻ってくる形へ寄せる
public async Task<HttpResponseMessage> PostWithTimeoutAsync(
    Uri url,
    HttpContent content,
    TimeSpan timeout,
    CancellationToken ct)
{
    using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); // 呼び出し元キャンセルと連携
    timeoutCts.CancelAfter(timeout); // この操作の上限時間

    return await _http.PostAsync(url, content, timeoutCts.Token);
}

4-3. プロキシと証明書

見え方
社内だけ失敗、自宅は成功、端末差が出る。

見つけ方
HTTPコード(例: 407)と例外をログへ残す。TLS/証明書系は例外メッセージが切り分けに効く。

using System.Net;
using System.Net.Http;

// ★狙い: 自動検出任せで端末差が出る場合、設定を明示して差を潰す方向へ寄せる
// ★注意: ネットワーク方針に依存するため、適用条件を文章で先に決めると混乱が減る
public async Task<HttpResponseMessage> PostViaProxyAsync(Uri url, HttpContent content, CancellationToken ct)
{
    var handler = new HttpClientHandler
    {
        UseProxy = true,
        Proxy = new WebProxy("http://proxy.example.local:8080"),
        // 認証が絡む現場では次が必要になることがある
        // Proxy.Credentials = CredentialCache.DefaultNetworkCredentials;
    };

    using var http = new HttpClient(handler);

    return await http.PostAsync(url, content, ct); // 観測点: 407/403 や TLS系例外
}

4-4. リトライ増幅

見え方
失敗時に連打で悪化。サーバ側も詰まり、さらに失敗が増える。

見つけ方
試行回数と間隔をログへ揃える。無制限や短すぎる間隔は増幅へ寄る。

using System;

// ★狙い: 障害時の増幅を避けつつ、短い瞬断に耐える
// ★観測点: attempt / delay / 例外
public static async Task RetryAsync(Func<CancellationToken, Task> action, int maxAttempts, CancellationToken ct)
{
    for (int attempt = 1; attempt <= maxAttempts; attempt++)
    {
        try
        {
            await action(ct);
            return; // 成功で終了
        }
        catch (Exception ex) when (attempt < maxAttempts)
        {
            var delay = TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt - 1)); // 200/400/800...

            _logger.LogWarning(
                ex,
                "notify retry attempt={Attempt}/{Max} delayMs={DelayMs}",
                attempt,
                maxAttempts,
                delay.TotalMilliseconds);

            await Task.Delay(delay, ct);
        }
    }

    throw new InvalidOperationException("notify retry exhausted");
}

停止条件(例: 4xxは再試行しない)を入れるかどうかは、送信先の仕様と運用で決める。


4-5. 接続枯渇

見え方
最初は成功するが、時間が経つほど失敗が増える。負荷が上がると顕在化しやすい。

見つけ方
Socket系例外や待ち行列の増加を見る。都度 new HttpClient() が混ざるとこの型へ寄りやすい。

using System.Net.Http;
using System.Threading;

// ★狙い: HttpClient を使い回し、同時送信を絞って枯渇と待ち行列を抑える
public sealed class NotifySender
{
    private static readonly HttpClient Shared = new HttpClient(); // 共有で再利用へ寄せる
    private readonly SemaphoreSlim _gate = new SemaphoreSlim(4, 4); // 同時送信数を決める

    public async Task PostAsync(Uri url, HttpContent content, CancellationToken ct)
    {
        await _gate.WaitAsync(ct); // 同時数を絞る
        try
        {
            await Shared.PostAsync(url, content, ct);
        }
        finally
        {
            _gate.Release();
        }
    }
}

5. 実装のひな型

狙いは「止血の基本セット」を部品として揃えること。

  • 経過msと結果を必ずログへ残す
  • タイムアウトとキャンセルを必ず通す
  • リトライは上限と間隔を必ず持つ
  • 同時数を制御できる余地を残す
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

// ★狙い: 「観測点を揃える送信部品」を用意し、UI層から切り離す
// ★読み方: SendJsonAsync が送信の中心。RetryAsync は失敗時の増幅を避ける役割。
public sealed class HttpNotifier
{
    private readonly HttpClient _http;
    private readonly ILogger _logger;

    public HttpNotifier(HttpClient http, ILogger logger)
    {
        _http = http;
        _logger = logger;
    }

    public async Task SendJsonAsync(Uri url, object payload, TimeSpan timeout, int maxAttempts, CancellationToken ct)
    {
        // 送信の経過時間は操作単位で揃える
        var sw = Stopwatch.StartNew();

        // payload全文は秘匿情報が混ざりやすい。必要ならサイズ/ハッシュへ寄せる。
        var json = JsonSerializer.Serialize(payload);
        using var content = new StringContent(json, Encoding.UTF8, "application/json");

        // タイムアウトは操作単位で決め、呼び出し元キャンセルとも連携する
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        timeoutCts.CancelAfter(timeout);

        await RetryAsync(
            async (attempt, innerCt) =>
            {
                // リクエストは試行ごとに生成して送る(Contentは再利用)
                using var req = new HttpRequestMessage(HttpMethod.Post, url)
                {
                    Content = content
                };

                using var res = await _http.SendAsync(req, innerCt);

                _logger.LogInformation(
                    "notify attempt={Attempt}/{Max} status={Status} elapsed={ElapsedMs}ms host={Host}",
                    attempt,
                    maxAttempts,
                    (int)res.StatusCode,
                    sw.ElapsedMilliseconds,
                    url.Host);

                res.EnsureSuccessStatusCode(); // 4xx/5xx を失敗として扱う基準を揃える
            },
            maxAttempts,
            timeoutCts.Token);

        _logger.LogInformation(
            "notify done elapsed={ElapsedMs}ms host={Host}",
            sw.ElapsedMilliseconds,
            url.Host);
    }

    private async Task RetryAsync(Func<int, CancellationToken, Task> action, int maxAttempts, CancellationToken ct)
    {
        for (int attempt = 1; attempt <= maxAttempts; attempt++)
        {
            try
            {
                await action(attempt, ct);
                return;
            }
            catch (Exception ex) when (attempt < maxAttempts)
            {
                // 指数バックオフで増幅を避ける(短い瞬断には耐える)
                var delay = TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt - 1));

                _logger.LogWarning(
                    ex,
                    "notify retry attempt={Attempt}/{Max} delayMs={DelayMs}",
                    attempt,
                    maxAttempts,
                    delay.TotalMilliseconds);

                await Task.Delay(delay, ct);
            }
        }

        throw new InvalidOperationException("notify retry exhausted");
    }
}

6. ハマりポイント

ハマり 何が起きる 避け方
ConfigureAwait の混在 await 後の戻り先が想定とズレる UI層は合流点を決める。部品側は ConfigureAwait(false) 寄り
例外を握り潰す 「来ない」だけ残る 例外はログへ残し、画面は要約へ寄せる
無制限リトライ 障害時に増幅 上限・間隔・停止条件を決める
自動プロキシ任せ 端末差が説明できない 必要な現場は明示設定へ寄せ、観測点をログへ残す

7. 再発防止の掟

  • HTTP通知は タイムアウトとキャンセル を必ず通す(既定値へ依存しない)
  • UIイベント内に .Result/.Wait を混ぜない(レビュー観点へ落とす)
  • リトライは 上限と間隔 を必ず持つ(無制限へ寄せない)
  • 失敗ログは 経過ms / host / HTTPコード / 例外 / 試行回数 を最小セットにする
  • HttpClient は再利用へ寄せ、同時送信は制御できる形へ寄せる

8. 禁書庫

  • UIイベント内に .Result/.Wait が混ざっていないか
  • 送信にタイムアウトとキャンセルが入っているか
  • 失敗ログに 経過ms / host / HTTPコード / 例外 / 試行回数 が揃っているか
  • リトライに上限と間隔があるか
  • HttpClient を都度 new していないか
  • 同時送信が増える形になっていないか(連打、タイマー、並列)

9. 関連トピック

  • 連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index
  • 止血と計測(ログ/タイムアウト/計測の型): E04
  • UIスレッドの構造理解(メッセージループ/帰還先): G11
  • 同期ブロック整理(待ち方/合成/デッドロック回避): G13
  • 規約化(レビュー観点へ落とす): R06

連載Index(読む順・公開済リンクが最新): S00_門前の誓い_総合Index

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?