hono icon indicating copy to clipboard operation
hono copied to clipboard

ERROR: Body is unusable: Body has already been read

Open cjnoname opened this issue 8 months ago • 24 comments

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

cjnoname avatar Mar 30 '25 23:03 cjnoname

oh nice of you could reproduce it! I couldn't!

Plopix avatar Mar 31 '25 02:03 Plopix

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.

cjnoname avatar Mar 31 '25 02:03 cjnoname

then it's not exactly the same I believe

Plopix avatar Mar 31 '25 02:03 Plopix

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!

cjnoname avatar Mar 31 '25 03:03 cjnoname

Hi @cjnoname

Do you think this is a problem only for AWS Lambda?

yusukebe avatar Mar 31 '25 10:03 yusukebe

Hi @cjnoname

Do you think this is a problem only for AWS Lambda?

Hey mate, I'm not sure because I only use Lambda.

cjnoname avatar Mar 31 '25 10:03 cjnoname

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 avatar Apr 05 '25 16:04 cjnoname

@cjnoname

Can you provide a minimal project to reproduce the issue and an instruction?

yusukebe avatar Apr 13 '25 03:04 yusukebe

@cjnoname

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 avatar Apr 13 '25 23:04 cjnoname

@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.

yusukebe avatar Apr 14 '25 06:04 yusukebe

I'll look into this.

watany-dev avatar Apr 14 '25 06:04 watany-dev

@watany-dev Thanks!

yusukebe avatar Apr 14 '25 06:04 yusukebe

We are trying, but it is taking a long time without being able to write a test that can be reproduced.

watany-dev avatar Apr 22 '25 02:04 watany-dev

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.

cjnoname avatar Apr 22 '25 07:04 cjnoname

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.

alasdair-mctavish avatar Apr 22 '25 14:04 alasdair-mctavish

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!

cjnoname avatar May 26 '25 02:05 cjnoname

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.

usualoma avatar Jun 07 '25 22:06 usualoma

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.

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.

cjnoname avatar Jun 20 '25 02:06 cjnoname

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.

usualoma avatar Jun 21 '25 08:06 usualoma

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.

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.

cjnoname avatar Aug 09 '25 18:08 cjnoname

any progress or workaround? this is creating constant error on my service

OoO256 avatar Sep 12 '25 17:09 OoO256

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.

usualoma avatar Sep 12 '25 23:09 usualoma

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.

cjnoname avatar Sep 13 '25 01:09 cjnoname

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',
  })
)

usualoma avatar Sep 13 '25 09:09 usualoma