Lazy relation whose load rejects triggers unhandledRejection even when the caller awaits and catches it
Issue description
A lazy relation whose load rejects produces an unhandledRejection even when the caller awaits it inside try/catch. On Node ≥ 15 that terminates the process.
Expected Behavior
If application code awaits a lazy relation and handles the rejection, that should be the end of it:
try {
const tier = await subscription.subscriptionTier; // lazy relation
} catch (err) {
// handled — the process should survive
}Actual Behavior
The caller's catch runs and unhandledRejection fires with the same error, killing the process.
The cause is the bookkeeping .then() in setPromise, inside RelationLoader.enableLazyLoad (src/query-builder/RelationLoader.ts):
const setPromise = (entity: ObjectLiteral, value: Promise<any>) => {
delete entity[resolveIndex]
delete entity[dataIndex]
entity[promiseIndex] = value
// eslint-disable-next-line @typescript-eslint/no-floating-promises
value.then(
// ensure different value is not assigned yet
(result) =>
entity[promiseIndex] === value
? setData(entity, result)
: result,
)
return value
}value.then(onFulfilled) is passed no rejection handler, so it derives a new promise that rejects whenever value rejects. That derived promise is returned to nobody and stored nowhere — entity[promiseIndex] holds value, not the derived chain — so it can never be handled, by the caller or by anyone else.
One failing lazy load therefore rejects twice:
| promise | reachable by user code? | outcome |
|---|---|---|
value (returned by the getter) |
yes | awaited and caught normally |
value.then(...) (bookkeeping) |
no | unhandledRejection → process exit |
Because the orphan is created inside TypeORM, no amount of care at the call site prevents it, and a lint rule such as @typescript-eslint/no-floating-promises cannot see it.
Steps to reproduce
This drives the real enableLazyLoad, with the underlying load stubbed to reject the way a query timeout or a dropped connection would. No database needed:
const { RelationLoader } = require('typeorm/query-builder/RelationLoader');
let unhandled = null;
process.on('unhandledRejection', (reason) => { unhandled = reason; });
const loader = Object.create(RelationLoader.prototype);
loader.load = () => Promise.reject(new Error('Query read timeout'));
const entity = {};
loader.enableLazyLoad(
{ propertyName: 'subscriptionTier', isManyToOne: true, isOneToOne: false },
entity,
undefined,
);
(async () => {
try {
await entity.subscriptionTier;
console.log('resolved (unexpected)');
} catch (err) {
console.log('caller CAUGHT its rejection correctly ->', err.message);
}
await new Promise((r) => setTimeout(r, 50)); // let the microtask queue drain
console.log('unhandledRejection fired?', unhandled ? 'YES -> ' + unhandled.message : 'no');
})();Output on 0.3.31:
caller CAUGHT its rejection correctly -> Query read timeout
unhandledRejection fired? YES -> Query read timeoutWith real entities the same thing happens whenever the relation query fails. We hit it in production with pg's query_timeout: the request returned a correct 500 from the awaited path, and the process died anyway from the orphan. The reported stack has no application frame above getMany, which is what makes it hard to trace back:
QueryFailedError: Query read timeout
at PostgresQueryRunner.query (.../driver/postgres/PostgresQueryRunner.ts:325:19)
at SelectQueryBuilder.loadRawResults (.../query-builder/SelectQueryBuilder.ts:3818:25)
at SelectQueryBuilder.executeEntitiesAndRawResults (.../query-builder/SelectQueryBuilder.ts:3564:26)
at SelectQueryBuilder.getRawAndEntities (.../query-builder/SelectQueryBuilder.ts:1617:29)
at SelectQueryBuilder.getMany (.../query-builder/SelectQueryBuilder.ts:1707:25)My Environment
| Dependency | Version |
|---|---|
| Operating System | Linux (Amazon ECS Fargate), also reproduced on macOS |
| Node.js version | v24 |
| Typeorm version | 0.3.31 — the same code is present in 1.1.1 and on master |
Additional Context
Suggested fix: give the bookkeeping chain a rejection handler, so it stops being an orphan. The promise handed to the caller is unchanged and still rejects.
value.then(
(result) =>
entity[promiseIndex] === value ? setData(entity, result) : result,
() => {
// the caller owns `value`'s rejection; only clear the cached in-flight promise
if (entity[promiseIndex] === value) delete entity[promiseIndex]
},
)Clearing promiseIndex on failure also means a later access retries the load instead of returning a permanently rejected cached promise.
I verified this against the repro above — the caller still receives the error, and unhandledRejection no longer fires:
caller CAUGHT -> Query read timeout
unhandledRejection fired? noHappy to open a PR with this plus a regression test if the approach looks right.
Relevant Database Driver(s)
- postgres
(The defect is driver-independent — it is in RelationLoader — but postgres is where we observed it.)
Are you willing to resolve this issue by submitting a Pull Request?
Yes, I have the time, and I know how to start.
Source: typeorm/typeorm