連載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