umbot
    Preparing search index...

    Customizing the HTTP layer

    This page is machine-translated from the Russian original. If something reads oddly, the Russian version is the source of truth — open an issue.

    You can replace the built-in fetch with any compatible HTTP client via AppContext.httpClient. This lets you add retry logic, timeouts, tracing, mocks in tests and so on.

    • Timeouts — limit request time
    • Retry logic — retry on errors
    • Tracing — trace requests
    • Mocks — substitute responses in tests
    import { Bot } from 'umbot';
    import { fullPlatforms } from 'umbot/plugins';

    const bot = new Bot();
    bot.use(fullPlatforms);
    const ctx = bot.getAppContext();

    ctx.httpClient = async (input, init) => {
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), 5000);
    try {
    const res = await fetch(input, { ...init, signal: controller.signal });
    clearTimeout(id);
    return res;
    } catch (e) {
    clearTimeout(id);
    throw e;
    }
    };

    bot.start('localhost', 3000);
    import { Bot } from 'umbot';
    import { fullPlatforms } from 'umbot/plugins';

    const bot = new Bot();
    bot.use(fullPlatforms);
    const ctx = bot.getAppContext();

    ctx.httpClient = async (input, init) => {
    const maxRetries = 3;
    let lastError: Error | undefined;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
    const res = await fetch(input, init);
    if (res.ok) return res;
    lastError = new Error(`HTTP ${res.status}`);
    } catch (e) {
    lastError = e as Error;
    }
    // Exponential backoff before retrying
    if (attempt < maxRetries) {
    await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1)));
    }
    }
    throw lastError;
    };

    bot.start('localhost', 3000);
    import { Bot } from 'umbot';
    import { fullPlatforms } from 'umbot/plugins';

    const bot = new Bot();
    bot.use(fullPlatforms);
    const ctx = bot.getAppContext();

    ctx.httpClient = async (input, init) => {
    const start = performance.now();
    // input can be string | URL | Request — narrow the type
    const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
    console.log(`[HTTP] → ${init?.method ?? 'GET'} ${url}`);

    try {
    const res = await fetch(input, init);
    const ms = (performance.now() - start).toFixed(1);
    console.log(`[HTTP] ← ${res.status} ${url} (${ms}ms)`);
    return res;
    } catch (e) {
    const ms = (performance.now() - start).toFixed(1);
    console.log(`[HTTP] ✗ ${url} ERROR (${ms}ms): ${e}`);
    throw e;
    }
    };

    bot.start('localhost', 3000);
    import { BotTest } from 'umbot/test';
    import { fullPlatforms } from 'umbot/plugins';

    const bot = new BotTest();
    bot.use(fullPlatforms);
    const ctx = bot.getAppContext();

    // Replace fetch with a mock
    ctx.httpClient = async (input, init) => {
    // input can be string | URL | Request — narrow the type
    const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;

    if (url.includes('api.weather.com')) {
    return new Response(JSON.stringify({ temp: 25 }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
    });
    }

    return new Response('Not Found', { status: 404 });
    };

    // Run through simulate() — unlike the interactive bot.test(),
    // it does not block waiting for console input (neither of them sends real requests
    // to platform APIs — both set skipAutoReply)
    await bot.simulate('what is the weather today?');

    Request.send() does not throw: on error it returns { status: false, data: null, err }. If the server responded but not with 2xx, the result also contains httpStatus and errorBody (the response body, up to 1000 characters). They let you tell "retry later" (429) from a request error (400). On timeouts and network errors these fields are absent.

    const res = await request.send('https://api.example.com/method');
    if (!res.status && res.httpStatus === 429) {
    // res.errorBody — the reason for the refusal and the retry parameters from the API
    }

    TelegramRequest uses this itself: on a 429 with retry_after up to 5 seconds, the request is retried once with the same body. A longer pause (for example, the limit of 20 messages per minute in groups) cannot be awaited inside a webhook response — such a message is not sent, and the reason is written to the log.

    Besides the maxTimeQuery timeout, Request has a signal field — an external cancellation signal. Both work together: the request is aborted by whichever fires first. Like the request body, signal belongs to a single call and is reset after send(), and the request's subscription to the external signal is removed once it finishes — a long-lived signal (a long polling session) does not accumulate subscriptions. This is how the built-in adapters abort a long polling request on bot.stopPolling().

    const controller = new AbortController();
    const request = new Request(appContext);
    request.maxTimeQuery = 35_000;
    request.signal = controller.signal;
    const pending = request.send('https://api.example.com/updates');
    controller.abort(); // send() returns { status: false, err } without waiting for the timeout

    httpClient must match this signature:

    type THttpClient = (url: URL | RequestInfo, init?: RequestInit) => Promise<Response>;
    

    It is compatible with the global fetch in Node.js 20.19+, as well as with libraries such as node-fetch, undici, got (via a wrapper).