Repository: fastify/fastify (JavaScript)

Issue:
Uncaught exception in error handler
### Prerequisites

- [X] I have written a descriptive issue title
- [X] I have searched existing issues to ensure the bug has not already been reported


### Fastify version

4.6.0

### Plugin version

_No response_

### Node.js version

16.6.x

### Operating system

macOS

### Operating system version (i.e. 20.04, 11.3, 10)

20

### Description

I was working on a plugin, one that creates a db transaction once a request starts/ends
and come across an issue where fastify process is killed.

In the example I added, sending a request to "/bug" cause the process to exit.

When there is a custom error handler, and an exception occurs within a hook
fastify will call the custom error handler, creating a new "payload", which will kill the process and print:

```
 Attempted to send payload of invalid type 'object'. Expected a string or Buffer.
```

### Steps to Reproduce

```js
const Fastify = require("fastify");

const fastify = Fastify({
  logger: true,
  disableRequestLogging: true,
});

fastify.setErrorHandler(async (error, request, reply) => {
  // Look mom, i'm a teapot.
  return reply.status(418).send({
    iam: "a teapot",
  });
});

fastify.register(async () => {
  fastify.decorateRequest("commitTransaction", true);

  fastify.addHook("onError", async (request) => {
    request.commitTransaction = false;
  });

  fastify.addHook("onSend", async (request, reply, payload) => {
    if (request.commitTransaction) {
      throw new Error("some exception");
    }

    return payload;
  });
});

fastify.post("/bug", async (request, reply) => {
  return reply.send("test");
});

fastify.post("/working", async (request, reply) => {
  return reply.send({ test: 1 });
});

return fastify.listen({ host: "127.0.0.1", port: 3333 });
```

### Expected Behavior

1. The process should not die.
2. Calling an error handler, and then calling all the lifecycle hooks all over again is weird.
     I am used to error handler being the last stop before a reply is sent.
     The only way I could make the error handler be the last stop is by using (the under documented) "hijack"
     
I think fastify is great, but it seems to have fallen into a common trap regrading errors and errors handling.

There are two versions of an "Error", an "exception" and an applicative error.

The "exception" type is the equivalent of a RuntimeExceprion in Java, where shit may hit the fence by null pointer exceptions, or any other stuff the code did not except **and shouldn't have** (if every line of code we wrote checked if something is null or undefined, we would go crazy).

The applicative error type is the equivalent of a checked exception in Java, its used for example when
schema validation fails. Point is, this is an error which is used as control flow.

I would argue that exceptions should result in a call to the error handler, which should be the last
one to handle the request chain.


Discussion:
You should check whether the `onSend` hook is sending `string` or `Buffer`.

See the last line about `onSend` hook in document
https://github.com/fastify/fastify/blob/main/docs/Reference/Hooks.md#onsend
> Note: If you change the payload, you may only change it to a string, a Buffer, a stream, or null.

If you didn't plan to modify the `payload`. Don't return anything.
This is wrong.. the error handler has no idea what the original content type is.

Fastify itself is not adhering to your suggestion, you can test that by taking my example and removing the custom error handler. You'll see fastify returning a json body instead of a plain text.
Thanks for reporting.
I agree that we should not crash the process in this case.

---

Thanks for the lecture on Error handling. The current implementation was reasoned about for months and it works quite well.

Note that this level of passive-aggression is not well tolerated in this community. All of this is provided for free and... you are free to not use it.
Wasn't trying to be "passive-aggressive". Sorry if it sounded like that.
I just modify a bit of the example to show the run order.

```js
import Fastify from 'fastify';

const fastify = Fastify({
  logger: false,
  disableRequestLogging: true,
})
fastify.setErrorHandler(async (error, request, reply) => {
  console.log(request.url, 'errorHandler')
  // Look mom, i'm a teapot.
  return reply.status(418).send({
    iam: "a teapot",
  });
});

fastify.decorateRequest("commitTransaction", true);

fastify.addHook("onError", async (request) => {
  request.commitTransaction = false;
  console.log(request.url, 'onError')
});

fastify.addHook("onSend", async (request, reply, payload) => {
  console.log(request.url, 'onSend', payload, typeof payload)
  if (request.commitTransaction) {
    throw new Error("some exception");
  }
  return payload;
});

fastify.post("/bug", async (request, reply) => {
  return reply.send("test");
});

fastify.post("/working", async (request, reply) => {
  return reply.send({ test: 1 });
});

{
  // /working onSend {"test":1} string
  // /working onError
  // /working errorHandler
  // /working onSend {"iam":"a teapot"} string
  // 418 {"iam":"a teapot"}
  const { statusCode, body } = await fastify.inject({ method: 'POST', path: '/working' })
  console.log(statusCode, body)
}

{
  // /bug onSend test string
  // /bug onError
  // /bug errorHandler
  // /bug onSend { iam: 'a teapot' } object
  const { statusCode, body } = await fastify.inject({ method: 'POST', path: '/bug' })
  console.log(statusCode, body)
}
```

We should probably reset the `content-type` before enter `error handler`.
As a workaround or good behavior. I highly recommend you to explicitly add `content-type` header for each `reply.send`.



Patch:
diff --git a/lib/error-handler.js b/lib/error-handler.js
index 190489b13da..68c89097339 100644
--- a/lib/error-handler.js
+++ b/lib/error-handler.js
@@ -49,6 +49,8 @@ function handleError (reply, error, cb) {
   // In case the error handler throws, we set the next errorHandler so we can error again
   reply[kReplyNextErrorHandler] = Object.getPrototypeOf(errorHandler)
 
+  // we need to remove content-type to allow content-type guessing for serialization
+  delete reply[kReplyHeaders]['content-type']
   delete reply[kReplyHeaders]['content-length']
 
   const func = errorHandler.func


Test patch:
diff --git a/test/content-type.test.js b/test/content-type.test.js
new file mode 100644
index 00000000000..06e91f9b544
--- /dev/null
+++ b/test/content-type.test.js
@@ -0,0 +1,43 @@
+'use strict'
+
+const t = require('tap')
+const test = t.test
+const Fastify = require('..')
+
+test('should remove content-type for setErrorHandler', async t => {
+  t.plan(8)
+  let count = 0
+
+  const fastify = Fastify()
+  fastify.setErrorHandler(function (error, request, reply) {
+    t.same(error.message, 'kaboom')
+    t.same(reply.hasHeader('content-type'), false)
+    reply.code(400).send({ foo: 'bar' })
+  })
+  fastify.addHook('onSend', async function (request, reply, payload) {
+    count++
+    t.same(typeof payload, 'string')
+    switch (count) {
+      case 1: {
+        // should guess the correct content-type based on payload
+        t.same(reply.getHeader('content-type'), 'text/plain; charset=utf-8')
+        throw Error('kaboom')
+      }
+      case 2: {
+        // should guess the correct content-type based on payload
+        t.same(reply.getHeader('content-type'), 'application/json; charset=utf-8')
+        return payload
+      }
+      default: {
+        t.fail('should not reach')
+      }
+    }
+  })
+  fastify.get('/', function (request, reply) {
+    reply.send('plain-text')
+  })
+
+  const { statusCode, body } = await fastify.inject({ method: 'GET', path: '/' })
+  t.same(statusCode, 400)
+  t.same(body, JSON.stringify({ foo: 'bar' }))
+})