ERROR: Body is unusable: Body has already been read
What version of Hono are you using?
4.7.5
What runtime/platform is your app running on? (with version if possible)
NodeJS on AWS Lambda
What steps can reproduce the bug?
The code below randomly throws this error with a probability of 1 in 100 to 1 in 1000.
{
"errorType": "TypeError",
"errorMessage": "Body is unusable: Body has already been read",
"stack": [
"TypeError: Body is unusable: Body has already been read",
" at consumeBody (node:internal/deps/undici/undici:5712:15)",
" at _Response.text (node:internal/deps/undici/undici:5662:18)",
" at s.createResult (file:///var/task/lambda.mjs:595:10175)",
" at Runtime.handler (file:///var/task/lambda.mjs:595:9620)"
]
}
import { Hono } from "hono";
import { secureHeaders } from "hono/secure-headers";
import { renderPage } from "./render";
import { randomBytes } from "crypto";
import { serveStatic } from "@hono/node-server/serve-static";
import { etag } from "hono/etag";
const app = new Hono();
app.use(etag({ weak: true }));
app.use(serveStatic({ root: "./static" }));
app.use((c, next) => {
const nonce = randomBytes(16).toString("base64");
c.set("secureHeadersNonce", nonce);
return secureHeaders({
contentSecurityPolicy: {
scriptSrc: [`'self'`, `'strict-dynamic'`, `'nonce-${nonce}'`]
}
})(c, next);
});
app.get("*", async c => {
const nonce = c.get("secureHeadersNonce");
return c.html(await renderPage(c.req.path, nonce));
});
export default app;
What is the expected behavior?
No response
What do you see instead?
No response
Additional information
No response
oh nice of you could reproduce it! I couldn't!
oh nice of you could reproduce it! I couldn't!
I found that the issue is with the ETag middleware. It works fine without it. I'm still doing more research, and I suspect it's a compatibility issue between the ETag middleware,serve-static middleware and the Lambda runtime.
There was a slight inaccuracy in my statement. I mentioned that the probability of this occurring is low, but in reality, it is quite high. However, it primarily happens during the Lambda warm up phase. Once the warm up is complete and there are enough warm Lambdas, this issue no longer occurs.
then it's not exactly the same I believe
Hi @yusukebe,
I found that the issue is with this line in the ETag middleware.
res.clone().body
Copilot explained:
The problem is in the etag middleware where it tries to clone the response body to generate a digest. The AWS Lambda environment doesn't support this operation in the same way as other environments.
The working version:
export const generateDigest = async (
data: ArrayBuffer,
generator: (body: Uint8Array) => ArrayBuffer | Promise<ArrayBuffer>
): Promise<string | null> => {
if (!data) {
return null;
}
const result = await generator(new Uint8Array(data));
if (!result) {
return null;
}
return Array.prototype.map
.call(new Uint8Array(result), x => x.toString(16).padStart(2, "0"))
.join("");
};
type ETagOptions = {
retainedHeaders?: string[];
weak?: boolean;
generateDigest?: (body: Uint8Array) => ArrayBuffer | Promise<ArrayBuffer>;
};
/**
* Default headers to pass through on 304 responses. From the spec:
* > The response must not contain a body and must include the headers that
* > would have been sent in an equivalent 200 OK response: Cache-Control,
* > Content-Location, Date, ETag, Expires, and Vary.
*/
export const RETAINED_304_HEADERS = [
"cache-control",
"content-location",
"date",
"etag",
"expires",
"vary"
];
function etagMatches(etag: string, ifNoneMatch: string | null) {
return ifNoneMatch != null && ifNoneMatch.split(/,\s*/).indexOf(etag) > -1;
}
function initializeGenerator(
generator?: ETagOptions["generateDigest"]
): ETagOptions["generateDigest"] | undefined {
if (!generator) {
if (crypto && crypto.subtle) {
generator = (body: Uint8Array) =>
crypto.subtle.digest(
{
name: "SHA-1"
},
body
);
}
}
return generator;
}
/**
* ETag Middleware for Hono.
*
* @see {@link https://hono.dev/docs/middleware/builtin/etag}
*
* @param {ETagOptions} [options] - The options for the ETag middleware.
* @param {boolean} [options.weak=false] - Define using or not using a weak validation. If true is set, then `W/` is added to the prefix of the value.
* @param {string[]} [options.retainedHeaders=RETAINED_304_HEADERS] - The headers that you want to retain in the 304 Response.
* @param {function(Uint8Array): ArrayBuffer | Promise<ArrayBuffer>} [options.generateDigest] -
* A custom digest generation function. By default, it uses 'SHA-1'
* This function is called with the response body as a `Uint8Array` and should return a hash as an `ArrayBuffer` or a Promise of one.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
* ```ts
* const app = new Hono()
*
* app.use('/etag/*', etag())
* app.get('/etag/abc', (c) => {
* return c.text('Hono is cool')
* })
* ```
*/
export const etag = (options?: ETagOptions): any => {
const retainedHeaders = options?.retainedHeaders ?? RETAINED_304_HEADERS;
const weak = options?.weak ?? false;
const generator = initializeGenerator(options?.generateDigest);
return async function etag(c, next) {
const ifNoneMatch = c.req.header("If-None-Match") ?? null;
await next();
const res = c.res as Response;
let etag = res.headers.get("ETag");
if (!etag && generator) {
try {
// Safely buffer the entire response body to avoid stream issues
const buffer = await res.arrayBuffer();
// Generate hash from the buffered response
const hash = await generateDigest(buffer, generator);
if (hash !== null) {
etag = weak ? `W/"${hash}"` : `"${hash}"`;
// Create a new response with the same data and headers
const newResponse = new Response(buffer, {
status: res.status,
statusText: res.statusText,
headers: res.headers
});
c.res = newResponse;
}
} catch (error) {
console.error("Error generating ETag:", error);
return;
}
}
if (etag && etagMatches(etag, ifNoneMatch)) {
c.res = new Response(null, {
status: 304,
statusText: "Not Modified",
headers: {
ETag: etag
}
});
c.res.headers.forEach((_, key) => {
if (retainedHeaders.indexOf(key.toLowerCase()) === -1) {
c.res.headers.delete(key);
}
});
} else if (etag) {
c.res.headers.set("ETag", etag);
}
};
};
The above version has been proven to work, but I'll leave it to you as the author of the package to implement it in the way you feel most comfortable. I'm always here for testing.
Thanks!
Hi @cjnoname
Do you think this is a problem only for AWS Lambda?
Hi @cjnoname
Do you think this is a problem only for AWS Lambda?
Hey mate, I'm not sure because I only use Lambda.
Hi @cjnoname
Do you think this is a problem only for AWS Lambda?
Do you have any plans to implement this, or would you prefer that I submit a PR? The performance is actually better with the code I shared above.
@cjnoname
Can you provide a minimal project to reproduce the issue and an instruction?
Can you provide a minimal project to reproduce the issue and an instruction?
Hey mate, sorry—I’ve been quite busy with this. But I can confirm it’s a bug with AWS Lambda. There’s no way to replicate it locally.
@cjnoname
Hey mate, sorry—I’ve been quite busy with this.
No problem!
There’s no way to replicate it locally.
That's the pain point of AWS Lambda, and I'm not so super familiar with AWS now 🥲 We are welcome to help from someone.
I'll look into this.
@watany-dev Thanks!
We are trying, but it is taking a long time without being able to write a test that can be reproduced.
Hey @@watany-dev
I'm 100% sure this doesn't work on Lambda. If anyone tries to deploy an SSR frontend app to Lambda with ETag enabled, it will fail.
To save time, feel free to modify the implementation in whichever way you prefe, then send me the source file, and I’ll happily test it on Lambda as soon as possible.
In other words, you're welcome to implement it in any way that makes sense to you — I can help test it on Lambda while you test it in Docker. The file I shared earlier in this channel works fine on Lambda, but you can take any approach you like, and I’ll verify it on my end.
Might not be related but I came across this after I was getting a "Body is unusable: Body has already been read" error.
Turned our my problem was I had a middleware calling ctx.req.raw.blob() and then in the request handler calling ctx.req.json().
Updated the middleware to call ctx.req.blob() instead and that's resolved the issue for me.
Hey @yusukebe @watany-dev,
I've created a PR to address the issue with AWS Lambda, and it works perfectly in my testing: https://github.com/honojs/hono/pull/4166
However, I haven't tested it with other use cases yet.
Please feel free to review and make any changes if needed.
Thanks!
Hi @cjnoname.
Thank you for investigating the issue. Have you been able to identify the path where this issue occurs? If it occurs under serveStatic, we may need to consider adding an option to generate “ETag” in serveStatic.
Hi @cjnoname.
Thank you for investigating the issue. Have you been able to identify the path where this issue occurs? If it occurs under
serveStatic, we may need to consider adding an option to generate “ETag” inserveStatic.
We are using serveStatic, but I’m not sure if that’s the root cause. The issue is that the stream body is being consumed even when using .clone(). I’m not sure where or why it’s being consumed.
Thank you for your reply, @cjnoname. I believe that it will be difficult to make effective changes without the following two pieces of information.
- A minimal, reproducible project.
- A request pattern that reproduces the problem for the deployed lambda.
If serveStatic is involved, the size of the file being delivered may be relevant, so I think a reproducible project is necessary, not just a code snippet.
Thank you for your reply, @cjnoname. I believe that it will be difficult to make effective changes without the following two pieces of information.
- A minimal, reproducible project.
- A request pattern that reproduces the problem for the deployed lambda.
If
serveStaticis involved, the size of the file being delivered may be relevant, so I think a reproducible project is necessary, not just a code snippet.
Sorry for my late reply, I’ve been quite busy lately.
I think you’re right. It’s related to static files.
ChatGPT 5 helped me move the ETag plugin from global scope to only the HTML render, which meant the static files were bypassed, and the bug disappeared.
ChatGPT also mentioned that Hono might consume the body multiple times when serving files.
I’m using CloudFront, and once I removed the ETags, the HTTP response changed from 304 to 200.
I’d appreciate your best suggestions.
Thanks.
any progress or workaround? this is creating constant error on my service
Hi @cjnoname, I apologize for missing your comment.
It's probably related to serveStatic after all. Ideally, we'd want to use filesystem metadata in responses from serveStatic. I'll think about it.
Thanks @usualoma, yes, I’m sure it’s related to serveStatic. The error occurred when I enabled ETag on both the files and the SSR render output. I’ve resolved it on my end by setting up separate caching rules for the files.
Hi @OoO256, thank you for your comment. Could you create a small project that reproduces the issue? We lack information on the specific circumstances under which it occurs.
Depending on the project configuration, generating the ETag yourself as shown below seems to be an effective workaround for now.
app.use(
async (c, next) => {
await next()
if (c.res.status < 200 || c.res.status >= 300) {
return
}
const url = new URL(c.req.url)
c.res.headers.set(
'etag',
`"${url.pathname}-${c.res.headers.get('content-length')}-${
c.res.headers.get('content-range') || ''
}"`
)
},
serveStatic({
root: './static',
})
)