Nick
Nick

Reputation: 859

Cloudflare Worker TypeError: One-time-use body

I'm trying to use a Cloudflare Worker to proxy a POST request to another server.

It is throwing a JS exception – by wrapping in a try/catch blog I've established that the error is:

TypeError: A request with a one-time-use body (it was initialized from a stream, not a buffer) encountered a redirect requiring the body to be retransmitted. To avoid this error in the future, construct this request from a buffer-like body initializer.

I would have thought this could be solved by simply copying the Response so that it's unused, like so:

return new Response(response.body, { headers: response.headers })

That's not working. What am I missing about streaming vs buffering here?

addEventListener('fetch', event => {

  var url = new URL(event.request.url);

  if (url.pathname.startsWith('/blog') || url.pathname === '/blog') {
    if (reqType === 'POST') {
      event.respondWith(handleBlogPost(event, url));
    } else {
      handleBlog(event, url);
    }
  } else {
   event.respondWith(fetch(event.request));
  }
})

async function handleBlog(event, url) {
  var newBlog = "https://foo.com";
  var originUrl = url.toString().replace(
    'https://www.bar.com/blog', newBlog);
  event.respondWith(fetch(originUrl)); 
}

async function handleBlogPost(event, url) {
  try {
    var newBlog = "https://foo.com";
    var srcUrl = "https://www.bar.com/blog";

    const init = {
      method: 'POST',
      headers: event.request.headers,
      body: event.request.body
    };
    var originUrl = url.toString().replace( srcUrl, newBlog );

    const response = await fetch(originUrl, init)

    return new Response(response.body, { headers: response.headers })

  } catch (err) {
    // Display the error stack.
    return new Response(err.stack || err)
  }
}

Upvotes: 5

Views: 5743

Answers (1)

Kenton Varda
Kenton Varda

Reputation: 45171

A few issues here.

First, the error message is about the request body, not the response body.

By default, Request and Response objects received from the network have streaming bodies -- request.body and response.body both have type ReadableStream. When you forward them on, the body streams through -- chunks are received from the sender and forwarded to the eventual recipient without keeping a copy locally. Because no copies are kept, the stream can only be sent once.

The problem in your case, though, is that after streaming the request body to the origin server, the origin responded with a 301, 302, 307, or 308 redirect. These redirects require that the client re-transmit the exact same request to the new URL (unlike a 303 redirect, which directs the client to send a GET request to the new URL). But, Cloudflare Workers didn't keep a copy of the request body, so it can't send it again!

You'll notice this problem doesn't happen when you do fetch(event.request), even if the request is a POST. The reason is that event.request's redirect property is set to "manual", meaning that fetch() will not attempt to follow redirects automatically. Instead, fetch() in this case returns the 3xx redirect response itself and lets the application deal with it. If you return that response on to the client browser, the browser will take care of actually following the redirect.

However, in your worker, it appears fetch() is trying to follow the redirect automatically, and producing an error. The reason is that you didn't set the redirect property when you constructed your Request object:

const init = {
  method: 'POST',
  headers: event.request.headers,
  body: event.request.body
};
// ...
await fetch(originUrl, init)

Since init.redirect wasn't set, fetch() uses the default behavior, which is the same as redirect = "automatic", i.e. fetch() tries to follow redirects. If you want fetch() to use manual redirect behavior, you could add redirect: "manual" to init. However, it looks like what you're really trying to do here is copy the whole request. In that case, you should just pass event.request in place of the init structure:

// Copy all properties from event.request *except* URL.
await fetch(originUrl, event.request);

This works because a Request has all of the fields that fetch()'s second parameter wants.

What if you want automatic redirects?

If you really do want fetch() to follow the redirect automatically, then you need to make sure that the request body is buffered rather than streamed, so that it can be sent twice. To do this, you will need to read the whole body into a string or ArrayBuffer, then use that, like:

const init = {
  method: 'POST',
  headers: event.request.headers,
  // Buffer whole body so that it can be redirected later.
  body: await event.request.arrayBuffer()
};
// ...
await fetch(originUrl, init)

A note on responses

I would have thought this could be solved by simply copying the Response so that it's unused, like so:

return new Response(response.body, { headers: response.headers })

As described above, the error you're seeing is not related to this code, but I wanted to comment on two issues here anyway to help out.

First, this line of code does not copy all properties of the response. For example, you're missing status and statusText. There are also some more-obscure properties that show up in certain situations (e.g. webSocket, a Cloudflare-specific extension to the spec).

Rather than try to list every property, I again recommend simply passing the old Response object itself as the options structure:

new Response(response.body, response)

The second issue is with your comment about copying. This code copies the Response's metadata, but does not copy the body. That is because response.body is a ReadableStream. This code initializes the new Respnose object to contain a reference to the same ReadableStream. Once anything reads from that stream, the stream is consumed for both Response objects.

Usually, this is fine, because usually, you only need one copy of the response. Typically you are just going to send it to the client. However, there are a few unusual cases where you might want to send the response to two different places. One example is when using the Cache API to cache a copy of the response. You could accomplish this by reading the whole Response into memory, like we did with requests above. However, for responses of non-trivial size, that could waste memory and add latency (you would have to wait for the entire response before any of it gets sent to the client).

Instead, what you really want to do in these unusual cases is "tee" the stream so that each chunk that comes in from the network is actually written to two different outputs (like the Unix tee command, which comes from the idea of a T junction in a pipe).

// ONLY use this when there are TWO destinations for the
// response body!
new Response(response.body.tee(), response)

Or, as a shortcut (when you don't need to modify any headers), you can write:

// ONLY use this when there are TWO destinations for the
// response body!
response.clone()

Confusingly, response.clone() does something completely different from new Response(response.body, response). response.clone() tees the response body, but keeps the Headers immutable (if they were immutable on the original). new Response(response.body, response) shares a reference to the same body stream, but clones the headers and makes them mutable. I personally find this pretty confusing, but it's what the Fetch API standard specifies.

Upvotes: 28

Related Questions