Repository navigation
fix(microservices): survive custom deserializer errors in clients - #17995
Merged
kamilmysliwiec merged 2 commits intoOct 8, 2026
Merged
Conversation
A custom deserializer that throws or rejects on a reply crashed the process. The TCP, Redis, MQTT and RMQ clients await the deserializer inside a listener that nobody awaits. The rejection was unhandled, so Node exited. If the app handled it, the request never settled. ClientRMQ now catches the error and fails the exact request with it. The callback gets the error and isDisposed, like ClientNats does. The RMQ reply consumer also skips the null message that RabbitMQ sends when it cancels the consumer, as ServerRMQ does, and logs a warning. ClientTCP, ClientRedis and ClientMqtt catch the error, log it and drop the reply. Only the deserializer knows the request id, so these clients cannot fail the request. Other pending requests stay untouched. Closes nestjs#17994
Asadshah7950
reviewed
Oct 5, 2026
Asadshah7950
left a comment
Contributor
There was a problem hiding this comment.
I pulled this down and verified the fix locally across all 4 microservice client suites (213 tests passing).
The crash it fixes is real. Because event callbacks like socket data listeners and message handlers run unawaited in the event loop, an unhandled rejection inside deserializer.deserialize() causes Node to exit with code 1.
A few things I like about this implementation:
-
Transport-aware separation:
- In
ClientRMQ, correlation is tracked at the envelope level viamsg.properties.correlationId. Becausecallbackis already bound to the pending request, the catch block can cleanly fail that specific caller with{ err: error, isDisposed: true }and clean uproutingMap. - In
ClientTCP,ClientRedis, andClientMqtt, the correlation ID lives inside the payload itself. When the deserializer fails, the client can't know which request the message was meant for. Dropping the bad packet and logging withthis.logger.error(...)prevents the crash without risking corrupted state in other pending requests.
- In
-
RabbitMQ consumer cancellation:
- Adding
if (!msg)inClientRMQ.consumeChannel()handles the null delivery that RabbitMQ sends when a queue is deleted or cancelled, so we don't throw an uncaughtTypeErroronmsg.properties.
- Adding
-
Narrow try/catch scope:
- The try/catch specifically wraps
this.deserializer.deserialize()and nothing downstream. If subscriber code insidecallback()throws, it isn't swallowed by this catch block. I verified this with the callback failure tests.
- The try/catch specifically wraps
-
Non-blocking note on request cleanup for TCP/Redis/Mqtt:
- For TCP, Redis, and MQTT, because dropped packets can't be mapped back to an ID, the caller's request will stay in
routingMapuntil the caller's RxJStimeout()fires. That's the right tradeoff since guessing the ID would be dangerous.
- For TCP, Redis, and MQTT, because dropped packets can't be mapped back to an ID, the caller's request will stay in
Clean fix and thorough test coverage across all transports. LGTM!
The null-message guard left a cancelled reply consumer in place while the client stayed connected, so replies stopped arriving for good. Without it the error closes the connection and the reconnect sets the channel up again, as before. Dropped responses are now logged with their stack.
4 of 12 tasks
kamilmysliwiec
added a commit
that referenced
this pull request
Oct 8, 2026
…18043) * fix(microservices): fail pending kafka request on deserializer error a custom deserializer that throws on a response rejects the kafka reply handler. kafkajs logs the error, commits the offset and rethrows, so the consumer crashes and the pending request never resolves. the other clients already catch this. mqtt, redis, tcp and rmq got the check in PR #17995 and rmq fails the pending callback too, bcs the id is known before the deserializer runs. kafka has the correlation id on the headers as well, so the request can be failed instead of staying pending. adds a focused spec in packages/microservices/test/client/client-kafka-deserializer.spec.ts, the same shape as the sibling fixes from that sweep. it fails on master with "promise rejected TypeError: Cannot read properties of undefined instead of resolving" * fix(microservices): ignore kafka replies with an empty correlation id --------- Co-authored-by: Kamil Myśliwiec <mail@kamilmysliwiec.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Checklist
PR Type
What is the current behavior?
Issue Number: #17994
Closes #17994
Hi! 👋 This is the follow-up to #17959, which fixed the same problem in
ClientNats.A custom deserializer that throws or rejects on a reply crashes the process.
ClientTCP,ClientRedis,ClientMqttandClientRMQawait the deserializer inside a listener that nobody awaits. The rejection is unhandled, so Node exits. If the app handles it, the request never settles.I ran the repro on 12.1.2 with real brokers. All four clients crashed with exit code 1 on that reply:
The default deserializer never throws, so only a custom deserializer triggers this.
What is the new behavior?
ClientRMQcatches the error and fails the exact request with it. The callback gets the error andisDisposed, as inClientNats. The RMQ reply consumer also ignores thenullmessage that RabbitMQ sends when it cancels the consumer, asServerRMQdoes. The client logs a warning in that case, because the pending requests can no longer get a reply.ClientTCP,ClientRedisandClientMqttcatch the error, log it and drop the reply. Only the deserializer knows the request id, so these clients cannot tell which request to fail. The other pending requests stay as they are.The same repro against a local build of this branch:
The four valid replies still resolve with
"ok".Does this PR introduce a breaking change?
Valid replies take the same path as before. Nothing exported changes.
Other information
The specs cover these cases:
ClientRMQ: the request fails with the error, and the client removes the request fromroutingMap.ClientTCP,ClientRedis,ClientMqtt: the client logs the error and keeps the other pending requests.try.ClientRMQ: anullmessage from the broker does not throw, and the client logs a warning.Some limits stay:
timeout()operator on the caller side ends the wait and frees the request.undefinedinstead of throwing still fails outside the newtry.ClientKafkahas the same missing catch. kafkajs awaits the handler, logs the error and rethrows it to its own runner. I did not run that case, so I left it out of this PR.I am happy to look at a better option for any of these if you have one in mind. 🙂