درخواست منتظرمانده را چه کسی متوقف می‌کند؟

یک درخواست وب ممکن است پاسخ سرآیند را سریع بگیرد، اما برای رسیدن بدنه مدت زیادی منتظر بماند. نمایش همیشگی حالت بارگذاری، هم کاربر را سردرگم می‌کند و هم منابع را درگیر نگه می‌دارد. مهلت پاسخ و امکان لغو، دو تصمیم متفاوت‌اند: یکی به بودجهٔ زمانی برنامه مربوط است و دیگری به تغییر نظر کاربر یا بسته‌شدن صفحه. این مقاله هر دو را در یک قرارداد روشن ترکیب می‌کند.

نمونه برای هر فراخوانی یک سیگنال مهلت می‌سازد و در صورت وجود سیگنال کاربر، هر دو را ترکیب می‌کند. لغو هرکدام می‌تواند دریافت پاسخ را متوقف کند. زمان تعیین‌شده باید با نیاز محصول و رفتار محیط اجرا هماهنگ باشد؛ در مرورگر، وقفه و تعلیق صفحه می‌تواند با اندازه‌گیری سادهٔ زمان دیواری تفاوت داشته باشد. این ابزار جای زمان انتظار سرویس سمت سرور یا سیاست ظرفیت اتصال را نمی‌گیرد.

ترکیب لغو کاربر و مهلت پاسخ

موفق‌شدن تابع دریافت به معنی موفق‌بودن وضعیت اچ‌تی‌تی‌پی نیست. نمونه پیش از خواندن داده، وضعیت پاسخ را بررسی و برای خطای سرور یک خطای قابل تشخیص تولید می‌کند. هنگام خواندن دادهٔ ساختاریافته نیز همان سیگنال برقرار می‌ماند تا کندی بدنه نادیده نماند. خطای لغو، پایان مهلت، وضعیت ناموفق و دادهٔ نامعتبر باید در رابط کاربری پیام‌های مناسب خود را داشته باشند، نه یک پیام کلی و مبهم.

چهار فایل را کنار هم ذخیره کنید. اجرای مثال‌ها به نود ۲۲ یا جدیدتر نیاز دارد؛ وابستگی بیرونی نصب نمی‌شود. با node --test test_deadline.mjs آزمون‌ها را اجرا کنید. سپس node server.mjs را در یک پنجره و node demo.mjs را در پنجرهٔ دیگر اجرا کنید. خروجی موفق باید شیئی با ready درست باشد و درگاه نمونه تنها روی نشانی محلی میزبان گوش می‌دهد.

آزمون به‌طور مشخص سرآیند را زود می‌فرستد و بدنه را عقب می‌اندازد تا نشان دهد مهلت فقط برای مرحلهٔ نخست اتصال نیست. حالت وضعیت ناموفق و لغو از قبل صادرشدهٔ کاربر نیز بررسی می‌شود. این نتیجه اجرای مرورگرهای قدیمی را تأیید نمی‌کند؛ پشتیبانی رابط‌ها را با مرورگرهای هدف بررسی کنید و اگر نیاز به سازگاری دارید، راهکار جایگزین را جدا و با همان قرارداد آزمون کنید.

فایل‌های کامل و قابل اجرا

چهار فایل زیر کامل‌اند؛ پیش‌نیاز، ترتیب اجرا و روش بررسی در متن آمده است. کدها خلاصه یا پنهان نشده‌اند.

1. فایل کامل fetch_deadline.mjs

این فایل را با همین نام و بدون حذف سطرها کنار بقیهٔ فایل‌ها ذخیره کنید.

export async function fetchJson(url, {timeoutMs = 2000, signal} = {}) {
  if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60000) throw new RangeError('timeoutMs must be 1..60000');
  const deadline = AbortSignal.timeout(timeoutMs);
  const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
  const response = await fetch(url, {signal: combined});
  if (!response.ok) {
    await response.body?.cancel();
    const error = new Error(`HTTP ${response.status}`);
    error.status = response.status;
    throw error;
  }
  return response.json();
}

دستور اجرا، نتیجهٔ مورد انتظار و محدودیت این فایل در بخش اجرای مقاله توضیح داده شده است.

2. فایل کامل server.mjs

این فایل را با همین نام و بدون حذف سطرها کنار بقیهٔ فایل‌ها ذخیره کنید.

import {createServer} from 'node:http';
import {pathToFileURL} from 'node:url';

export function makeServer() {
  return createServer((req, res) => {
    const url = new URL(req.url, 'http://localhost');
    const delay = Math.max(0, Math.min(500, Number(url.searchParams.get('delay')) || 0));
    res.writeHead(url.pathname === '/fail' ? 500 : 200, {'Content-Type': 'application/json'});
    res.flushHeaders();
    setTimeout(() => { if (!res.destroyed) res.end('{"ready":true}'); }, delay);
  });
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  makeServer().listen(8089, '127.0.0.1', () => console.log('http://127.0.0.1:8089'));
}

دستور اجرا، نتیجهٔ مورد انتظار و محدودیت این فایل در بخش اجرای مقاله توضیح داده شده است.

3. فایل کامل demo.mjs

این فایل را با همین نام و بدون حذف سطرها کنار بقیهٔ فایل‌ها ذخیره کنید.

import {fetchJson} from './fetch_deadline.mjs';
console.log(await fetchJson('http://127.0.0.1:8089/?delay=50', {timeoutMs: 1000}));

دستور اجرا، نتیجهٔ مورد انتظار و محدودیت این فایل در بخش اجرای مقاله توضیح داده شده است.

4. فایل کامل test_deadline.mjs

این فایل را با همین نام و بدون حذف سطرها کنار بقیهٔ فایل‌ها ذخیره کنید.

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {once} from 'node:events';
import {fetchJson} from './fetch_deadline.mjs';
import {makeServer} from './server.mjs';

test('success, HTTP failure, body timeout and caller cancellation', async () => {
  const server = makeServer().listen(0, '127.0.0.1');
  await once(server, 'listening');
  const base = `http://127.0.0.1:${server.address().port}`;
  try {
    assert.deepEqual(await fetchJson(base), {ready: true});
    await assert.rejects(fetchJson(base + '/fail'), error => error.status === 500);
    await assert.rejects(fetchJson(base + '/?delay=200', {timeoutMs: 20}), error => ['AbortError', 'TimeoutError'].includes(error.name));
    const controller = new AbortController(); controller.abort();
    await assert.rejects(fetchJson(base, {signal: controller.signal}), error => error.name === 'AbortError');
  } finally {
    server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
  }
});

دستور اجرا، نتیجهٔ مورد انتظار و محدودیت این فایل در بخش اجرای مقاله توضیح داده شده است.

اجرای آزمون سرآیند، بدنه و خطای وضعیت

در محصول واقعی، هنگام خروج از صفحه یا شروع جست‌وجوی تازه، درخواست قبلی را لغو و پاسخ دیررس را از به‌روزرسانی نتیجهٔ جدید بازدارید. لغو دریافت در سمت کاربر الزاماً محاسبه یا تغییر دادهٔ شروع‌شده در سرور را متوقف نمی‌کند. برای عملیات حساس، شناسهٔ درخواست و قرارداد سمت سرور لازم است. معیار مناسب، پایان قابل پیش‌بینی انتظار و وضعیت روشن برای کاربر است، نه صرفاً کم‌کردن عدد زمان انتظار.

چک‌لیست اجرای عملی

  • مهلت تا خواندن بدنهٔ پاسخ برقرار بماند.
  • خطای وضعیت با خطای لغو تفکیک شود.
  • لغو کاربر و پاسخ دیررس آزمون شوند.
  • توقف دریافت با توقف عملیات سرور یکسان فرض نشود.

منابع

تدوین و راهنمای عملی: دانشنامه لیان.