diff --git a/src/murfey/server/api/bootstrap.py b/src/murfey/server/api/bootstrap.py index ec1b935a9..63fd518a1 100644 --- a/src/murfey/server/api/bootstrap.py +++ b/src/murfey/server/api/bootstrap.py @@ -15,11 +15,11 @@ from __future__ import annotations import functools -import io import json import logging import random import re +from io import BytesIO from urllib.parse import quote import packaging.version @@ -111,7 +111,7 @@ def get_bootstrap_instructions(request: Request): ) -@bootstrap.get("/pip.whl", response_class=Response) +@bootstrap.get("/pip.whl", response_class=StreamingResponse) def get_pip_wheel(): """ Return a static version of pip. This does not need to be the newest or best, @@ -125,7 +125,7 @@ def get_pip_wheel(): ) -@bootstrap.get("/murfey.whl", response_class=Response) +@bootstrap.get("/murfey.whl", response_class=StreamingResponse) def get_murfey_wheel(): """ Return a wheel file containing the latest release version of Murfey. We should @@ -133,7 +133,7 @@ def get_murfey_wheel(): murfey.bootstrap is compatible with all relevant versions of Python. This also ignores yanked releases, which again should be fine. """ - full_path_response = requests.get("https://pypi.org/simple/murfey") + full_path_response = http_session.get("https://pypi.org/simple/murfey") wheels = {} for wheel_file in re.findall( @@ -161,7 +161,7 @@ def get_murfey_wheel(): """ -@cygwin.get("/setup-x86_64.exe", response_class=Response) +@cygwin.get("/setup-x86_64.exe", response_class=StreamingResponse) def get_cygwin_setup(): """ Obtain and pass through a Cygwin installer from an official source. @@ -169,11 +169,20 @@ def get_cygwin_setup(): Cygwin distribution that then remains on the client machines. """ filename = "setup-x86_64.exe" - installer = requests.get(f"https://www.cygwin.com/{filename}") - return Response( - content=installer.content, - media_type=installer.headers.get("Content-Type"), - status_code=installer.status_code, + response = http_session.get(f"https://www.cygwin.com/{filename}") + + # Construct headers to return with response + headers: dict[str, str] = { + "Content-Disposition": f"attachment; filename=cygwin-{filename}" + } + if response.headers.get("Content-Length"): + headers["Content-Length"] = response.headers["Content-Length"] + + return StreamingResponse( + content=response.iter_content(chunk_size=8192), + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type"), ) @@ -185,7 +194,7 @@ def find_cygwin_mirror() -> str: lifetime of the server. """ url = "https://www.cygwin.com/mirrors.lst" - mirrors = requests.get(url) + mirrors = http_session.get(url) logger.info( f"Reading mirrors from {url} returned status code {mirrors.status_code} {mirrors.reason}" ) @@ -226,12 +235,17 @@ def find_cygwin_mirror() -> str: return picked_mirror -@cygwin.get("/{request_path:path}", response_class=Response) -def parse_cygwin_request(request_path: str): +@cygwin.get("/{request_path:path}", response_class=StreamingResponse) +def parse_cygwin_request( + request: Request, + request_path: str, +): """ Forward a Cygwin setup request to an official mirror. """ + logger.debug(f"Received request to access {str(request.url)!r}") + # Validate request path if bool(re.fullmatch(r"^[\w\s\.\-\+/]+$", request_path)) is False: raise ValueError(f"{request_path!r} is not a valid request path") @@ -244,11 +258,17 @@ def parse_cygwin_request(request_path: str): ) logger.info(f"Forwarding Cygwin download request to {_sanitise_str(url)}") - cygwin_data = requests.get(url) - return Response( - content=cygwin_data.content, - media_type=cygwin_data.headers.get("Content-Type"), - status_code=cygwin_data.status_code, + response = http_session.get(url) + + headers: dict[str, str] = {} + if response.headers.get("Content-Length"): + headers["Content-Length"] = response.headers["Content-Length"] + + return StreamingResponse( + content=response.iter_content(chunk_size=8192), + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type"), ) @@ -294,14 +314,19 @@ def parse_cygwin_request(request_path: str): ) -@msys2.get("/distrib/{setup_file}", response_class=Response) -def get_msys2_setup(setup_file: str): +@msys2.get("/distrib/{setup_file}", response_class=StreamingResponse) +def get_msys2_setup( + request: Request, + setup_file: str, +): """ Obtain and pass through an MSYS2 installer from an official source. This is used during client bootstrapping, and can download and install the MSYS2 distribution that then remains on the client machines. """ + logger.debug(f"Received request to access {str(request.url)!r}") + # Validate characters in sent path if not bool(re.fullmatch(r"^[\w\.\-]+$", setup_file)): raise ValueError("Unallowed characters present in requested setup file") @@ -312,15 +337,21 @@ def get_msys2_setup(setup_file: str): ): raise ValueError(f"{setup_file!r} is not a valid executable") - installer = requests.get(f"{msys2_url}/distrib/{setup_file}") - return Response( - content=installer.content, - media_type=installer.headers.get("Content-Type"), - status_code=installer.status_code, + response = http_session.get(f"{msys2_url}/distrib/{setup_file}") + + headers: dict[str, str] = {} + if response.headers.get("Content-Length"): + headers["Content-Length"] = response.headers["Content-Length"] + + return StreamingResponse( + content=response.iter_content(chunk_size=8192), + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type"), ) -@msys2.get("", response_class=Response) +@msys2.get("/", response_class=Response) def get_msys2_main_index( request: Request, ) -> Response: @@ -329,25 +360,11 @@ def get_msys2_main_index( from the main MSYS2 repository. """ - def _rewrite_url(match): - """ - Use regular expression matching to rewrite the package URLs and point them - explicitly to this current server. - """ - url = ( - f"{base_path}/{match.group(1)}" - if not str(match.group(1)).startswith("http") - else str(match.group(1)) - ) - return f'' + match.group(2) + "" - - # Get base path to current FastAPI endpoint - base_url = str(request.base_url).strip("/") - path = request.url.path.strip("/") - base_path = f"{base_url}/{path}" + logger.debug(f"Received request to access {str(request.url)!r}") + # Construct URL and get response env_url = f"{msys2_url}" - response = requests.get(env_url) + response = http_session.get(env_url) # Parse and rewrite package index content content: bytes = response.content # Get content in bytes @@ -357,16 +374,10 @@ def _rewrite_url(match): if line.startswith("]*)">([^<]*)', # Regex search criteria - _rewrite_url, # Function to apply search criteria to - line, - ) - content_text_list.append(line_new) - + content_text_list.append(line) # Other URLs don't need to be mirrored else: - pass + continue else: content_text_list.append(line) @@ -380,32 +391,17 @@ def _rewrite_url(match): ) -@msys2.get("/{system}", response_class=Response) +@msys2.get("/{system}/", response_class=Response) def get_msys2_environment_index( - system: str, request: Request, + system: str, ) -> Response: """ Returns a list of all MSYS2 environments for a given system from the main MSYS2 repository. """ - def _rewrite_url(match): - """ - Use regular expression matching to rewrite the package URLs and point them - explicitly to this current server. - """ - url = ( - f"{base_path}/{match.group(1)}" - if not str(match.group(1)).startswith("http") - else str(match.group(1)) - ) - return f'' + match.group(2) + "" - - # Get base path to current FastAPI endpoint - base_url = str(request.base_url).strip("/") - path = request.url.path.strip("/") - base_path = f"{base_url}/{path}" + logger.debug(f"Received request to access {str(request.url)!r}") # Validate provided system; use this endpoint to display 'distrib' folder too if not (any(system in env[0] for env in valid_envs) or system == "distrib"): @@ -413,7 +409,7 @@ def _rewrite_url(match): # Construct URL to main MSYS repo and get response arch_url = f'{msys2_url}/{quote(system, safe="/")}' - response = requests.get(arch_url) + response = http_session.get(arch_url) # Parse and rewrite package index content content: bytes = response.content # Get content in bytes @@ -425,14 +421,7 @@ def _rewrite_url(match): if system == "distrib": if not any(ext in line for ext in msys2_file_ext): continue - - # Rewrite URL to point explicitly to current server - line_new = re.sub( - '^]*)">([^<]*)', # Regex search criteria - _rewrite_url, # Function to apply search criteria to - line, - ) - content_text_list.append(line_new) + content_text_list.append(line) else: content_text_list.append(line) @@ -446,33 +435,18 @@ def _rewrite_url(match): ) -@msys2.get("/{system}/{environment}", response_class=Response) +@msys2.get("/{system}/{environment}/", response_class=Response) def get_msys2_package_index( + request: Request, system: str, environment: str, - request: Request, ) -> Response: """ Obtain a list of all available MSYS2 packages for a given environment from the main MSYS2 repo. """ - def _rewrite_url(match): - """ - Use regular expression matching to rewrite the package URLs and point them - explicitly to this current server. - """ - url = ( - f"{base_path}/{match.group(1)}" - if not str(match.group(1)).startswith("http") - else str(match.group(1)) - ) - return f'' + match.group(2) + "" - - # Get base path to current FastAPI endpoint - base_url = str(request.base_url).strip("/") - path = request.url.path.strip("/") - base_path = f"{base_url}/{path}" + logger.debug(f"Received request to access {str(request.url)!r}") # Validate environment if any(system in env[0] and environment in env[1] for env in valid_envs) is False: @@ -482,35 +456,17 @@ def _rewrite_url(match): package_list_url = ( f'{msys2_url}/{quote(system, safe="/")}/{quote(environment, safe="/")}' ) - response = requests.get(package_list_url) - - # Parse and rewrite package index content - content: bytes = response.content # Get content in bytes - content_text: str = content.decode("latin1") # Convert to strings - content_text_list = [] - for line in content_text.splitlines(): - if line.startswith("]*)">([^<]*)', # Regex search criteria - _rewrite_url, # Function to apply search criteria to - line, - ) - content_text_list.append(line_new) - else: - content_text_list.append(line) - - # Reconstruct conent - content_text_new = str("\n".join(content_text_list)) # Regenerate HTML structure - content_new = content_text_new.encode("latin1") # Convert back to bytes + response = http_session.get(package_list_url) return Response( - content=content_new, + content=response.content, status_code=response.status_code, media_type=response.headers.get("Content-Type"), ) -@msys2.get("/{system}/{environment}/{package}", response_class=Response) +@msys2.get("/{system}/{environment}/{package}", response_class=StreamingResponse) def get_msys2_package_file( + request: Request, system: str, environment: str, package: str, @@ -519,6 +475,8 @@ def get_msys2_package_file( Obtain and pass through a specific download for an MSYS2 package. """ + logger.debug(f"Received request to access {str(request.url)!r}") + # Validate environment if any(system in env[0] and environment in env[1] for env in valid_envs) is False: raise ValueError(f"'{system}/{environment}' is not a valid msys2 environment") @@ -535,16 +493,20 @@ def get_msys2_package_file( # Construct URL to main MSYS repo and get response package_url = f'{msys2_url}/{quote(system, safe="/")}/{quote(environment, safe="/")}/{quote(package, safe="/")}' - package_file = requests.get(package_url) + response = http_session.get(package_url) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code) - if package_file.status_code == 200: - return Response( - content=package_file.content, - media_type=package_file.headers.get("Content-Type"), - status_code=package_file.status_code, - ) - else: - raise HTTPException(status_code=package_file.status_code) + headers: dict[str, str] = {} + if response.headers.get("Content-Length"): + headers["Content-Length"] = response.headers["Content-Length"] + + return StreamingResponse( + content=response.iter_content(chunk_size=8192), + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type"), + ) """ @@ -569,11 +531,24 @@ def get_cargo_config(request: Request): and its default path on Windows is %USERPROFILE%\\.cargo\\config.toml. """ - # Construct URL to our mirror of the Rust sparse index - index_mirror = ( - f"{request.url.scheme}://{request.url.netloc}/{rust.prefix.strip('/')}/index/" + # Check if this is a forwarded request from somewhere else and construct netloc + netloc = ( + f"{request.headers['X-Forwarded-Host']}:{request.headers['X-Forwarded-Port']}" + if request.headers.get("X-Forwarded-Host") + and request.headers.get("X-Forwarded-Port") + else request.url.netloc ) + # Find path to Rust router using current URL Path + path_to_router = request.url.path.removesuffix("/cargo/config.toml") + + # Construct base URL for subsequent use + base_url = f"{request.url.scheme}://{netloc}{path_to_router}" + logger.debug(f"Base URL to Rust sub-router determined to be {base_url}") + + # Construct URL to our mirror of the Rust sparse index + index_url = f"{base_url}/index/" + # Construct and return the config.toml file config_data = "\n".join( [ @@ -581,22 +556,28 @@ def get_cargo_config(request: Request): 'replace-with = "murfey-crates"', # Redirect to our mirror "", "[source.murfey-crates]", - f'registry = "sparse+{index_mirror}"', # sparse+ to use sparse protocol + f'registry = "sparse+{index_url}"', # sparse+ to use sparse protocol "", "[registries.murfey-crates]", - f'index = "sparse+{index_mirror}"', # sparse+ to use sparse protocol + f'index = "sparse+{index_url}"', # sparse+ to use sparse protocol "", "[registry]", 'default = "murfey-crates"', # Redirect to our mirror "", ] ) - config_bytes = io.BytesIO(config_data.encode("utf-8")) + config_bytes = config_data.encode("utf-8") + + headers: dict[str, str] = { + "Content-Disposition": "attachment; filename=config.toml", + "Content-Length": str(len(config_bytes)), + } return StreamingResponse( - config_bytes, + BytesIO(config_bytes), + status_code=200, + headers=headers, media_type="application/toml+json", - headers={"Content-Disposition": "attachment; filename=config.toml"}, ) @@ -605,7 +586,7 @@ def get_cargo_config(request: Request): """ -@rust.get("/index") +@rust.get("/index/", response_class=Response) def get_index_page(): """ Returns a mirror of the https://index.crates.io landing page. @@ -616,8 +597,8 @@ def get_index_page(): raise HTTPException(status_code=response.status_code) return Response( content=response.content, - media_type=response.headers.get("Content-Type"), status_code=response.status_code, + media_type=response.headers.get("Content-Type"), ) @@ -632,8 +613,20 @@ def get_index_config(request: Request): used by Cargo when searching for and downloading packages. """ - # Construct URL for Rust router - base_url = f"{request.url.scheme}://{request.url.netloc}" + rust.prefix + # Check if this is a forwarded request from somewhere else and construct netloc + netloc = ( + f"{request.headers['X-Forwarded-Host']}:{request.headers['X-Forwarded-Port']}" + if request.headers.get("X-Forwarded-Host") + and request.headers.get("X-Forwarded-Port") + else request.url.netloc + ) + + # Find path to Rust router using current URL Path + path_to_router = request.url.path.removesuffix("/index/config.json") + + # Construct base URL for subsequent use + base_url = f"{request.url.scheme}://{netloc}{path_to_router}" + logger.debug(f"Base URL to Rust sub-router determined to be {base_url}") # Construct config file with the necessary endpoints config = { @@ -643,12 +636,18 @@ def get_index_config(request: Request): # Save it as a JSON and return it as part of the response json_data = json.dumps(config, indent=4) - json_bytes = io.BytesIO(json_data.encode("utf-8")) + json_bytes = json_data.encode("utf-8") + + headers: dict[str, str] = { + "Content-Disposition": "attachment; filename=config.json", + "Content-Length": str(len(json_bytes)), + } return StreamingResponse( - json_bytes, + BytesIO(json_bytes), + status_code=200, + headers=headers, media_type="application/json", - headers={"Content-Disposition": "attachment; filename=config.json"}, ) @@ -669,7 +668,7 @@ def get_index_package_metadata( c1 = 3, and c2 is the first character of the package. """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate path to the package metadata if any(not re.fullmatch(r"[\w\-]{1,2}", char) for char in (c1, c2)): @@ -690,7 +689,8 @@ def get_index_package_metadata( raise HTTPException(status_code=response.status_code) return StreamingResponse( response.iter_content(chunk_size=8192), - media_type="application/json", + status_code=response.status_code, + media_type=response.headers.get("Content-Type"), ) @@ -706,7 +706,7 @@ def get_index_package_metadata_for_short_package_names( /1/{package} or /2/{package}. """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate path to crate if n not in ("1", "2"): @@ -721,7 +721,8 @@ def get_index_package_metadata_for_short_package_names( raise HTTPException(status_code=response.status_code) return StreamingResponse( response.iter_content(chunk_size=8192), - media_type="application/json", + status_code=response.status_code, + media_type=response.headers.get("Content-Type"), ) @@ -736,7 +737,7 @@ def get_rust_package_download( sparse index registry. """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate package and version if not re.fullmatch(r"[\w\-]+", package): @@ -750,16 +751,16 @@ def get_rust_package_download( file_name = f"{package}-{version}.crate" # Construct file name to save package as if response.status_code != 200: raise HTTPException(status_code=response.status_code) + + headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + if response.headers.get("Content-Length"): + headers["Content-Length"] = response.headers["Content-Length"] + return StreamingResponse( content=response.iter_content(chunk_size=8192), - headers={ - "Content-Disposition": f'attachment; filename="{file_name}"', - "Content-Type": response.headers.get( - "Content-Type", "application/octet-stream" - ), - "Content-Length": response.headers.get("Content-Length"), - }, status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type", "application/octet-stream"), ) @@ -780,7 +781,7 @@ def get_rust_api_package_index( in a JSON object based on the search query given. """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate package name if package and not re.fullmatch(r"[\w\-]+", package): @@ -816,7 +817,7 @@ def get_rust_api_package_info( to other types of metadata. """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate package name if not re.fullmatch(r"[\w\-]+", package): @@ -837,10 +838,10 @@ def get_rust_api_package_versions( ): """ Displays all available versions for a particular Rust package, along with download - links for said versions. + links for said versions, as a JSON object. """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate crate name if not re.fullmatch(r"[\w\-]+", package): @@ -866,7 +867,7 @@ def get_rust_api_package_download( Obtain and pass through a crate download request for a specific Rust package. """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate package name if not re.fullmatch(r"[\w\-]+", package): @@ -883,16 +884,16 @@ def get_rust_api_package_download( file_name = f"{package}-{version}.crate" # Construct crate name to save as if response.status_code != 200: raise HTTPException(status_code=response.status_code) + + headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + if response.headers.get("Content-Length"): + headers["Content-Length"] = response.headers["Content-Length"] + return StreamingResponse( content=response.iter_content(chunk_size=8192), - headers={ - "Content-Disposition": f'attachment; filename="{file_name}"', - "Content-Type": response.headers.get( - "Content-Type", "application/octet-stream" - ), - "Content-Length": response.headers.get("Content-Length"), - }, status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type", "application/octet-stream"), ) @@ -915,7 +916,7 @@ def get_rust_package_crate( (e.g. https://static.crates.io/crates/anyhow will fail) """ - logger.debug(f"Received request to access {str(request.url)}") + logger.debug(f"Received request to access {str(request.url)!r}") # Validate crate and package names if not re.fullmatch(r"[\w\-]+", package): @@ -931,16 +932,16 @@ def get_rust_package_crate( response = http_session.get(url) if response.status_code != 200: raise HTTPException(status_code=response.status_code) + + headers = {"Content-Disposition": f'attachment; filename="{crate}"'} + if response.headers.get("Content-Length"): + headers["Content-Length"] = response.headers["Content-Length"] + return StreamingResponse( - content=response.iter_content(), - headers={ - "Content-Disposition": f'attachment; filename="{crate}"', - "Content-Type": response.headers.get( - "Content-Type", "application/octet-stream" - ), - "Content-Length": response.headers.get("Content-Length"), - }, + content=response.iter_content(chunk_size=8192), status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type", "application/octet-stream"), ) @@ -959,20 +960,18 @@ def _get_full_pypi_path_response(package: str) -> requests.Response: # Check that a package name follows PEP 503 naming conventions, containing only # alphanumerics (including underscores; \w), dashes (\-), and periods (\.) - if re.match(r"^[\w\-\.]+$", package) is not None: - # Sanitise and normalise package name according to PEP 503 - package_clean = quote(re.sub(r"[-_.]+", "-", package.lower()), safe="/") + if not re.fullmatch(r"[\w\-\.]+", package): + raise ValueError(f"{package!r} is not a valid package name") - # Get HTTP response - url = f"https://pypi.org/simple/{package_clean}" - response = requests.get(url) + # Sanitise and normalise package name according to PEP 503 + package_clean = quote(re.sub(r"[-_.]+", "-", package.lower()), safe="/") - if response.status_code == 200: - return response - else: - raise HTTPException(status_code=response.status_code) - else: - raise ValueError(f"{package!r} is not a valid package name") + # Get HTTP response + url = f"https://pypi.org/simple/{package_clean}" + response = http_session.get(url) + if response.status_code != 200: + raise HTTPException(status_code=response.status_code) + return response @pypi.get("/", response_class=Response) @@ -981,17 +980,16 @@ def get_pypi_index(): Obtain list of all PyPI packages via the simple API (PEP 503). """ - index = requests.get("https://pypi.org/simple/") - + response = http_session.get("https://pypi.org/simple/") return Response( - content=index.content, - status_code=index.status_code, - media_type=index.headers.get("Content-Type"), + content=response.content, + status_code=response.status_code, + media_type=response.headers.get("Content-Type"), ) @pypi.get("/{package}/", response_class=Response) -def get_pypi_package_downloads_list(package: str) -> Response: +def get_pypi_package_downloads_list(request: Request, package: str) -> Response: """ Obtain list of all package downloads from PyPI via the simple API (PEP 503), and rewrite all download URLs to point to this server, under the current directory. @@ -1006,6 +1004,8 @@ def _rewrite_pypi_url(match): url = match.group(3) return '" + match.group(3) + "" + logger.debug(f"Received request to access {str(request.url)!r}") + # Validate package and URL full_path_response = _get_full_pypi_path_response(package) @@ -1037,13 +1037,17 @@ def _rewrite_pypi_url(match): return Response( content=content_new, - media_type=full_path_response.headers.get("Content-Type"), status_code=full_path_response.status_code, + media_type=full_path_response.headers.get("Content-Type"), ) -@pypi.get("/{package}/{filename}", response_class=Response) -def get_pypi_file(package: str, filename: str): +@pypi.get("/{package}/{filename}", response_class=StreamingResponse) +def get_pypi_file( + request: Request, + package: str, + filename: str, +): """ Obtain and pass through a specific download for a PyPI package. """ @@ -1081,6 +1085,8 @@ def _expose_wheel_metadata(response_bytes: bytes) -> bytes: return response_bytes_new + logger.debug(f"Received request to access {str(request.url)!r}") + # Validate package and URL full_path_response = _get_full_pypi_path_response(package) @@ -1098,12 +1104,17 @@ def _expose_wheel_metadata(response_bytes: bytes) -> bytes: if not selected_package_link: raise HTTPException(status_code=404, detail="File not found for package") original_url = selected_package_link.group(1) - original_file = requests.get(original_url) + response = http_session.get(original_url) - return Response( - content=original_file.content, - media_type=original_file.headers.get("Content-Type"), - status_code=original_file.status_code, + # Construct headers to return with response + headers: dict[str, str] = {} + if response.headers.get("Content-Length"): + headers["Content-Lengh"] = response.headers["Content-Length"] + return StreamingResponse( + content=response.iter_content(chunk_size=8192), + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("Content-Type"), )