Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/backend/base/langflow/api/v1/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@
)
from langflow.services.event_manager import create_webhook_event_manager, webhook_event_manager
from langflow.services.telemetry.schema import RunPayload
from langflow.utils.compression import compress_response
from langflow.utils.version import get_version_info

if TYPE_CHECKING:
Expand Down Expand Up @@ -275,7 +274,7 @@ async def get_all(
all_types = translate_component_dict(visible_types_en, locale) if locale != "en" else visible_types_en

component_display_names = build_component_display_names(visible_types_en)
return compress_response({**all_types, "component_display_names": component_display_names})
return JSONResponse(content=jsonable_encoder({**all_types, "component_display_names": component_display_names}))

except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
Expand Down
18 changes: 8 additions & 10 deletions src/backend/base/langflow/api/v1/flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import orjson
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi_pagination import Page, Params
from fastapi_pagination.ext.sqlmodel import apaginate
from lfx.log.logger import logger
Expand Down Expand Up @@ -106,7 +107,6 @@
from langflow.services.database.models.user.model import User, UserRead
from langflow.services.deps import get_catalog_policy_service, get_settings_service, get_storage_service
from langflow.services.storage.service import StorageService
from langflow.utils.compression import compress_response
from langflow.utils.i18n import translate_flow_notes, translate_starter_flows

# Re-export helpers so existing ``from langflow.api.v1.flows import ...`` still works.
Expand Down Expand Up @@ -447,13 +447,13 @@ async def read_flows(
act=FlowAction.READ,
)
if header_flows:
# Convert to FlowHeader objects and compress the response
# Convert to FlowHeader objects
flow_headers = [FlowHeader.model_validate(flow, from_attributes=True) for flow in flows]
return compress_response(flow_headers)
return JSONResponse(content=jsonable_encoder(flow_headers))

# Convert to FlowRead while session is still active to avoid detached instance errors
flow_reads = [FlowRead.model_validate(flow, from_attributes=True) for flow in flows]
return compress_response(flow_reads)
return JSONResponse(content=jsonable_encoder(flow_reads))

stmt = stmt.where(Flow.folder_id == folder_id)

Expand Down Expand Up @@ -741,8 +741,6 @@ async def upsert_flow(

Returns 201 for creation, 200 for update. Returns 404 if owned by another user.
"""
from fastapi.responses import JSONResponse

# Read once, outside the retry loop: a rollback between attempts expires the ORM User
# and a later attribute read would lazy-load outside the greenlet.
writer_id = current_user.id
Expand Down Expand Up @@ -1381,7 +1379,7 @@ async def read_basic_examples(
blocked_template_keys=catalog_policy_snapshot.blocked_template_keys,
)
)
return compress_response(visible_flows)
return JSONResponse(content=jsonable_encoder(visible_flows))

async with _starter_flows_lock:
# Double-check inside lock to prevent thundering herd
Expand All @@ -1395,7 +1393,7 @@ async def read_basic_examples(
blocked_template_keys=catalog_policy_snapshot.blocked_template_keys,
)
)
return compress_response(visible_flows)
return JSONResponse(content=jsonable_encoder(visible_flows))

# Ensure raw DB data is cached
cached_flow_reads = _starter_flows_cache.get("starter_flows")
Expand All @@ -1408,7 +1406,7 @@ async def read_basic_examples(
).first()

if not starter_folder:
return compress_response([])
return JSONResponse(content=jsonable_encoder([]))

all_starter_folder_flows = (
await session.exec(select(Flow).where(Flow.folder_id == starter_folder.id))
Expand Down Expand Up @@ -1449,7 +1447,7 @@ async def read_basic_examples(
blocked_template_keys=catalog_policy_snapshot.blocked_template_keys,
)
)
return compress_response(visible_flows)
return JSONResponse(content=jsonable_encoder(visible_flows))


@router.post("/expand/", status_code=200, dependencies=[Depends(get_current_active_user)], include_in_schema=False)
Expand Down
17 changes: 17 additions & 0 deletions src/backend/base/langflow/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from pydantic import PydanticDeprecatedSince20
from pydantic_core import PydanticSerializationError
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.middleware.gzip import DEFAULT_EXCLUDED_CONTENT_TYPES, GZipMiddleware

from langflow.api import log_router
from langflow.api.health_check_router import health_check_router
Expand Down Expand Up @@ -77,6 +78,16 @@
_tasks: list[asyncio.Task] = []

MAX_PORT = 65535
GZIP_MINIMUM_SIZE = 1000
GZIP_COMPRESS_LEVEL = 6
GZIP_ALREADY_COMPRESSED_CONTENT_TYPES = (
"application/octet-stream",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
)
GZIP_EXCLUDED_CONTENT_TYPES = (*DEFAULT_EXCLUDED_CONTENT_TYPES, *GZIP_ALREADY_COMPRESSED_CONTENT_TYPES)

# Enterprise lifespan hook registry. Enterprise plugins append async callables
# at app-construction time (plugin registration runs before the lifespan
Expand Down Expand Up @@ -861,6 +872,12 @@ def create_app():
lifespan=lifespan,
root_path=settings.root_path,
)
app.add_middleware(
GZipMiddleware,
minimum_size=GZIP_MINIMUM_SIZE,
compresslevel=GZIP_COMPRESS_LEVEL,
exclude_content_types=GZIP_EXCLUDED_CONTENT_TYPES,
)
app.add_middleware(
ContentSizeLimitMiddleware,
)
Expand Down
19 changes: 0 additions & 19 deletions src/backend/base/langflow/utils/compression.py

This file was deleted.

1 change: 1 addition & 0 deletions src/backend/base/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ maintainers = [
dependencies = [
"lfx~=1.13.0",
"fastapi>=0.139.0,<1.0.0",
"starlette>=1.5.0",
"slowapi>=0.1.9,<1.0.0",
"httpx[http2]>=0.27,<1.0.0",
"h2>=4.4.1",
Expand Down
135 changes: 135 additions & 0 deletions src/backend/tests/unit/api/test_response_compression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import json

import pytest
from fastapi import FastAPI, Response
from httpx import ASGITransport, AsyncClient
from langflow.main import GZIP_COMPRESS_LEVEL, GZIP_EXCLUDED_CONTENT_TYPES, GZIP_MINIMUM_SIZE
from starlette.middleware.gzip import GZipMiddleware

LARGE_NODE_COUNT = 40


def _large_flow_payload() -> dict:
nodes = [
{
"id": f"node-{index}",
"data": {"node": {"template": {"code": {"value": "from lfx.custom import Component\n" * 12}}}},
}
for index in range(LARGE_NODE_COUNT)
]
return {
"name": "compression fixture",
"description": "flow large enough to cross the compression threshold",
"data": {"nodes": nodes, "edges": []},
"is_component": False,
"webhook": False,
}


async def _create_large_flow(client: AsyncClient, headers: dict) -> str:
payload = _large_flow_payload()
assert len(json.dumps(payload).encode()) > GZIP_MINIMUM_SIZE
response = await client.post("api/v1/flows/", json=payload, headers=headers)
assert response.status_code == 201
return response.json()["id"]


async def test_flow_read_is_compressed_when_the_client_accepts_gzip(client: AsyncClient, logged_in_headers):
flow_id = await _create_large_flow(client, logged_in_headers)

response = await client.get(f"api/v1/flows/{flow_id}", headers={**logged_in_headers, "Accept-Encoding": "gzip"})

assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert "accept-encoding" in response.headers["vary"].lower()
assert response.json()["id"] == flow_id


async def test_flow_read_is_untouched_when_the_client_does_not_accept_gzip(client: AsyncClient, logged_in_headers):
flow_id = await _create_large_flow(client, logged_in_headers)

response = await client.get(f"api/v1/flows/{flow_id}", headers={**logged_in_headers, "Accept-Encoding": "identity"})

assert response.status_code == 200
assert "content-encoding" not in response.headers
assert json.loads(response.content)["id"] == flow_id


async def test_flow_update_echo_is_compressed(client: AsyncClient, logged_in_headers):
flow_id = await _create_large_flow(client, logged_in_headers)
payload = _large_flow_payload()
payload["name"] = "compression fixture renamed"

response = await client.patch(
f"api/v1/flows/{flow_id}",
json=payload,
headers={**logged_in_headers, "Accept-Encoding": "gzip"},
)

assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.json()["name"] == "compression fixture renamed"


async def test_response_below_the_threshold_is_not_compressed(client: AsyncClient, logged_in_headers):
response = await client.get("api/v1/version", headers={**logged_in_headers, "Accept-Encoding": "gzip"})

assert response.status_code == 200
assert len(response.content) < GZIP_MINIMUM_SIZE
assert "content-encoding" not in response.headers


EXCLUDED_UNDER_TEST = (
"application/octet-stream",
"application/zip",
"text/event-stream",
"image/png",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
)


def _app_with_the_same_middleware() -> FastAPI:
app = FastAPI()
app.add_middleware(
GZipMiddleware,
minimum_size=GZIP_MINIMUM_SIZE,
compresslevel=GZIP_COMPRESS_LEVEL,
exclude_content_types=GZIP_EXCLUDED_CONTENT_TYPES,
)

@app.get("/payload")
async def payload(content_type: str) -> Response:
return Response(content=b"x" * (GZIP_MINIMUM_SIZE * 4), media_type=content_type)

return app


def test_binary_and_streaming_content_types_are_configured_as_excluded():
for content_type in EXCLUDED_UNDER_TEST:
assert content_type in GZIP_EXCLUDED_CONTENT_TYPES


@pytest.mark.parametrize("content_type", EXCLUDED_UNDER_TEST)
async def test_excluded_content_types_are_not_compressed(content_type):
transport = ASGITransport(app=_app_with_the_same_middleware())
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
response = await client.get(
"/payload", params={"content_type": content_type}, headers={"Accept-Encoding": "gzip"}
)

assert response.status_code == 200
assert "content-encoding" not in response.headers
assert len(response.content) == GZIP_MINIMUM_SIZE * 4


async def test_a_compressible_type_on_the_same_app_is_compressed():
transport = ASGITransport(app=_app_with_the_same_middleware())
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
response = await client.get(
"/payload", params={"content_type": "application/json"}, headers={"Accept-Encoding": "gzip"}
)

assert response.headers["content-encoding"] == "gzip"
Loading
Loading