
はじめに
実運用しているサービスで、「人間の返信は保存されるのに、LLM(AI)による返信だけが保存されない」という不具合がありました。
原因は、サーバー側のコードが自分自身の Route Handler を fetch('/api/ai/reply') という相対URLで呼んでいたことです。
ブラウザなら動くこの書き方が、Node 上では Failed to parse URL で失敗します。
さらにエラーがログに出るだけで握りつぶされていたため、気づくまでに時間がかかりました。
この記事では、原因の整理と「HTTP の自己呼び出しをやめて、共通関数を直接呼ぶ」修正、再発を防ぐテストまでをまとめます。
こんな人におすすめ
- Next.js の Route Handler とサーバー側サービスの両方から同じ処理を呼びたい方
- サーバーで
fetchした際にFailed to parse URLやERR_INVALID_URLが出て困っている方 - 副次処理の失敗が画面に出ず、気づけない構成になっている方
- LLM 呼び出しなど外部 API 連携のテストを整えたい方
罠1: Node の fetch は相対URLを解決できない
ブラウザの fetch('/api/ai/reply') は、現在のページのオリジンを基準に解決されます。
一方、サーバー側にはその基準がありません。
Node の fetch に相対URLを渡すと、URL として不正という扱いになり失敗します。
// クライアント用に書いたヘルパー(ブラウザでは動く)
export async function generateAiReply(topic: string, content: string) {
const res = await fetch('/api/ai/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, latestReplyContent: content }),
});
return (await res.json()).text as string;
}
このヘルパーをサーバー側のサービスから再利用すると、Node 上で例外になります。
クライアント向けに作った関数を「便利だから」とサーバーで流用したときに踏みやすい罠です。
罠2: 副次処理の失敗は画面に出ない
今回の構成では、主処理(返信の作成)が成功したあとに、副次処理として AI 返信を生成していました。
副次処理の例外は logger.error に出すだけで握りつぶされていたため、画面上は常に成功に見えます。
気づいたきっかけは、本番 DB で「直近30日の AI 返信の件数」を数えたことでした。
件数が0だったことで、初めて黙って失敗し続けていたと分かりました。
副次処理は、成功率そのものを指標として見ておくのが安全です。
解決策: HTTP の自己呼び出しをやめる
サーバー内から自分の API を HTTP で叩く構成には、次の問題があります。
- ベースURLの管理が必要になる
- 認証やレート制限が二重にかかる可能性がある
- 不要なネットワークの往復が増える
素直な解決は、生成ロジックを関数に切り出し、Route Handler とサービスの両方から直接呼ぶことです。
サービス側の変更は、import と呼び出しの2行だけで済みました。
-import { generateAiReply } from '@/lib/ai/client';
+import { generateAiReplyText } from '@/lib/ai/replyGenerator';
...
- const aiText = await generateAiReply(topic, latestReplyContent);
+ const aiText = await generateAiReplyText(topic, latestReplyContent);
Route Handler は変換役だけにする
ドメインロジックは lib/ に置き、Route には検証・レート制限・HTTP ステータスへの変換だけを残します。
API キー未設定のような「設定不備」は専用のエラークラスで表現し、Route 側で 500 に変換します。
export async function POST(req: Request) {
try {
// 入力検証・レート制限はここで行う
const text = await generateAiReplyText(topic, latestReplyContent);
return NextResponse.json({ text });
} catch (error) {
if (error instanceof AiApiKeyNotConfiguredError) {
return NextResponse.json({ error: 'API key is not configured' }, { status: 500 });
}
return handleApiError(error, 'generate AI reply');
}
}
こうすると、HTTP の都合(ステータスコード)が境界の内側に漏れません。
修正前と同じ振る舞いを Route 側で維持できます。
入力の型は、プロンプト組み立てに必要なフィールドだけを持つ最小限のインターフェースにしておくと便利です。
HTTP 経由で検証済みの入力と、DB から取得した値の両方が、そのまま満たせます。
export interface ReplyPromptInput {
title: string;
body: string;
}
回帰テストは「fetch が呼ばれないこと」を固定する
退行の原因そのものをテストで固定します。
fetch をスタブして、呼び出しがないことをアサートします。
it('応答テキストを返し、HTTP の fetch は使わない', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
mockGenerateContent.mockResolvedValue({ text: 'AIの返信' });
await expect(generateAiReplyText(topic, '返信内容')).resolves.toBe('AIの返信');
expect(fetchSpy).not.toHaveBeenCalled();
});
使われなくなったクライアント用ヘルパーとそのテストは、同じ変更で削除します。
デッドコードを残すと、再びサーバーから呼ばれてしまう可能性があります。
つまづきやすいポイント
- ブラウザでは動くため、開発中の手動確認だけでは見つかりません。サーバー経路のテストを用意しましょう
- 副次処理の
catchでログだけ出して握りつぶすと、主処理の成功に隠れて失敗が長期間続きます - Route Handler にロジックを書き込むと、サーバーから再利用するために HTTP 呼び出しへ逃げたくなります。最初から
lib/に切り出しておくのが安全です - エラークラスを使わずに HTTP ステータスをロジック側で返すと、サービスから呼んだときに扱いづらくなります
まとめ
サーバー内で自分の API を相対URLの fetch で呼ぶと、Node では URL を解決できず失敗します。
対策は、HTTP をやめて共通関数を直接呼ぶことです。
Route Handler は検証とステータス変換だけに絞り、設定不備は専用エラーで表現します。
あわせて、副次処理の成功率を数えて見る習慣と、「fetch が呼ばれない」ことを固定する回帰テストを入れておくと、同じ退行を防げます。

