Skip to content

Preserve filter HTTPException and return 429 in the rate limit example#597

Open
sebnowak wants to merge 1 commit into
open-webui:mainfrom
sebnowak:fix/preserve-filter-http-errors
Open

Preserve filter HTTPException and return 429 in the rate limit example#597
sebnowak wants to merge 1 commit into
open-webui:mainfrom
sebnowak:fix/preserve-filter-http-errors

Conversation

@sebnowak
Copy link
Copy Markdown

@sebnowak sebnowak commented Mar 16, 2026

I ran into this while testing the rate-limit filter pipeline through Open WebUI.

Currently, the filter endpoints in main.py wrap any exception as 500. That means a filter cannot intentionally return something like 429 and expect the caller to receive that status.

The rate-limit example also raises a plain Exception, so even when it is enforcing the limit, the response looks like an internal error instead of a real rate-limit response.

This patch keeps HTTPException intact in the filter endpoints and updates the rate-limit example to raise HTTPException(status_code=429, ...).

Scope of this PR:

  • preserve deliberate HTTP errors from filters
  • keep existing 500 behavior for unexpected exceptions
  • make the rate-limit example return 429 instead of a generic error

I have a matching Open WebUI PR (open-webui/open-webui#22726).

I have personally tested the changes. How I tested:

  1. I ran belows python3 test_pipelines_http_errors.py (test creation was supported by Codex)
  2. That script executes the current source from this branch and verifies:
    • filter_inlet preserves a raised HTTPException(429, ...)
    • filter_outlet preserves a raised HTTPException(429, ...)
    • the bundled rate limit example raises a real 429 on the blocked request instead of a generic exception

CLI otuput:

Running Pipelines HTTP error checks...

test_filter_endpoints_preserve_http_exception (__main__.PipelinesHttpErrorTests.test_filter_endpoints_preserve_http_exception)
filter_inlet and filter_outlet preserve a raised HTTPException. ... ok
test_rate_limit_example_raises_http_429 (__main__.PipelinesHttpErrorTests.test_rate_limit_example_raises_http_429)
the bundled rate limit example returns HTTP 429 when blocked. ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.014s

OK

TEST

test_pipelines_http_errors.py

import ast
import asyncio
import contextlib
import io
import sys
import textwrap
import types
import unittest
from pathlib import Path


PIPELINES_ROOT = Path(__file__).resolve().parents[1] / "pipelines"


class HTTPException(Exception):
    def __init__(self, status_code: int, detail=None):
        super().__init__(detail)
        self.status_code = status_code
        self.detail = detail


class BaseModel:
    def __init__(self, **kwargs):
        for name, value in self.__class__.__dict__.items():
            if name.startswith("_") or callable(value):
                continue
            setattr(self, name, value)
        for key, value in kwargs.items():
            setattr(self, key, value)


class OpenAIChatMessage:
    pass


def _extract_async_function(path: Path, name: str) -> str:
    source = path.read_text(encoding="utf-8")
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if isinstance(node, ast.AsyncFunctionDef) and node.name == name:
            return textwrap.dedent(ast.get_source_segment(source, node))
    raise ValueError(f"Could not find async function {name!r} in {path}")


class PipelinesHttpErrorTests(unittest.IsolatedAsyncioTestCase):
    async def test_filter_endpoints_preserve_http_exception(self):
        """filter_inlet and filter_outlet preserve a raised HTTPException."""
        main_path = PIPELINES_ROOT / "main.py"
        inlet_source = _extract_async_function(main_path, "filter_inlet")
        outlet_source = _extract_async_function(main_path, "filter_outlet")

        class FilterForm:
            def __init__(self, body, user=None):
                self.body = body
                self.user = user

        class RateLimitFilter:
            async def inlet(self, *_args, **_kwargs):
                raise HTTPException(429, "Rate limit exceeded. Please try again later.")

            async def outlet(self, *_args, **_kwargs):
                raise HTTPException(429, "Rate limit exceeded. Please try again later.")

        namespace = {
            "app": types.SimpleNamespace(
                state=types.SimpleNamespace(
                    PIPELINES={"rate-limit-filter": {"type": "filter"}}
                )
            ),
            "PIPELINE_MODULES": {"rate-limit-filter": RateLimitFilter()},
            "HTTPException": HTTPException,
            "status": types.SimpleNamespace(
                HTTP_404_NOT_FOUND=404,
                HTTP_500_INTERNAL_SERVER_ERROR=500,
            ),
            "FilterForm": FilterForm,
        }
        exec(inlet_source, namespace)
        exec(outlet_source, namespace)

        form = FilterForm(body={"model": "chat-model"}, user={"id": "user-1"})

        with self.assertRaises(HTTPException) as inlet_ctx:
            await namespace["filter_inlet"]("rate-limit-filter", form)
        self.assertEqual(inlet_ctx.exception.status_code, 429)

        with self.assertRaises(HTTPException) as outlet_ctx:
            await namespace["filter_outlet"]("rate-limit-filter", form)
        self.assertEqual(outlet_ctx.exception.status_code, 429)

    async def test_rate_limit_example_raises_http_429(self):
        """the bundled rate limit example returns HTTP 429 when blocked."""
        example_path = PIPELINES_ROOT / "examples/filters/rate_limit_filter_pipeline.py"
        source = example_path.read_text(encoding="utf-8")
        namespace = {
            "os": __import__("os"),
            "time": __import__("time"),
            "BaseModel": BaseModel,
            "OpenAIChatMessage": OpenAIChatMessage,
            "HTTPException": HTTPException,
            "List": __import__("typing").List,
            "Optional": __import__("typing").Optional,
        }
        fastapi_module = types.ModuleType("fastapi")
        fastapi_module.HTTPException = HTTPException
        pydantic_module = types.ModuleType("pydantic")
        pydantic_module.BaseModel = BaseModel
        schemas_module = types.ModuleType("schemas")
        schemas_module.OpenAIChatMessage = OpenAIChatMessage
        previous_modules = {
            name: sys.modules.get(name)
            for name in ("fastapi", "pydantic", "schemas")
        }
        sys.modules["fastapi"] = fastapi_module
        sys.modules["pydantic"] = pydantic_module
        sys.modules["schemas"] = schemas_module
        try:
            exec(source, namespace)
        finally:
            for name, module in previous_modules.items():
                if module is None:
                    sys.modules.pop(name, None)
                else:
                    sys.modules[name] = module
        pipeline = namespace["Pipeline"]()
        pipeline.valves.requests_per_minute = 1
        pipeline.valves.requests_per_hour = None
        pipeline.valves.sliding_window_limit = None
        user = {"id": "user-1", "role": "user"}

        with contextlib.redirect_stdout(io.StringIO()):
            await pipeline.inlet({"model": "chat-model"}, user)

        with self.assertRaises(HTTPException) as ctx:
            with contextlib.redirect_stdout(io.StringIO()):
                await pipeline.inlet({"model": "chat-model"}, user)

        self.assertEqual(ctx.exception.status_code, 429)
        self.assertEqual(
            ctx.exception.detail,
            "Rate limit exceeded. Please try again later.",
        )


if __name__ == "__main__":
    print("Running Pipelines HTTP error checks...\n", file=sys.stderr, flush=True)
    unittest.main(verbosity=2, buffer=True)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant