long2ice/fastapi-cache

Caching not convertible to JSON eg. image/png response type

Martenz opened this issue · 3 comments

Would it be possible to cache images with byte response type?
If so how? with a custom encoder?

eg with image_tile -> bytes:

`@router.get("/{cog_name}/{z}/{x}/{y}")
async def get_cog_tile(cog_name: str, z: int, x: int, y: int, colormap: str = 'terrain') -> Response:

...

headers = {"Content-Encoding": "identity", "content-type": 'image/png'}
return Response(content=image_tile, headers=headers)
`

Thanks

hozn commented

It is possible to cache raw binary data, though there's a little bit of support missing currently (more below). To get it basically working using your example above, though, you can just provide a custom coder:

class PassthruCoder(Coder):
    @classmethod
    def encode(cls, value: Any) -> Any:
        return value

    @classmethod
    def decode(cls, value: Any) -> Any:
        return value

@router.get("/{cog_name}/{z}/{x}/{y}")
@cache(coder=PassthruCoder)
async def get_cog_tile(cog_name: str, z: int, x: int, y: int, colormap: str = 'terrain') -> Response:
    ...
    headers = {"Content-Encoding": "identity", "content-type": 'image/png'}
    return Response(content=image_tile, headers=headers)

This works with the InMemoryBackend because in the fastapi guts, it will check if an endpoint returns a Response and it will just return that immediately, if so. So, in this case the entire Response gets cached in memory. You likely will need to fix this Coder if this needs to support other backends where it actually needs to get serialized.

One thing missing from this implementation, though, is the cache headers. No headers will be sent down, so the browser will re-request the same files. The content will have been cached, so it won't be recalculated by the server, but it should never have been requested in the first place.

After doing some digging, it appears that the issue is that if an endpoint returns a Response directly, this is not handled by the @cache decorator. The cache decorator will set the headers on the response parameter to your endpoint, rather than to the actual Response that your endpoint returned. Those headers will just be thrown away by fastapi when it sees that you already returned a response.

I could not figure out how to return raw binary data without wrapping it in a Response, as you've done here, as the data is always handed off to jsonable_encoder which really assumes non-binary data.

My workaround was to add add a few lines of code to the cache decorator (I created a local copy of this function):

# File: fastapi_cache/decorator.py
#  Lines ~158-171
            if_none_match = request.headers.get("if-none-match")
            if ret is not None:
                # <hack>
                if isinstance(ret, Response):
                    response = ret
                # </hack>
                if response:
                    response.headers["Cache-Control"] = f"max-age={ttl}"
                    etag = f"W/{hash(ret)}"
                    if if_none_match == etag:
                        response.status_code = 304
                        return response
                    response.headers["ETag"] = etag
                return coder.decode(ret)

I hope that there's a nicer solution, but maybe this helps you in the interim.

Thanks for your time @hozn it worked! I hope this can be merged to the repo, in my case I'm trying to cache with Redis a couple of different binary outputs (images from COG and vector tiles from postgis). This hack seems to work properly maybe it can be an option in the decorator.

Together with the hack mentioned by @hozn , in order to store the Response on a RedisBackend, I did the following:

defined a custom response

# This is for PNG data, thus the explicit charset
class ImageResponse(Response):
    media_type = "image/png"
    charset = "Windows-1252"

defined the Coder as it follows:

class ImageResponseCoder(Coder):
    @classmethod
    def encode(cls, value: ImageResponse) -> bytes:
        return value.body

    @classmethod
    def decode(cls, value: bytes) -> ImageResponse:
        return ImageResponse(content=value, headers={"Content-Encoding": "identity", "content-type": "image/png"})

and in the route I define the coder and response class

@router.get("/png/{cog_name}/{z}/{x}/{y}", response_class=ImageResponse)
@cache(namespace="coglayers", expire=86400, coder=ImageResponseCoder)
async def get_cog_tile(cog_name: str, z: int, x: int, y: int, colormap: str = "terrain"):
    # snip
    return ImageResponse(content=image_tile, headers={"Content-Encoding": "identity", "content-type": "image/png"})