On ClojureScript, a handle function that returns a Throwable rejects the resulting promise with the internal wrapper promise rather than with the exception. The downstream catch receives a PromiseImpl, so ex-message is nil and the reason is lost. The JVM does the right thing on the same code, and so does ClojureScript on 11.0.678.
Repro
deps.edn:
{:deps {org.clojure/clojure {:mvn/version "1.12.3"}
org.clojure/clojurescript {:mvn/version "1.11.132"}
funcool/promesa {:mvn/version "12.0.1"}}
:paths ["src"]}
src/probe/core.cljs:
(ns probe.core
(:require [promesa.core :as p]))
(defn show [label e]
(println label "| type:" (pr-str (type e))
"| ex-message:" (pr-str (ex-message e))
"| js/Error?:" (instance? js/Error e)))
(defn -main []
(-> (p/rejected (ex-info "plain" {}))
(p/catch (fn [e] (show "plain catch " e))))
(-> (p/rejected (ex-info "via-handle" {}))
(p/then (fn [r] {:ok r}))
(p/handle (fn [result error] (or error result)))
(p/catch (fn [e] (show "handle->catch" e)))))
(-main)
clojure -M -m cljs.main --target node --output-to out.js --compile probe.core
node out.js
Result
promesa 11.0.678:
plain catch | type: #object[cljs$core$ExceptionInfo] | ex-message: "plain" | js/Error?: true
handle->catch | type: #object[cljs$core$ExceptionInfo] | ex-message: "via-handle" | js/Error?: true
promesa 12.0.1:
plain catch | type: #object[cljs$core$ExceptionInfo] | ex-message: "plain" | js/Error?: true
handle->catch | type: #object[PromiseImpl] | ex-message: nil | js/Error?: false
A plain catch is unaffected. Only the value that has passed through handle is wrapped.
Where it happens
|
JVM |
ClojureScript |
| 11.0.678 |
ok |
ok |
| 12.0.0 |
ok |
PromiseImpl |
| 12.0.1 |
ok |
PromiseImpl |
master 9b38ac3 (2026-07-31) |
ok |
PromiseImpl |
So it arrived in 12.0.0 and is still on master.
Why this looks like a bug rather than misuse
handle's own docstring says the returned promise is "completed (resolving or rejecting) with the return value of calling f", and that "f can return a new plain value or promise instance, and automatic unwrapping will be performed". Returning a Throwable to reject is what that describes, and the JVM branch honours it.
Whatever the intended reading, a PromiseImpl in the error position is neither of the two sensible outcomes. It is not the exception, and it is not the exception resolved as a value.
Mechanism
handle is implemented as:
#?(:cljs (c/-> (pt/-promise p)
(pt/-hmap (comp pt/-promise f))
(pt/-mcat identity))
:clj (c/-> (pt/-promise p)
(pt/-hmap (comp pt/-promise f))
(util/unwrap-completion-stage)))
pt/-promise on a Throwable correctly gives a rejected promise. Verified on cljs with 12.0.1:
-promise on a Throwable REJECTED with: #object[cljs$core$ExceptionInfo] msg "coerced"
So (comp pt/-promise f) produces a rejected inner promise, and the cljs (pt/-mcat identity) does not flatten it into the outer rejection the way the JVM's unwrap-completion-stage does. The inner promise ends up as the rejection reason. That matches a plain Promise.reject(aPromise), which does not unwrap its argument the way resolve does.
Impact
Any cljs code using handle for cleanup and passing the error through, then relying on a later catch, silently loses the exception. In our case a JSON-RPC error response fell back from carrying the handler's reason to a bare "Internal error", with nothing thrown and no warning. It was caught only because two tests asserted on the message.
Environment
- macOS arm64, node, ClojureScript 1.11.132,
cljs.main node target
- Verified 2026-09-01 against 11.0.678, 12.0.0, 12.0.1, and master
9b38ac3
On ClojureScript, a
handlefunction that returns aThrowablerejects the resulting promise with the internal wrapper promise rather than with the exception. The downstreamcatchreceives aPromiseImpl, soex-messageisniland the reason is lost. The JVM does the right thing on the same code, and so does ClojureScript on 11.0.678.Repro
deps.edn:{:deps {org.clojure/clojure {:mvn/version "1.12.3"} org.clojure/clojurescript {:mvn/version "1.11.132"} funcool/promesa {:mvn/version "12.0.1"}} :paths ["src"]}src/probe/core.cljs:Result
promesa 11.0.678:
promesa 12.0.1:
A plain
catchis unaffected. Only the value that has passed throughhandleis wrapped.Where it happens
9b38ac3(2026-07-31)So it arrived in 12.0.0 and is still on master.
Why this looks like a bug rather than misuse
handle's own docstring says the returned promise is "completed (resolving or rejecting) with the return value of callingf", and that "fcan return a new plain value or promise instance, and automatic unwrapping will be performed". Returning aThrowableto reject is what that describes, and the JVM branch honours it.Whatever the intended reading, a
PromiseImplin the error position is neither of the two sensible outcomes. It is not the exception, and it is not the exception resolved as a value.Mechanism
handleis implemented as:pt/-promiseon aThrowablecorrectly gives a rejected promise. Verified on cljs with 12.0.1:So
(comp pt/-promise f)produces a rejected inner promise, and the cljs(pt/-mcat identity)does not flatten it into the outer rejection the way the JVM'sunwrap-completion-stagedoes. The inner promise ends up as the rejection reason. That matches a plainPromise.reject(aPromise), which does not unwrap its argument the wayresolvedoes.Impact
Any cljs code using
handlefor cleanup and passing the error through, then relying on a latercatch, silently loses the exception. In our case a JSON-RPC error response fell back from carrying the handler's reason to a bare "Internal error", with nothing thrown and no warning. It was caught only because two tests asserted on the message.Environment
cljs.mainnode target9b38ac3