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:
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) {