cljs: `handle` returning a Throwable rejects with the wrapper promise, not the exception (regression in 12.0.0)
- Dominant language
- Clojure
- Stars
- 547
- Forks
- 64
- PR merge metrics
- No merged PRs in 30d
Description
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`:
```clojure
{: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`:
```clojure
(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)
```
```sh
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:
```clojure
#?(: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`
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with the ClojureScript branch of handle and trace the pt/-promise, pt/-hmap, and pt/-mcat calls shown in the issue. Run the supplied cljs.main and Node reproduction, then compare the result with the JVM branch and promesa 11.0.678. Done means a Throwable returned through handle reaches the downstream catch unchanged, with its exception message preserved.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- clojure
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100