funcool / funcool/promesa

cljs: `handle` returning a Throwable rejects with the wrapper promise, not the exception (regression in 12.0.0)

Open
#171 0 comments 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.