taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit fc42bc395dee4b34283f7c6b2c12889f66580de0
parent 6af0cb6842298379e7db0c93f000935424390bbf
Author: Florian Dold <dold@taler.net>
Date:   Tue, 21 Jul 2026 02:05:54 +0200

util: cancel and clean up the qtart request on timeout and failure

Extract the timeout/cancellation/cleanup logic into awaitNativeRequest in
http-common for easier testing.

Diffstat:
Mpackages/taler-util/src/http-common.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-impl.qtart.ts | 69+++++++++++++++++++--------------------------------------------------
2 files changed, 91 insertions(+), 50 deletions(-)

diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts @@ -21,6 +21,7 @@ import { Codec } from "./codec.js"; import { makeErrorDetail, TalerError } from "./errors.js"; import { j2s } from "./helpers.js"; import { Logger } from "./logging.js"; +import { openPromise } from "./promises.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { Duration } from "./time.js"; import { TalerErrorDetail } from "./types-taler-wallet.js"; @@ -44,6 +45,77 @@ export interface HttpResponse { export const DEFAULT_REQUEST_TIMEOUT_MS = 60000; +/** + * Thrown by awaitNativeRequest when the request timed out. + */ +export class RequestTimeoutError extends Error { + constructor() { + super("Request timed out"); + Object.setPrototypeOf(this, RequestTimeoutError.prototype); + this.name = "RequestTimeoutError"; + } +} + +/** + * Thrown by awaitNativeRequest when the request was cancelled. + */ +export class RequestCancelledError extends Error { + constructor() { + super("Request cancelled"); + Object.setPrototypeOf(this, RequestCancelledError.prototype); + this.name = "RequestCancelledError"; + } +} + +/** + * Await a native, cancellable request, bounded by an optional timeout and an + * optional cancellation token. + * + * On timeout or cancellation the native request is cancelled via cancelFn so + * that it does not stay in flight, and RequestTimeoutError / RequestCancelled- + * Error is thrown. The timer and the cancellation listener are released on + * every path, so neither leaks when the request fails. + */ +export async function awaitNativeRequest<T>( + native: { promise: Promise<T>; cancelFn: () => void }, + opts: { timeout?: Duration; cancellationToken?: CancellationToken }, +): Promise<T> { + const abortCap = openPromise<T>(); + let timeoutHandle: ReturnType<typeof setTimeout> | undefined = undefined; + let unregisterCancel: (() => void) | undefined = undefined; + + const cleanup = (): void => { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + timeoutHandle = undefined; + } + if (unregisterCancel !== undefined) { + unregisterCancel(); + unregisterCancel = undefined; + } + }; + + if (opts.timeout && opts.timeout.d_ms !== "forever") { + timeoutHandle = setTimeout(() => { + native.cancelFn(); + abortCap.reject(new RequestTimeoutError()); + }, opts.timeout.d_ms); + } + + if (opts.cancellationToken) { + unregisterCancel = opts.cancellationToken.onCancelled(() => { + native.cancelFn(); + abortCap.reject(new RequestCancelledError()); + }); + } + + try { + return await Promise.race([native.promise, abortCap.promise]); + } finally { + cleanup(); + } +} + export interface HttpRequestOptions { method?: "POST" | "PATCH" | "PUT" | "GET" | "DELETE"; headers?: { [name: string]: string | undefined }; diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts @@ -19,9 +19,19 @@ /** * Imports. */ -import { j2s, Logger, openPromise } from "@gnu-taler/taler-util"; +import { j2s, Logger } from "@gnu-taler/taler-util"; import { TalerError } from "./errors.js"; -import { encodeBody, getDefaultHeaders, HttpLibArgs } from "./http-common.js"; +import { + awaitNativeRequest, + encodeBody, + getDefaultHeaders, + HttpLibArgs, + RequestCancelledError, + RequestTimeoutError, +} from "./http-common.js"; + +// Re-exported for backwards compatibility; defined in http-common. +export { RequestCancelledError, RequestTimeoutError }; import { HeadersImpl, HttpRequestLibrary, @@ -35,20 +45,6 @@ const logger = new Logger("http-impl.qtart.ts"); const textDecoder = new TextDecoder(); -export class RequestTimeoutError extends Error { - public constructor() { - super("Request timed out"); - Object.setPrototypeOf(this, RequestTimeoutError.prototype); - } -} - -export class RequestCancelledError extends Error { - public constructor() { - super("Request cancelled"); - Object.setPrototypeOf(this, RequestCancelledError.prototype); - } -} - /** * Implementation of the HTTP request library interface for node. */ @@ -113,42 +109,23 @@ export class HttpLibImpl implements HttpRequestLibrary { data = encodeBody(opt?.body); } - const cancelPromCap = openPromise<QjsHttpResp>(); - logger.trace(`calling qtart fetchHttp`); - // Just like WHATWG fetch(), the qjs http client doesn't - // really support cancellation, so cancellation here just - // means that the result is ignored! const { promise: fetchProm, cancelFn } = qjsOs.fetchHttp(url, { method, data, headers: headersList, }); - let timeoutHandle: any = undefined; - let cancelCancelledHandler: (() => void) | undefined = undefined; - - if (opt?.timeout && opt.timeout.d_ms !== "forever") { - timeoutHandle = setTimeout(() => { - cancelPromCap.reject(new RequestTimeoutError()); - }, opt.timeout.d_ms); - } - - if (opt?.cancellationToken) { - cancelCancelledHandler = opt.cancellationToken.onCancelled(() => { - logger.info(`cancelling native networking request`); - const cancelRes = cancelFn(); - logger.info( - `cancelled native networking request (result ${cancelRes})`, - ); - cancelPromCap.reject(new RequestCancelledError()); - }); - } - + // awaitNativeRequest applies the timeout and cancellation token, cancels + // the native request on either, and releases the timer and the + // cancellation listener on every path. let res: QjsHttpResp; try { - res = await Promise.race([fetchProm, cancelPromCap.promise]); + res = await awaitNativeRequest( + { promise: fetchProm, cancelFn }, + { timeout: opt?.timeout, cancellationToken: opt?.cancellationToken }, + ); } catch (e) { logger.trace(`got exception while waiting for qtart http response`); if (e instanceof RequestCancelledError) { @@ -199,14 +176,6 @@ export class HttpLibImpl implements HttpRequestLibrary { logger.trace(`response headers: ${j2s(res.headers)}`); } - if (timeoutHandle != null) { - clearTimeout(timeoutHandle); - } - - if (cancelCancelledHandler != null) { - cancelCancelledHandler(); - } - const headers: HeadersImpl = new HeadersImpl(); if (res.headers) {