httpClient と外部 API 呼び出しの落とし穴
サーバースクリプトから外部 API を呼ぶときに詰まりやすい点をまとめます。多くは「エラーにならないまま静かに間違う」タイプです。要点は次の 3 つです。
httpClientは、1 回の実行にまとめられるサーバースクリプトの間で同じインスタンスが共有されます。使う直前に各プロパティを毎回設定し直します(1.4.17.1 まではResponseHeaders.Clear()も必要でした)。httpClientは multipart/form-data を送れません。- 原因が分からないときは、まず
SysLogsテーブルを見ます。
落とし穴の一覧
| # | 落とし穴 | ひとことで |
|---|---|---|
| 1 | スクリプトのタイムアウト | 既定 10 秒。Script.json で延ばす |
| 2 | ResponseHeaders の重複 | 1.4.17.1 までは 2 回目以降で例外。1.5.8.1 では自動でクリアされる |
| 3 | httpClient の状態共有 | 使う直前に毎回全部設定し直す |
| 4 | multipart 非対応 | 送れない。JSON で送れる API を選ぶ |
| 5 | items.Get() の戻り値 | .length ではなく .Length |
| 6 | ChoiceHash | 読めない(設定専用) |
| 7 | 拡張 SQL | "Api": true 必須。無いと NullReference |
| 8 | AfterUpdate の saved | 更新後の値。context.UserData で橋渡し |
| 9 | 通知の HttpClient 型 | 使えない。Slack / Teams 型を使う |
| 10 | Send() の戻り値 | 常に true。あてにしない |
| 11 | エラーの調べ方 | SysLogs テーブルを見る |
以下、1.5.7.0 を対象に確認した内容です(2 のみ 1.4.17.1 までを対象にした内容)。httpClient と通知の実装(2・3・9・10)は 確認したソースでも確認しています。
httpClient の基本の書き方
以下の 1〜4 を踏まえると、httpClient は次の形で使うのが安全です。
httpClient.ResponseHeaders.Clear(); // 1.4.17.1 以前向け(1.5.8.1 では不要だが害はない)
httpClient.RequestUri = ENDPOINT;
httpClient.RequestHeaders.Clear(); // ヘッダも残っている
httpClient.MediaType = 'application/json';
httpClient.Encoding = 'utf-8'; // 明示的に戻す
httpClient.TimeOut = 45000;
httpClient.Content = body;
httpClient.Post(); // Get / Put / Delete / Patch なども同様1. サーバースクリプトのタイムアウトは既定 10 秒
App_Data/Parameters/Script.json の既定値は次のとおりです。
{
"ServerScriptTimeOut": 10000,
"ServerScriptTimeOutChangeable": false,
"ServerScriptHttpClientTimeOut": 100000
}httpClient 側は 100 秒あっても、それを包むサーバースクリプトが 10 秒で打ち切られます。生成 AI のように応答に数秒〜十数秒かかる相手だと、成功したり失敗したりします。
ServerScriptTimeOut を延ばし、ServerScriptTimeOutChangeable を true にするとスクリプト単位でも指定できます。変更後は再起動が必要です。
{
"ServerScriptTimeOut": 60000,
"ServerScriptTimeOutChangeable": true
}スクリプト単位のタイムアウトが実際にはどう適用されるかは タイムアウト を参照してください。
WARNING
DisableServerScriptHttpClient が true の環境では httpClient 自体が使えません。既定は false です。
2. 2 回目以降の呼び出しで例外になる(ResponseHeaders) 〜 1.4.17.1
同じ条件で httpClient を 2 回以上実行すると、2 回目以降でエラーになります。リクエストを組み立てる前に ResponseHeaders をクリアすれば回避できます。
httpClient.ResponseHeaders.Clear();
//RequestUri、RequestHeadersやMediaTypeのセット
httpClient.Post();//他のGet/Put/Delete/Patchなども同様原因は次のとおりです。
- 同じ実行タイミングのサーバースクリプトは文字列結合されてひとまとまりで実行され、その間
httpClientのインスタンスは使い回されます。 httpClientは取得したヘッダをResponseHeaders(Dictionary)にforeachで追加していきますが、前回の結果を消していません。Dateなど毎回返るキーが重複し、例外になります。
該当箇所: ServerScriptModelHttpClient.cs#L31-L34
1.5.8.1 では修正済み
この内容は 1.4.17.1 までを対象にしたものです。確認したソースでは、Get() / Post() などの送信処理の最初で ResponseHeaders.Clear() が呼ばれるため、2 回目以降の呼び出しでもキーは重複せず、この例外は起きません(ServerScriptModelHttpClient.cs)。古い版と同じスクリプトを使う場合に備えて ResponseHeaders.Clear() を書いておいても害はありません(履歴の自動削除 などの例もこの書き方です)。
3. httpClient はスクリプト間で共有される
httpClient は、同じタイミング(条件)で 1 回の実行にまとめられるサーバースクリプトが同じインスタンスを共有します。あるスクリプトが変えた設定を、次に走るスクリプトが引き継ぎます。
確認したソースでは、httpClient のインスタンスはサーバースクリプトの実行 1 回ごとに ServerScriptModel の中で作られます(ServerScriptModel.cs、ServerScriptUtilities.cs)。同じ条件の複数のスクリプトは本文が連結されて 1 回で実行されるため、その間は同じインスタンスです。一方、「更新前」と「更新後」のように別の条件の実行では別のインスタンスになります。
たとえば、あるスクリプトが httpClient.Encoding = 'iso-8859-1'; としたまま終わると、次に走るスクリプトが日本語を iso-8859-1 で送信してしまいます。HTTP 200 が返りエラーにはならないため、気づきにくい不具合になります(実例では、文字化けしたテキストのベクトルが返り、類似度の計算結果だけがおかしくなりました)。
対策は、使う直前に毎回すべて設定し直すことです(上の「基本の書き方」を参照)。RequestHeaders も同様で、前のスクリプトが付けた Authorization が残っていると、別のサービスに他所のトークンを送ることになりかねません。RequestHeaders.Clear() は必ず呼んでください。
4. multipart/form-data を送れない
httpClient.Content は文字列で、内部的には StringContent として送信されます。MediaType に boundary 付きの値を代入することはできますが、送信時に失敗します。
httpClient.MediaType = 'multipart/form-data; boundary=----abc123'; // 代入は成功する
httpClient.Content = body;
httpClient.Post();
// → The format of value 'multipart/form-data; boundary=----abc123' is invalid.StringContent のメディアタイプにはパラメータ(; boundary=...)を含められないためです。multipart を要求する API(例: OpenAI 互換の /v1/audio/transcriptions 系の音声文字起こし)はサーバースクリプトから直接呼べません。回避策は次のとおりです。
- JSON で送れる API を選ぶ(画像なら data URL で JSON に埋め込める)
- プリザンターの外に出す(API を呼ぶ常駐ワーカーを別に用意し、プリザンターの API 経由でやりとりする)
5. items.Get() の戻り値は .Length
items.Get(siteId) が返すのは .NET の配列で、JavaScript の配列ではありません。
var rows = items.Get(context.SiteId);
// ❌ undefined。ループが 1 回も回らないが、エラーにもならない
for (var i = 0; i < rows.length; i++) { }
// ✅
for (var i = 0; i < rows.Length; i++) { }rows.length は undefined なので 0 < undefined が false になり、何事もなかったように処理が終わります。同じ理由で rows.forEach(...) や rows.map(...) も使えません。「0 件しか見つからない」ときはここを疑ってください。
6. columns.X.ChoiceHash は読めない
分類項目の選択肢をスクリプトから取ろうとしても、ChoiceHash は常に null です。
typeof columns = object
columns.ClassA = ok
LabelText = 種別 ← これは読める
ChoiceHash = null ← これは読めないChoiceHash はサーバースクリプトから選択肢を設定するためのプロパティで、現在の選択肢の取得には使えません。選択肢一覧が必要なら、スクリプト側に定義を持つか、拡張 SQL で Sites.SiteSettings(JSON)を読んでパースします。
7. 拡張 SQL は "Api": true が無いと NullReference で落ちる
extendedSql.ExecuteRow('MySql', ...) を呼ぶと、次の例外が出ることがあります。
System.NullReferenceException: Object reference not set to an instance of an object.
at Implem.Pleasanter.Models.ExtensionUtilities.DataSetToExpando(DataSet dataSet)
at Implem.Pleasanter.Models.ExtensionUtilities.ExecuteDataSetAsDynamic(...)
at Implem.Pleasanter.Libraries.ServerScripts.ServerScriptModelExtendedSql.ExecuteRow(...)原因は SQL や接続ではなく、拡張 SQL の定義に "Api": true が無いことです。
{
"Name": "GetLatestImage",
"Api": true,
"SiteIdList": [5],
"CommandText": "select ..."
}サーバースクリプトから呼べるのは Api が true の拡張 SQL だけで、条件に合わない場合は「見つからない」ではなく null が返り、その先で落ちます。名前の綴り間違いでも同じ例外になります。
8. AfterUpdate では saved が更新後の値になっている
「値が変わったときだけ処理する」を書こうとすると、AfterUpdate では saved も更新後の値になっていて差分が取れません。
// AfterUpdate での実測
saved.Status = 300
model.Status = 300 ← 更新前の値が取れない
// BeforeUpdate での実測
saved.Status = 300
model.Status = 900 ← 差分が取れるBeforeUpdate なら差分は取れますが、そこで外部通知まで済ませると、保存が失敗しても通知だけ飛びます。
context.UserData は同一リクエスト内のサーバースクリプト間で共有される入れ物なので、BeforeUpdate で書いて AfterUpdate で読むことができます。
if (context.Condition === 'BeforeUpdate') {
context.UserData.statusBefore = String(saved.Status == null ? '' : saved.Status);
return;
}
// AfterUpdate
var before = context.UserData.statusBefore;
var after = String(model.Status == null ? '' : model.Status);
if (before !== after) {
// 保存が完了してから通知する
}9. 通知の HttpClient 型はサーバースクリプトから使えない
notifications で汎用 Webhook(Type = 9)を送ろうとすると、二段階でつまずきます。
- 既定で無効です(
App_Data/Parameters/Notification.jsonの"HttpClient": false)。 trueにすると、今度はERR Error: Value cannot be null. (Parameter 'name')という例外になります。HttpClient型の送信処理はEncoding/MediaType/MethodType/Headersを参照しますが(Notification.cs)、サーバースクリプト側のラッパーはこれらを持っておらず(ServerScriptModelNotificationModel.cs)、EncodingがnullのままEncoding.GetEncoding(null)が呼ばれて落ちます。
Slack 型(Type = 2)や Teams 型(Type = 6)はこれらを使わないので問題なく動きます。Slack 型は Address に {"text": "..."} を POST するだけなので、受け口が Slack でなくても、その形式で受け取れる Webhook なら流用できます。
var n = notifications.New();
n.Type = 2; // Slack
n.Address = WEBHOOK_URL;
n.Title = 'タイトル';
n.Body = '本文';
n.Send();10. notifications の Send() は常に true を返す
Send() の戻り値は送信結果ではありません。確認したソースでは、送信処理を呼んだあと無条件に true を返しています(ServerScriptModelNotificationModel.cs)。通知タイプが無効化されていても、パラメータで機能が閉じられていても true が返ります。戻り値で成否を判定せず、受け口側で確認してください。開発中は受信を確認できる Webhook を用意しておくと確実です。
11. エラーは SysLogs テーブルで調べる
サーバースクリプトが落ちると、画面には次のメッセージしか出ません。
サーバスクリプトの実行に失敗しました。
サーバスクリプトを確認・修正してから、再度お試しください。コンテナのログ(docker logs)にも出ません。スタックトレースは SysLogs テーブルにあります。
select top (3) "SysLogId", "ErrMessage", "ErrStackTrace"
from "SysLogs"
where "ErrMessage" is not null
order by "SysLogId" desc;select "SysLogId", "ErrMessage", "ErrStackTrace"
from "SysLogs"
where "ErrMessage" is not null
order by "SysLogId" desc
limit 3;select `SysLogId`, `ErrMessage`, `ErrStackTrace`
from `SysLogs`
where `ErrMessage` is not null
order by `SysLogId` desc
limit 3;7 の「拡張 SQL の Api 忘れ」のように、ここを見て初めて原因が分かるものがあります。この SQL をすぐ実行できるようにしておくと、調査が大幅に速くなります。