Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
85 changes: 85 additions & 0 deletions .github/workflows/lint-backend.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Lint Backend

on:
pull_request:
paths:
- 'src/**/*.py'
- 'tests/**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/lint-backend.yml'

# Cancel in-flight runs on the same PR when a new push lands.
concurrency:
group: lint-backend-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: Ruff and mypy on changed files
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Need the full base ref locally so we can compute the diff
# against the PR's merge base.
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.13

- name: Install dev dependencies
run: uv sync --group dev

- name: Compute changed Python files
id: changed
run: |
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
# Diff against the merge base so we only see files touched in the PR.
merge_base=$(git merge-base "$base" "$head")
mapfile -t files < <(git diff --name-only --diff-filter=ACMR "$merge_base" "$head" -- '*.py')
if [ "${#files[@]}" -eq 0 ]; then
echo "No Python files changed."
echo "files=" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s\n' "${files[@]}"
# Pass as a single space-separated string to subsequent steps.
echo "files=${files[*]}" >> "$GITHUB_OUTPUT"

- name: Ruff (auto-fix dry run + format check)
if: steps.changed.outputs.files != ''
run: |
# `--no-fix` reports the same set of issues that `--fix` would
# apply, so the developer sees what to run locally to clean up.
uv run ruff check --no-fix --output-format=github ${{ steps.changed.outputs.files }}
uv run ruff format --check ${{ steps.changed.outputs.files }}

- name: Mypy
if: steps.changed.outputs.files != ''
run: |
# Run mypy from src/ so module paths resolve, dropping the
# leading "src/" from each path. Tests files are checked from
# the repo root.
src_files=()
test_files=()
for f in ${{ steps.changed.outputs.files }}; do
case "$f" in
src/*) src_files+=("${f#src/}") ;;
tests/*) test_files+=("$f") ;;
esac
done
if [ "${#src_files[@]}" -gt 0 ]; then
(cd src && uv run mypy "${src_files[@]}")
fi
if [ "${#test_files[@]}" -gt 0 ]; then
uv run mypy "${test_files[@]}"
fi
45 changes: 44 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ dependencies = [
]

[dependency-groups]
dev = ["pytest>=9.0.3", "pytest-asyncio>=0.21.0", "pytest-mock>=3.12.0", "pytest-cov>=4.0.0"]
dev = [
"pytest>=9.0.3",
"pytest-asyncio>=0.21.0",
"pytest-mock>=3.12.0",
"pytest-cov>=4.0.0",
"ruff>=0.15.12",
"mypy>=2.0.0",
]

[project.scripts]
openrag = "tui.main:run_tui"
Expand All @@ -77,4 +84,40 @@ filterwarnings = [
package = true
override-dependencies = ["python-dotenv>=1.1.0"]

# Ruff and mypy run only against files changed in a PR (see
# .github/workflows/lint-backend.yml). The configs below stay
# intentionally minimal — broad enforcement is out of scope for the
# refactor that introduced them.
[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = ["E501"] # line-length handled by formatter

# `bootstrap` must be the first import in any module that uses it
# (it loads .env and configures structlog before anything else can
# read env vars or emit logs). Define it as its own isort section
# placed above standard-library so the formatter never re-sorts it
# below other imports.
[tool.ruff.lint.isort]
section-order = [
"future",
"bootstrap",
"standard-library",
"third-party",
"first-party",
"local-folder",
]

[tool.ruff.lint.isort.sections]
bootstrap = ["bootstrap"]

[tool.mypy]
python_version = "3.13"
ignore_missing_imports = true
no_strict_optional = true
warn_unused_ignores = true


80 changes: 80 additions & 0 deletions src/api/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Liveness and readiness probes."""

import asyncio

import httpx
from fastapi import Request
from fastapi.responses import JSONResponse

from config.settings import clients
from utils.logging_config import get_logger

logger = get_logger(__name__)


async def health_check(request: Request):
"""Simple liveness probe: Indicates that the OpenRAG Backend service is online and running."""
return JSONResponse({"status": "ok"}, status_code=200)


async def opensearch_health_ready(request):
"""Readiness probe: verifies OpenSearch dependency is reachable."""
from config.settings import IBM_AUTH_ENABLED, OPENSEARCH_URL

if IBM_AUTH_ENABLED:
logger.debug("[OPENSEARCH] OpenSearch auth mode enabled, health check per-request")
# In IBM auth mode we cannot rely on the global OpenSearch client
# (auth is established per-request), so perform a lightweight,
# unauthenticated connectivity check against the OpenSearch endpoint.
opensearch_url = OPENSEARCH_URL.rstrip("/")
try:
timeout = httpx.Timeout(5.0)
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{opensearch_url}/")
if resp.status_code < 500:
logger.debug("[OPENSEARCH] OpenSearch health check successful")
return JSONResponse(
{
"status": "ready",
"dependencies": {"opensearch": "up"},
"note": "OpenSearch auth mode - connectivity verified via unauthenticated probe",
},
status_code=200,
)
else:
logger.debug("[OPENSEARCH] OpenSearch health check failed")
return JSONResponse(
{
"status": "not_ready",
"dependencies": {"opensearch": "down"},
"error": f"Unexpected status from OpenSearch: {resp.status_code}",
},
status_code=503,
Comment on lines +34 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't report readiness on arbitrary 4xx responses.

resp.status_code < 500 marks 404/429/etc. as healthy, so a bad OPENSEARCH_URL or proxy misroute can still return 200 from /search/health. Only explicit expected statuses for the unauthenticated probe should count as ready.

Suggested guard
-            if resp.status_code < 500:
+            if resp.status_code in {200, 401, 403}:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if resp.status_code < 500:
logger.debug("[OPENSEARCH] OpenSearch health check successful")
return JSONResponse(
{
"status": "ready",
"dependencies": {"opensearch": "up"},
"note": "OpenSearch auth mode - connectivity verified via unauthenticated probe",
},
status_code=200,
)
else:
logger.debug("[OPENSEARCH] OpenSearch health check failed")
return JSONResponse(
{
"status": "not_ready",
"dependencies": {"opensearch": "down"},
"error": f"Unexpected status from OpenSearch: {resp.status_code}",
},
status_code=503,
if resp.status_code in {200, 401, 403}:
logger.debug("[OPENSEARCH] OpenSearch health check successful")
return JSONResponse(
{
"status": "ready",
"dependencies": {"opensearch": "up"},
"note": "OpenSearch auth mode - connectivity verified via unauthenticated probe",
},
status_code=200,
)
else:
logger.debug("[OPENSEARCH] OpenSearch health check failed")
return JSONResponse(
{
"status": "not_ready",
"dependencies": {"opensearch": "down"},
"error": f"Unexpected status from OpenSearch: {resp.status_code}",
},
status_code=503,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/health.py` around lines 35 - 53, The health check currently treats
any resp.status_code < 500 as healthy which marks 4xx (e.g., 404/429) as ready;
update the check in the OpenSearch probe (the branch that inspects
resp.status_code and returns the JSONResponse) to only accept an explicit
allowlist of expected unauthenticated probe status codes (e.g., allowed_statuses
= {200} or include 401/403 if your auth probe expects them) and treat every
other status as not_ready; change the conditional around resp.status_code in
that block to check membership in allowed_statuses and leave the JSONResponse
payloads (status/dependencies/error) unchanged otherwise.

)
except Exception as e:
logger.error("[OPENSEARCH] OpenSearch health check failed", error=str(e))
return JSONResponse(
{
"status": "not_ready",
"dependencies": {"opensearch": "down"},
"error": "OpenSearch health check failed",
},
status_code=503,
)

try:
await asyncio.wait_for(clients.opensearch.info(), timeout=5.0)
return JSONResponse(
{"status": "ready", "dependencies": {"opensearch": "up"}},
status_code=200,
)
except Exception as e:
logger.error("[OPENSEARCH] OpenSearch health check failed", error=str(e))
return JSONResponse(
{
"status": "not_ready",
"dependencies": {"opensearch": "down"},
"error": "OpenSearch health check failed",
},
status_code=503,
)
Empty file added src/app/__init__.py
Empty file.
Loading
Loading