mirror of
https://github.com/Novartis/cellxgene-gateway.git
synced 2026-10-04 04:58:11 +08:00
When viewing datasets through the gateway, requests would fail with: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb5 in position 1 The gateway was copying the accept-encoding header from browser requests when proxying to cellxgene backend servers. When accept-encoding is manually set, the Python requests library assumes the caller will handle decompression and leaves response content compressed. The cellxgene server responded with zstd-compressed content (magic bytes 28 b5 2f fd), but the gateway attempted to decode this compressed binary data as UTF-8 text, causing the decode error. Solution: Remove accept-encoding from the copied headers list in cache_entry.py. This allows the requests library to automatically handle compression negotiation and transparently decompress responses (gzip, deflate, brotli, zstd, etc.). This is the standard practice when proxying with requests and maintains all other gateway functionality (URL rewriting, auth, caching, etc.). Tested: - Dataset viewing works with compressed responses - File browser and static assets load correctly - URL rewriting continues to function properly
212 lines
6.7 KiB
Python
212 lines
6.7 KiB
Python
# Copyright 2019 Novartis Institutes for BioMedical Research Inc. Licensed
|
|
# under the Apache License, Version 2.0 (the "License"); you may not use
|
|
# this file except in compliance with the License. You may obtain a copy
|
|
# of the License at http://www.apache.org/licenses/LICENSE-2.0. Unless
|
|
# required by applicable law or agreed to in writing, software distributed
|
|
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
|
|
# OR CONDITIONS OF ANY KIND, either express or implied. See the License for
|
|
# the specific language governing permissions and limitations under the License.
|
|
import datetime
|
|
import logging
|
|
import re
|
|
from enum import Enum
|
|
|
|
import psutil
|
|
from flask import make_response, render_template, request
|
|
from flask.wrappers import Response
|
|
from requests import get, post, put
|
|
|
|
from cellxgene_gateway import env
|
|
from cellxgene_gateway.cellxgene_exception import CellxgeneException
|
|
from cellxgene_gateway.flask_util import querystring
|
|
from cellxgene_gateway.util import current_time_stamp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CacheEntryStatus(Enum):
|
|
loaded = "loaded"
|
|
loading = "loading"
|
|
error = "error"
|
|
terminated = "terminated"
|
|
|
|
|
|
class CacheEntry:
|
|
def __init__(
|
|
self,
|
|
pid,
|
|
key,
|
|
port,
|
|
launchtime,
|
|
timestamp,
|
|
status: CacheEntryStatus,
|
|
message,
|
|
all_output,
|
|
stderr,
|
|
http_status,
|
|
):
|
|
self.pid = pid
|
|
self.key = key
|
|
self.port = port
|
|
self.launchtime = launchtime
|
|
self.timestamp = timestamp
|
|
self.status = status
|
|
self.message = message
|
|
self.all_output = all_output
|
|
self.stderr = stderr
|
|
self.http_status = http_status
|
|
|
|
@classmethod
|
|
def for_key(cls, key, port):
|
|
return cls(
|
|
None,
|
|
key,
|
|
port,
|
|
current_time_stamp(),
|
|
current_time_stamp(),
|
|
CacheEntryStatus.loading,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
|
|
@property
|
|
def source_name(self):
|
|
return self.key.source_name
|
|
|
|
def set_loaded(self, pid):
|
|
self.pid = pid
|
|
self.status = CacheEntryStatus.loaded
|
|
|
|
def set_error(self, message, stderr, http_status):
|
|
self.message = message
|
|
self.stderr = stderr
|
|
self.http_status = http_status
|
|
self.status = CacheEntryStatus.error
|
|
|
|
def append_output(self, output):
|
|
if self.all_output == None:
|
|
self.all_output = output
|
|
else:
|
|
self.all_output += output
|
|
|
|
def terminate(self):
|
|
pid = self.pid
|
|
if pid != None and self.status != CacheEntryStatus.terminated:
|
|
terminated = []
|
|
|
|
def on_terminate(p):
|
|
terminated.append(p.pid)
|
|
|
|
p = psutil.Process(pid)
|
|
children = p.children()
|
|
for child in children:
|
|
child.terminate()
|
|
psutil.wait_procs(children, callback=on_terminate)
|
|
# the parent process may automatically die once its children have --
|
|
try:
|
|
p.terminate()
|
|
psutil.wait_procs([p], callback=on_terminate)
|
|
except psutil.NoSuchProcess:
|
|
pass
|
|
|
|
logger.info(f"terminated {terminated}")
|
|
self.status = CacheEntryStatus.terminated
|
|
|
|
def rewrite_text_content(self, cellxgene_content):
|
|
# for v0.16.0 compatibility, see issue #24
|
|
gateway_content = (
|
|
re.sub(
|
|
'(="|\()/static/',
|
|
f"\\1{self.key.gateway_basepath()}static/",
|
|
cellxgene_content,
|
|
)
|
|
.replace("http://fonts.gstatic.com", "https://fonts.gstatic.com")
|
|
.replace(self.cellxgene_basepath(), self.key.gateway_basepath())
|
|
)
|
|
return gateway_content
|
|
|
|
def cellxgene_basepath(self):
|
|
return f"http://127.0.0.1:{self.port}"
|
|
|
|
def serve_content(self, path):
|
|
gateway_basepath = self.key.gateway_basepath()
|
|
subpath = path[len(self.key.descriptor) :] # noqa: E203
|
|
if len(subpath) == 0:
|
|
r = make_response(f"Redirect to {gateway_basepath}\n", 302)
|
|
r.headers["location"] = gateway_basepath + querystring()
|
|
return r
|
|
elif self.status == CacheEntryStatus.loading:
|
|
launch_time = datetime.datetime.fromtimestamp(self.launchtime)
|
|
return render_template(
|
|
"loading.html",
|
|
launchtime=launch_time,
|
|
all_output=self.all_output,
|
|
)
|
|
|
|
headers = {}
|
|
copy_headers = [
|
|
"accept",
|
|
# "accept-encoding" - removed: let requests library handle compression/decompression
|
|
"accept-language",
|
|
"cache-control",
|
|
"connection",
|
|
"content-length",
|
|
"content-type",
|
|
"cookie",
|
|
"host",
|
|
"origin",
|
|
"pragma",
|
|
"referer",
|
|
"sec-fetch-mode",
|
|
"sec-fetch-site",
|
|
"user-agent",
|
|
]
|
|
for h in copy_headers:
|
|
if h in request.headers:
|
|
headers[h] = request.headers[h]
|
|
|
|
full_path = self.cellxgene_basepath() + subpath + querystring()
|
|
|
|
try:
|
|
cellxgene_response = None
|
|
if request.method in ["GET", "HEAD", "OPTIONS"]:
|
|
cellxgene_response = get(full_path, headers=headers)
|
|
elif request.method == "PUT":
|
|
cellxgene_response = put(
|
|
full_path,
|
|
headers=headers,
|
|
data=request.data,
|
|
)
|
|
elif request.method == "POST":
|
|
cellxgene_response = post(
|
|
full_path,
|
|
headers=headers,
|
|
data=request.data,
|
|
)
|
|
else:
|
|
raise CellxgeneException(f"Unexpected method {request.method}", 400)
|
|
content_type = cellxgene_response.headers["content-type"]
|
|
if "text" in content_type:
|
|
gateway_content = self.rewrite_text_content(
|
|
cellxgene_response.content.decode()
|
|
)
|
|
else:
|
|
gateway_content = cellxgene_response.content
|
|
|
|
resp_headers = {}
|
|
for h in copy_headers:
|
|
if h in cellxgene_response.headers:
|
|
resp_headers[h] = cellxgene_response.headers[h]
|
|
|
|
gateway_response = make_response(
|
|
gateway_content,
|
|
cellxgene_response.status_code,
|
|
resp_headers,
|
|
)
|
|
finally:
|
|
if cellxgene_response is not None:
|
|
cellxgene_response.close()
|
|
return gateway_response
|