通信のタイムアウト時間を設定する

http プラグインの connectTimeout で接続までを、AbortSignal で全体を区切る。中断時のエラーの見分け方、無通信の検出、Rust の reqwest の timeout と read_timeout も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約9分 net-007
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 接続と全体の時間を区切る
  4. データが届かない時間で打ち切る
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

サーバーが落ちている、回線が極端に遅い、といったときに何秒で諦めるかを決めておかないと、画面が「読み込み中」のまま止まります。http プラグインの fetch() にも Rust の reqwest にも、既定では時間の制限がありません。http プラグインでは接続までの時間を connectTimeout、応答を受け終わるまでの時間を AbortSignal で区切り、Rust では reqwest のクライアントに指定します。ダウンロードのように所要時間が読めない通信は、「データが届かない時間」で判定するのが実用的です。

種類何を測るかhttp プラグイン (JS)reqwest (Rust)
接続接続が確立するまでconnectTimeout(ミリ秒)connect_timeout()
全体送信を始めてから本文を受け終わるまでAbortSignal で自作timeout()
無通信最後にデータが届いてから本文を読みながら自作read_timeout()

前提条件

npm run tauri add http

送り先は http:default の allow に書きます(書き方は HTTP GET リクエストを送る(Rust経由))。下の例では、動作確認で使う応答の遅いテスト用 URL と、応答しないアドレスも許可しています。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "http:default",
      "allow": [
        { "url": "https://api.example.com/*" },
        { "url": "https://httpbin.org/*" },
        { "url": "http://10.255.255.1/*" }
      ]
    }
  ]
}

Upload プラグインの upload() / download() には時間の設定も中断の API もありません。時間を区切りたい大きな送受信は、ダウンロード や アップロード のレシピにある Rust 版で書きます。

1. フロントエンドから実装する (TypeScript)

接続と全体の時間を区切る

connectTimeout は fetch() の第 2 引数に書ける http プラグイン独自のオプションで、接続が確立するまでだけを測ります。接続できたあとにサーバーが黙っている場合は止まらないので、全体の時間は AbortController で区切ります。

import { fetch } from '@tauri-apps/plugin-http';

export class TimeoutError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'TimeoutError';
  }
}

/** 接続は 5 秒、本文を受け終わるまで全体で totalMs。cancel で利用者の中止も受ける */
export async function getJson<T>(url: string, totalMs = 10_000, cancel?: AbortSignal): Promise<T> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new TimeoutError(`timed out after ${totalMs}ms: ${url}`)), totalMs);
  const relay = () => controller.abort(cancel?.reason);
  cancel?.addEventListener('abort', relay, { once: true });
  try {
    const res = await fetch(url, { connectTimeout: 5_000, signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return (await res.json()) as T; // 本文の受信も制限時間に含める
  } catch (e) {
    // 中断されたときのエラーの形は一定しないので、signal の状態で判定して理由を投げ直す
    if (controller.signal.aborted) throw controller.signal.reason;
    throw e;
  } finally {
    clearTimeout(timer);
    cancel?.removeEventListener('abort', relay);
  }
}

WebView 標準の fetch() は、中断されると AbortError の DOMException で失敗します。http プラグインの fetch() は違い、中断した時点によって「Request cancelled」または「Request canceled」(綴りが違う)で失敗し、エラーが Error ではなく文字列のこともあります。e.name === 'AbortError' の判定は当たらないので、上のコードでは signal.aborted を見て、abort() に渡した理由(TimeoutError)を投げ直しています。

// (続き)
try {
  const data = await getJson<{ url: string }>('https://httpbin.org/delay/10', 3_000);
  console.log(data.url);
} catch (e) {
  if (e instanceof TimeoutError) console.warn(e.message);
  else console.error('失敗:', e); // 接続の失敗は文字列で届く
}

データが届かない時間で打ち切る

大きなファイルのダウンロードに全体の制限時間を付けると、回線が遅い環境では正常な通信まで途中で切れます。この場合は「最後にデータが届いてから何秒か」で判定します。チャンクを待つ直前にタイマーを掛け直すのがポイントです。

import { fetch } from '@tauri-apps/plugin-http';

/** idleMs のあいだデータが届かなければ中断する。全体の時間は制限しない */
export async function streamWithIdleTimeout(
  url: string,
  idleMs: number,
  onChunk: (chunk: Uint8Array) => Promise<void>,
): Promise<void> {
  const controller = new AbortController();
  let timer: ReturnType<typeof setTimeout> | undefined;
  const arm = () => {
    timer = setTimeout(() => controller.abort(), idleMs);
  };
  try {
    arm(); // 応答が届くまで
    const res = await fetch(url, { connectTimeout: 10_000, signal: controller.signal });
    clearTimeout(timer);
    if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
    const reader = res.body.getReader();
    for (;;) {
      arm(); // 次のチャンクを待つ時間だけを測る(書き込みの時間は含めない)
      const { done, value } = await reader.read();
      clearTimeout(timer);
      if (done) return;
      await onChunk(value);
    }
  } catch (e) {
    if (controller.signal.aborted) throw new Error(`no data for ${idleMs}ms: ${url}`);
    throw e;
  } finally {
    clearTimeout(timer);
  }
}

onChunk で fs の FileHandle.write() を呼べば、ファイルをダウンロードして保存する の書き込みと組み合わせられます。

2. バックエンドから実装する (Rust)

reqwest では 3 種類ともクライアントを作るときに指定でき、全体の上限だけはリクエストごとに RequestBuilder::timeout() で変えられます。クライアントは接続を使い回すので、setup で 1 つ作って manage() し、コマンドから借ります。ダウンロード用には timeout() を付けない別のクライアントを用意します。原因は is_timeout() と is_connect() で見分けられるので、JS に返す文字列に含めておくと、画面で「時間切れ」と「接続できない」を出し分けられます。

use std::time::Duration;
use tauri::Manager;

/// アプリ全体で使い回すクライアント(同じサーバーへの接続が再利用される)
struct ApiClient(reqwest::Client);

fn build_api_client() -> reqwest::Result<reqwest::Client> {
    reqwest::Client::builder()
        .connect_timeout(Duration::from_secs(5)) // 接続まで
        .read_timeout(Duration::from_secs(15)) // データが届かない時間。届くたびに数え直す
        .timeout(Duration::from_secs(30)) // 全体の上限
        .build()
}

fn describe(e: &reqwest::Error) -> String {
    if e.is_timeout() {
        format!("timeout: {e}")
    } else if e.is_connect() {
        format!("connect failed: {e}")
    } else {
        e.to_string()
    }
}

#[tauri::command]
async fn get_text(
    state: tauri::State<'_, ApiClient>,
    url: String,
    timeout_ms: Option<u64>,
) -> Result<String, String> {
    let mut req = state.0.get(&url);
    if let Some(ms) = timeout_ms {
        req = req.timeout(Duration::from_millis(ms)); // このリクエストだけ全体の上限を変える
    }
    let res = req
        .send()
        .await
        .and_then(|r| r.error_for_status())
        .map_err(|e| describe(&e))?;
    res.text().await.map_err(|e| describe(&e)) // 本文の受信中の時間切れもここで分かる
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_http::init())
        .setup(|app| {
            app.manage(ApiClient(build_api_client()?));
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![get_text])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

この get_text は説明のために任意の URL を受け取っていますが、Rust からの通信には http プラグインの許可リストが効きません。実際のアプリでは送り先を Rust 側で固定します。

import { invoke } from '@tauri-apps/api/core';

try {
  const text = await invoke<string>('get_text', { url: 'https://httpbin.org/delay/10', timeoutMs: 3000 });
  console.log(text);
} catch (e) {
  console.error(e); // 「timeout: error sending request for url (https://httpbin.org/delay/10)」など
}

動作確認

npm run tauri dev で起動して試します。https://httpbin.org/delay/10 は 10 秒待ってから応答するテスト用の URL で、getJson() に 3 秒を指定すると約 3 秒で TimeoutError になります。http://10.255.255.1/ のように応答しないアドレスでは、connectTimeout の 5 秒で接続の失敗になります。

timed out after 3000ms: https://httpbin.org/delay/10
失敗: error sending request for url (http://10.255.255.1/)

よくあるエラーと対処法

  • e.name === 'AbortError' で判定しても当たらない: http プラグインの fetch() は AbortError を投げません。signal.aborted と signal.reason で判定します。
  • connectTimeout を付けたのに待たされる: 接続できたあとの遅さは対象外です。全体か無通信の時間も区切ります。
  • 大きなファイルのダウンロードが途中で必ず失敗する: 全体の制限時間が短すぎます。無通信の時間で判定する形に変えます。
  • 「error sending request for url (…)」: 接続できませんでした。時間切れ・名前解決の失敗・接続の拒否がどれも同じ文言になるので、JS では区別できません。区別したいときは Rust で is_timeout() を使います。

OS ごとの違いと注意点

  • WebView 標準の fetch(): AbortSignal.timeout(ms) をそのまま使え、時間切れは TimeoutError の DOMException になります。ただし他のサイトへの通信には CORS の制約がかかります。
  • 送り直し: 時間切れのあとに自動で送り直すのは、何度送っても結果が同じ GET や PUT に限ります。POST の送り直しは二重登録の原因になります。
  • オフライン: 回線が切れているかどうかは、タイムアウトを待たずに インターネット接続状態(オンライン)を監視する の方法で先に確かめられます。
  • 証明書: 開発用サーバーへの接続が時間切れではなく証明書の検証で失敗している場合は、開発用に SSL 証明書エラーを無視する を参照してください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する