commit 19c30e61b252bec5bfaa3d65fc7900764a628467
parent 32de0432c0a9f050cba1eb44d93388fe28687a67
Author: Florian Dold <dold@taler.net>
Date: Tue, 28 Jul 2026 23:06:35 +0200
util: keep the typed timeout and cancellation errors for native requests
cancelFn() rejects the native promise synchronously, so cancelling before
rejecting let Promise.race settle on the platform's own error, and a qtart
timeout surfaced as a plain cancellation instead of a timeout.
Issue: https://bugs.taler.net/n/9822
Diffstat:
2 files changed, 58 insertions(+), 2 deletions(-)
diff --git a/packages/taler-util/src/http-common.ts b/packages/taler-util/src/http-common.ts
@@ -95,17 +95,21 @@ export async function awaitNativeRequest<T>(
}
};
+ // Reject before cancelling, not after: cancelFn() makes the native promise
+ // reject synchronously, and Promise.race keeps whichever settled first, so
+ // cancelling first surfaces the platform's own cancellation error and the
+ // typed error below is lost.
if (opts.timeout && opts.timeout.d_ms !== "forever") {
timeoutHandle = setTimeout(() => {
- native.cancelFn();
abortCap.reject(new RequestTimeoutError());
+ native.cancelFn();
}, opts.timeout.d_ms);
}
if (opts.cancellationToken) {
unregisterCancel = opts.cancellationToken.onCancelled(() => {
- native.cancelFn();
abortCap.reject(new RequestCancelledError());
+ native.cancelFn();
});
}
diff --git a/packages/taler-util/src/http-native-request.test.ts b/packages/taler-util/src/http-native-request.test.ts
@@ -96,3 +96,55 @@ test("no armed-timer leak: the timeout does not fire after the request settled",
await new Promise((r) => setTimeout(r, 60));
assert.strictEqual(s.cancels, 0);
});
+
+/**
+ * Like stubNative, but cancelFn rejects the in-flight promise synchronously,
+ * the way the qtart runtime actually behaves.
+ */
+function stubNativeRejectingOnCancel<T>() {
+ let cancels = 0;
+ let rejectFn: (e: unknown) => void = () => undefined;
+ const promise = new Promise<T>((_resolve, reject) => {
+ rejectFn = reject;
+ });
+ return {
+ native: {
+ promise,
+ cancelFn: () => {
+ cancels++;
+ rejectFn(new Error("HTTP request cancelled"));
+ },
+ },
+ get cancels() {
+ return cancels;
+ },
+ };
+}
+
+async function rejectionOf(p: Promise<unknown>): Promise<any> {
+ try {
+ await p;
+ } catch (e) {
+ return e;
+ }
+ throw new Error("expected the promise to reject");
+}
+
+test("timeout reports a timeout even when cancelling rejects the native request", async (t) => {
+ const s = stubNativeRejectingOnCancel();
+ const e = await rejectionOf(
+ awaitNativeRequest(s.native, { timeout: Duration.fromMilliseconds(20) }),
+ );
+ assert.strictEqual(e.name, "RequestTimeoutError");
+ assert.strictEqual(s.cancels, 1);
+});
+
+test("cancellation reports a cancellation even when cancelling rejects the native request", async (t) => {
+ const s = stubNativeRejectingOnCancel();
+ const src = CancellationToken.create();
+ const p = awaitNativeRequest(s.native, { cancellationToken: src.token });
+ src.cancel();
+ const e = await rejectionOf(p);
+ assert.strictEqual(e.name, "RequestCancelledError");
+ assert.strictEqual(s.cancels, 1);
+});