"authorization" header causes "cf-cache-status: BYPASS" regardless of "cacheEverything"

I’d like to cache a response from authentication-required server.

However, setting “authorization” header interferes cache: the result will always be “BYPASS” (without the header, it can be “MISS” or “HIT”). Setting cacheEverything option does not solve the issue.

Here is the simple example.

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event))
})

async function handleRequest(event) {
  const headers = new Headers();

  // Comment out this line solves the issue.
  headers.set("authorization", "Bearer aaa");

  const options = {
    method: "GET",
    headers,
    cf: {
      cacheTtl: 10,
      cacheEverything: true,
    }
  };

  let response;

  // The first request should be "MISS"
  response = await fetch("http://example.com", options);
  console.log(response.headers.get('cf-cache-status'));

  // The first request should be "HIT"
  response = await fetch("http://example.com", options);
  console.log(response.headers.get('cf-cache-status'));

  return response;
}

With this code, the result printed to console is

BYPASS
BYPASS

while the expected one is

MISS
HIT

How to solve the issue? Do I miss any options?

NOTE:

1 Like

Actually, this is the expected behavior.

If the Request to your origin includes an Authorization header, cache it will be bypassed.

My recommendation is to use the Cache API instead.

Note that the Cache API does not returns a cf-cache-status header. Also Cache API is not available in preview.

In the example below, I am adding 2 lines return new Response (...) just to test if the Cache API is working in the production environment.

async function handleRequest(event) {
	
	const {request} = event;
	const cacheUrl = new URL(request.url);
	const cacheKey = new Request(cacheUrl.toString(), request);
	const cache = caches.default;
	let response = await cache.match(cacheKey);
	
	if (!response)
	{
		const options = {
			method: 'GET',
			headers: {
				Authorization: `Bearer aaa`
			}
		};

		// If not in cache, get it from origin
		response = await fetch('http://example.com', options);
		
		// Must use Response constructor to inherit all of response's fields
		response = new Response(response.body, response);
		response.headers.append('Cache-Control', 's-maxage=10');
		event.waitUntil(cache.put(cacheKey, response.clone()));
		
		
		//important: the line below is used only to debug the code, delete it before publishing
		return new Response('from origin');
	}
	else
	{
		//important: the line below is used only to debug the code, delete it before publishing
		return new Response('from cache');
	}

	return response;
}
1 Like

Thank you for your answer. Yes, I’ve confirmed that Cache API can cache the response.

However, it seems not cache responses larger than 128MB, while the document says that the max object size is 512MB. https://developers.cloudflare.com/workers/platform/limits#cache-api

I suspect that the limit is caused by worker memory, since Fetch API + cf option can cache those responses (if no ‘Authorization’ header given).

Is there any way to cache files larger than 128MB which require authorization?

Sorry it was incorrect that I wrote “it seems not cache responses larger than 128MB”.

With this code a 150MB file can be cached, but a 400MB file cannot be cached.

  async function handleRequest(event) {
    const request = event.request;

    const cache = caches.default;
    const cached = await cache.match(request);
    if (cached) {
      console.log('Cache HIT');
      return cached;
    }
    console.log('Cache MISS');

    let response = await fetch('http://example.com/400MB.dat', { method: 'GET' });
    if (response.status >= 300) {
      return response;
    }

    response = new Response(response.body, response);
    response.headers.set("Cache-Control", 's-maxage=10');
    event.waitUntil(cache.put(request, response.clone()));
    
    return response;
  }
  
  addEventListener("fetch", (event) => {
    event.respondWith(handleRequest(event).catch(console.error));
  });

Does Cache API have some undocumented limitations about the file size?

It should be 512MB.

Check the historical CPU time consumed by your worker, since some of the files are large, maybe you are running out of CPU time.

  • Free plan 10ms
  • Bundled plan 50ms

If that’s your case, consider upgrading your plan to Workers Unbound, a service with unlocked CPU limits.

Thank you for the information. I’ve signed up for the Workers Unbound.

Hmm… I’ve tested again with the same code.

I accessed 400MB.dat over and over again, and all of them are not cached. In the console, no requests excess 50ms (I’m using bundled plan).

Are there any other reasons…?

You’re running out of the allotted RAM in the worker, since you’re loading the file contents into a variable. Since you’re not parsing the contents, the CPU-time is still very low, but the variable content just grows until the RAM is exhausted, you’ll need to stream the contents as chunks directly to the cache.

I think it should default to streaming if you remove the await.

Thank you for the answer. With TransformStream , 400mb.dat became to be cached!

However, it causes another problem:

If the request has Range header, the response should be 206 Partial Content. However, the cache-hit response is always 200 OK if the original cache was created by cache.put() with stream response.

Here is the sample code.

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event))
})

async function handleRequest(event) {
  const request = event.request;

  const cache = caches.default;
  const cached = await cache.match(request);
  if (cached) {
    console.log('Cache HIT');
    return cached; // **HERE** Always returns 200 even if the request has "Range" header.
  }
  console.log('Cache MISS');

  const url = new URL(request.url);
  url.hostname = 'origin.example.com';

  const newHeaders = new Headers();
  if (request.headers.has('range')) {
    newHeaders.set('range', request.headers.get('range'));
  }
  response = await fetch(url, { method: 'GET', headers: newHeaders });

  if (response.status >= 300) {
    return response;
  }

  if (response.status === 206) {
    // Re-fetch the entire content from origin, since cache.put() cannot cache 206 response.
    event.waitUntil(
      fetch(url, { method: 'GET' })
        .then(res => {
          const { readable, writable } = new TransformStream();
          res.body.pipeTo(writable);
          const cacheRes = new Response(readable, res);
          cacheRes.headers.set('Cache-Control', 's-maxage=10');
          return cache.put(request, cacheRes);
        })
    );
  } else {
    let { readable, writable } = new TransformStream();
    response.body.pipeTo(writable);
    response = new Response(readable, response);
    response.headers.set('Cache-Control', 's-maxage=10');
    event.waitUntil(cache.put(request, response.clone()));
  }

  return response;
}

Then execute range request two times.

$ curl -i -r 0-1 workertest.mydomain.example/400mb.dat
HTTP/1.1 206 Partial Content
Content-Length: 2
Accept-Ranges: bytes
Content-Range: bytes 0-1/157286400
CF-Cache-Status: DYNAMIC

$ curl -i -r 0-1 workertest.mydomain.example/400mb.dat
HTTP/1.1 200 OK
Transfer-Encoding: chunked
CF-Cache-Status: HIT

The first one is correct, but the second one ignores range header. This issue won’t occur if the cache is created from non-stream response.

Is this a runtime bug?

(Please let me know if I should open a new topic.)

Not sure if you need to add a start and end byte range for the stream so it knows when it’s completed, I think I saw a thread similar to that - I think that was saving byte data in a KV though and the solution was to add the start and end as metadata.

Note that curl -i uses HEAD method.

According to the documentation, the method of the Request passed to cache.get and cache.put as the cacheKey should be GET.

const {url, headers, redirect} = event.request;

const request = new Request(url, {
	body,
	headers,
	method: 'GET',
	redirect
})

By the way, I found this solved post from 2019 that uses streams and the Cache API together. I hope it works for you.

Note that curl -i uses HEAD method.

My sample uses -i (--include), not -I (--head) so it uses GET, isn’t it?

By the way, I found this solved post from 2019 that uses streams and the Cache API together. I hope it works for you.

Thank you for the information! I’ll try it.

By the way, I found this solved post from 2019 that uses streams and the Cache API together. I hope it works for you.

It does not mentioned about Range request.

And the “resolved” code in the post extract the response body on memory so it causes memory issue again (I’ve just tried it and a 400MB file could not be cached) .

const responseForCache = new Response(await originResponse.clone().text(), Object.assign({}, originResponse, { headers: cacheHeaders }));

I think it is a cache.match API issue, since it seems ignore the Range request header (if the response is created from stream response) while the document says

Our implementation of the Cache API respects the following HTTP headers on the request passed to match():

Range

  • Results in a 206 response if a matching response is found. Your Cloudflare cache always respects range requests, even if an Accept-Ranges header is on the response.

@baba, you are right. For a moment I thought it was the opposite.

Try this other workaround and tell me if it works for you.

const handleRequest = async (event) => {
	
	const {request} = event;
	const cacheUrl = new URL(request.url);
	const cacheKey = new Request(cacheUrl.toString(), request);
	const cache = caches.default;
	let response = await cache.match(cacheKey);
	
	if (!response)
	{
		const init = {
			method: 'GET',
			headers: {
				Authorization: `Bearer aaa`
			}
		};

		//196.98 MB
		response = await fetch('http://eforexcel.com/wp/wp-content/uploads/2020/09/5m-Sales-Records.zip', init);
		
		let { readable, writable } = new TransformStream()	
		response.body.pipeTo(writable)
		
		//The output of tee() looks like this [ReadableStream, ReadableStream]. 
		//Each of those streams receives the same incoming data.
		const branches = readable.tee()

		/*
		To prevent running out of CPU time and RAM I think it is better not to read the Response object and:
			1. declare the headers manually (probably based on file extension)
			2. force 200 status (cache.put does not accepts 206 Partial Content)
			
		Important: cache.put will store the Response the moment the stream is completed. That's the way tee() works.
		*/

		event.waitUntil(cache.put(cacheKey, new Response(branches[0], {
			status: 200,
			headers: {
				'Cache-Control': 's-maxage=30'
			}
		})));
		
		return new Response(branches[1])
	}
	else
	{
		return new Response('from cache')
		//return response
	}
}

The code simultaneously returns and puts the response in cache, so you need to wait until the file is downloaded to be able to serve it later from cache.

Thank you for the suggestion.

But the workaround didn’t work to me. As I tested, the matched cache status is always 200 rather than 206, even if Range header is added to the request with curl -r 0-1.

Note I modified the last of the code like:

else
{
    return new Response('from cache' + response.status)
}

Additionally, I cannot understand about the meaning of it:

  1. force 200 status (cache.put does not accepts 206 Partial Content)

Since the response is created from below, the response will never be 206 because no Range header is set.

const init = {
    method: 'GET',
    headers: {
        Authorization: `Bearer aaa`
    }
};
//196.98 MB
response = await fetch('http://eforexcel.com/wp/wp-content/uploads/2020/09/5m-Sales-Records.zip', init);

What I was trying to tell you is that cache.match actually respects the Range header.

If fetch to origin returns a 206 Partial Content and you put this chunk in cache forcing the status as 200, you´ll be able to retrieve this chunk later using cache.match. You´ll need to fetch the origin server using a Request + the cacheKey.

response = await fetch(new Request('https://example.com', cacheKey));

If the origin server ignores the Range Headers, you could resolve the response as an arrayBuffer, but this will use a lot of CPU.

const handleRequest = async (event) => {
	
	const {request} = event;
	const requestRange = request.headers.get('Range');
	const cacheUrl = new URL(request.url);
	const cacheKey = new Request(cacheUrl.toString(), {
		...request,
		method: 'GET'
	});
	
	let responseHeaders = {};
	
	const cache = caches.default;
	let response = await cache.match(cacheKey);
	
	if (!response)
	{
		const init = {
			method: 'GET',
			headers: {
				Authorization: `Bearer aaa`
			}
		};
		
		/*
			196.98 MB
			http://eforexcel.com/wp/wp-content/uploads/2020/09/5m-Sales-Records.zip
		*/
		response = await fetch(new Request('https://example.com', cacheKey));
		
		let { readable, writable } = new TransformStream()	
		response.body.pipeTo(writable)
		
		//The output of tee() looks like this [ReadableStream, ReadableStream]. 
		//Each of those streams receives the same incoming data.
		const branches = readable.tee();
		
		responseHeaders['Cache-Control'] = 's-maxage=10';
		
		Array.from(response.headers).forEach(i => {
			responseHeaders[i[0]] = i[1];
		});

		//force 200 status even if the Response from origin is a 206 Partial Content
		event.waitUntil(cache.put(cacheKey, new Response(branches[0], {
			status: 200,
			headers: responseHeaders
		})));
		
		return new Response(branches[1], response);
	}
	else
	{
		Array.from(response.headers).forEach(i => {
			responseHeaders[i[0]] = i[1];
		});
		
		/*
			//If the origin server respects the Range Header you could return the Response in 4 lines:!
			
			return new Response(response.body, {
				status: (requestRange && response.headers.get('Content-Range')) ? 206 : response.status,
				headers: responseHeaders
			});

			//ending the code here
		*/
		
		if(requestRange)
		{
			if(!response.headers.get('Content-Range'))
			{
				//this part will execute only if the origin server does not respects Range header
				//use arrayBuffer to get Content-Range
				//this part will exaust the CPU time reading large files
				
				return await response.arrayBuffer().then(arrBuff => {
					
					const bytes = /^bytes\=(\d+)\-(\d+)?$/g.exec(requestRange);

					if (bytes)
					{
						const start = Number(bytes[1]);
						const end = Number(bytes[2]) || arrBuff.byteLength - 1;
						responseHeaders['Content-Range'] = `bytes ${start}-${end}/${arrBuff.byteLength}`;
						
						return new Response(arrBuff.slice(start, end + 1), 
						{
							status: 206,
							statusText: 'Partial Content',
							headers: responseHeaders
						});		
					}
					else
					{
						return new Response('Range Not Satisfiable', {
							status: 416,
							statusText: 'Range Not Satisfiable',
							headers: {
								'Content-Range': `*/${arrBuff.byteLength}`
							}
						});				
					}
				});				
			}
			else
			{				
				/*
					This part is executed when the origin server respects the Range header and the content is stored in cache as partial content.
					
					Since cache.match returns 200 status even if the Response includes a Content-Range header. Status 206 should be forced.
				*/
				
				return new Response(response.body, {
					status: 206,
					headers: responseHeaders,
				});
			}
		}
		else
		{
			return response;
		}
	}
}

Thank you for explanation.

If fetch to origin returns a 206 Partial Content and you put this chunk in cache forcing the status as 200, you´ll be able to retrieve this chunk later using cache.match .

If I cache with this method, the cached data is just a part of the content corresponding Range header in the first request. Since Range request is typically used for mp4 video (by HTML <video> element), what position is requested is depends on browsers and there are countless variations. Thus, caching each part of content independently is not a good idea. What I want to do is once caching the entire content and every subsequent Range requests is retrieved from cache.match.

You´ll need to fetch the origin server using a Request + the cacheKey.

Yes, the issue can be worked around by using cacheKey (fetch + cf option). However, it’s only for enterprise plan. I’m currently using business plan.

I’ve opened support ticket about this cache API bug, referring this topic, but I haven’t got a formal answer yet. Cloudflare staff, could you confirm it?

Hi @baba,

I think it is a cache.match API issue, since it seems ignore the Range request header (if the response is created from stream response) while the document says

For cache.match() to work with a range request, the response stored in the cache must have a Content-Length header. Pumping the response body through a TransformStream forces the response to have a Transfer-Encoding: chunked header instead. This is why you’re receiving 200 responses instead of 206 responses. We’ll update our documentation: Clarify cache.match() range request requirements by harrishancock · Pull Request #1517 · cloudflare/cloudflare-docs · GitHub

With this code a 150MB file can be cached, but a 400MB file cannot be cached.

Unfortunately, it appears that cache.put()ing a response with a Content-Length header expresses a bug in which we enforce a different, lower limit intended only to apply to request bodies (internally, the response to be cached is transmitted via a request body). The request upload size limit for the business plan is 200MB, which is why you were able to cache a 150MB file, but not a 400MB file. This was not intended behavior, and we have a fix in flight to avoid applying this other limit to Cache API usage.

Harris

Thank you for your reply.

For cache.match() to work with a range request, the response stored in the cache must have a Content-Length header. Pumping the response body through a TransformStream forces the response to have a Transfer-Encoding: chunked header instead. This is why you’re receiving 200 responses instead of 206 responses. We’ll update our documentation: Clarify cache.match() range request requirements by harrishancock · Pull Request #1517 · cloudflare/cloudflare-docs · GitHub

OK, I understand.

This was not intended behavior, and we have a fix in flight to avoid applying this other limit to Cache API usage.

I see, I’m looking forward to the fix.

Thanks.