sql-dialect-master 1.1.2__tar.gz → 1.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/PKG-INFO +30 -6
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/README.md +23 -4
- sql_dialect_master-1.1.4/backend/api/auth.py +29 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/main.py +87 -15
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/middleware.py +54 -17
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/rate_limit_store.py +19 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/readiness.py +25 -7
- sql_dialect_master-1.1.4/backend/api/workers.py +44 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/cache.py +12 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/config.py +42 -13
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/exceptions.py +3 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/functions_lookup.py +9 -5
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/metadata.py +2 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/core_extract.py +55 -10
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/mappings.py +2 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/templates.py +2 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/dialect.py +3 -1
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/models.py +3 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/orchestrator.py +171 -6
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/renderer.py +40 -5
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/templates.py +16 -0
- sql_dialect_master-1.1.4/backend/core/nl2sql_pipeline/validation.py +77 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/p1_sql_scanner.py +44 -7
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/parser.py +3 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/semantic_diff.py +3 -1
- sql_dialect_master-1.1.4/backend/core/sql_statements.py +30 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/transpiler.py +129 -34
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/type_mapping.py +8 -6
- sql_dialect_master-1.1.4/backend/frontend_entrypoint.py +22 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/utils/validators.py +27 -41
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/design_tokens.py +7 -3
- sql_dialect_master-1.1.4/frontend/core/fingerprints.py +24 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/navigation.py +37 -5
- sql_dialect_master-1.1.4/frontend/core/sql_inputs.py +6 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/state.py +125 -35
- sql_dialect_master-1.1.4/frontend/core/statuses.py +34 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/viewmodels.py +5 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/convert.py +106 -19
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/diff.py +33 -5
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/functions.py +2 -3
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/history.py +15 -11
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/lineage.py +31 -14
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/nl2sql.py +32 -10
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/query_analysis.py +12 -4
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/runtime.py +3 -3
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/templates.py +18 -1
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/command_palette.py +21 -2
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/diff.py +2 -8
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/editor.py +5 -1
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/status.py +8 -11
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/pyproject.toml +17 -2
- sql_dialect_master-1.1.4/sdm_local_v2.py +219 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/PKG-INFO +30 -6
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/SOURCES.txt +9 -0
- sql_dialect_master-1.1.4/sql_dialect_master.egg-info/entry_points.txt +2 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/requires.txt +6 -1
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/top_level.txt +1 -0
- sql_dialect_master-1.1.2/backend/core/nl2sql_pipeline/validation.py +0 -28
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/LICENSE +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/api/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/column_hint_validation.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/constants.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/function_call_scanner.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/function_semantics.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/functions_db.json +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/aggregations.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/evidence.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/relations.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_components/tokenizer.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_legacy.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/intent.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/ir.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/predicates.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/resolution.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/p2_data_validation.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/post_processor.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/rules.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/core/type_mapping.json +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/backend/utils/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/app_context_v2.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/escaping.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/styles.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/themes.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/core/type_status.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/settings.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/pages/types.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/templates_v2.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/frontend/ui/__init__.py +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/setup.cfg +0 -0
- {sql_dialect_master-1.1.2 → sql_dialect_master-1.1.4}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sql-dialect-master
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.4
|
|
4
4
|
Summary: SQL Dialect Master - Multi-database SQL conversion engine
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
|
-
Requires-Dist: sqlglot
|
|
8
|
+
Requires-Dist: sqlglot<31.0.0,>=30.18.0
|
|
9
9
|
Requires-Dist: streamlit>=1.38.0
|
|
10
10
|
Requires-Dist: fastapi>=0.115.0
|
|
11
11
|
Requires-Dist: uvicorn>=0.30.0
|
|
@@ -16,10 +16,15 @@ Requires-Dist: hypothesis>=6.112.0; extra == "dev"
|
|
|
16
16
|
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
17
17
|
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
18
18
|
Requires-Dist: httpx>=0.27.0; extra == "dev"
|
|
19
|
+
Requires-Dist: requests>=2.31.0; extra == "dev"
|
|
19
20
|
Provides-Extra: qa
|
|
20
21
|
Requires-Dist: ruff>=0.6; extra == "qa"
|
|
21
22
|
Requires-Dist: bandit>=1.7; extra == "qa"
|
|
22
23
|
Requires-Dist: pip-audit>=2.7; extra == "qa"
|
|
24
|
+
Requires-Dist: build>=1.2.2; extra == "qa"
|
|
25
|
+
Requires-Dist: twine>=5.1; extra == "qa"
|
|
26
|
+
Requires-Dist: setuptools>=75.0.0; extra == "qa"
|
|
27
|
+
Requires-Dist: wheel>=0.45; extra == "qa"
|
|
23
28
|
Provides-Extra: semantic
|
|
24
29
|
Requires-Dist: duckdb>=1.5.5; extra == "semantic"
|
|
25
30
|
Requires-Dist: psycopg[binary]>=3.3.5; extra == "semantic"
|
|
@@ -31,7 +36,7 @@ Dynamic: license-file
|
|
|
31
36
|
|
|
32
37
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
33
38
|
|
|
34
|
-
Current release: 1.1.
|
|
39
|
+
Current release: 1.1.4
|
|
35
40
|
|
|
36
41
|
[](https://www.python.org/downloads/)
|
|
37
42
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -77,16 +82,18 @@ cd sql-dialect-master
|
|
|
77
82
|
python -m pip install -e .
|
|
78
83
|
```
|
|
79
84
|
|
|
80
|
-
For development and
|
|
85
|
+
For development, testing, quality checks, and local builds, use the project's
|
|
86
|
+
Python environment and install the optional tooling:
|
|
81
87
|
|
|
82
88
|
```bash
|
|
83
|
-
python -m
|
|
89
|
+
python -m venv .venv
|
|
90
|
+
.venv/bin/python -m pip install -e ".[dev,qa]"
|
|
84
91
|
```
|
|
85
92
|
|
|
86
93
|
For database-backed semantic tests:
|
|
87
94
|
|
|
88
95
|
```bash
|
|
89
|
-
python -m pip install -e ".[dev,semantic]"
|
|
96
|
+
.venv/bin/python -m pip install -e ".[dev,semantic]"
|
|
90
97
|
```
|
|
91
98
|
|
|
92
99
|
### Run Streamlit UI (Recommended)
|
|
@@ -95,6 +102,13 @@ python -m pip install -e ".[dev,semantic]"
|
|
|
95
102
|
streamlit run sdm_local_v2.py
|
|
96
103
|
```
|
|
97
104
|
|
|
105
|
+
After installing the package, the same workspace can be launched from any
|
|
106
|
+
working directory with:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
sdm-streamlit
|
|
110
|
+
```
|
|
111
|
+
|
|
98
112
|
Open http://localhost:8501 in your browser.
|
|
99
113
|
|
|
100
114
|
### Run FastAPI Backend
|
|
@@ -105,6 +119,16 @@ uvicorn backend.api.main:app --reload
|
|
|
105
119
|
|
|
106
120
|
API docs available at http://localhost:8000/docs
|
|
107
121
|
|
|
122
|
+
Set `SDM_API_KEY` in `.env` to require credentials for `/api/*` endpoints.
|
|
123
|
+
Clients may send `X-API-Key` or `Authorization: Bearer <key>`. Health probes
|
|
124
|
+
use the separate `SDM_HEALTH_PROBE_TOKEN` setting. An empty API key preserves
|
|
125
|
+
local access.
|
|
126
|
+
|
|
127
|
+
`SDM_API_WORKERS` (default 4) and `SDM_API_QUEUE_SIZE` (default 4) bound CPU
|
|
128
|
+
work. When all slots are occupied, requests receive HTTP 503 and a
|
|
129
|
+
`Retry-After` header. `SDM_REQUEST_TIMEOUT` returns HTTP 504 when a request
|
|
130
|
+
times out; running threads retain their capacity until they finish.
|
|
131
|
+
|
|
108
132
|
## 📖 Usage Examples
|
|
109
133
|
|
|
110
134
|
### Python SDK
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
|
|
4
4
|
|
|
5
|
-
Current release: 1.1.
|
|
5
|
+
Current release: 1.1.4
|
|
6
6
|
|
|
7
7
|
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -48,16 +48,18 @@ cd sql-dialect-master
|
|
|
48
48
|
python -m pip install -e .
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
For development and
|
|
51
|
+
For development, testing, quality checks, and local builds, use the project's
|
|
52
|
+
Python environment and install the optional tooling:
|
|
52
53
|
|
|
53
54
|
```bash
|
|
54
|
-
python -m
|
|
55
|
+
python -m venv .venv
|
|
56
|
+
.venv/bin/python -m pip install -e ".[dev,qa]"
|
|
55
57
|
```
|
|
56
58
|
|
|
57
59
|
For database-backed semantic tests:
|
|
58
60
|
|
|
59
61
|
```bash
|
|
60
|
-
python -m pip install -e ".[dev,semantic]"
|
|
62
|
+
.venv/bin/python -m pip install -e ".[dev,semantic]"
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
### Run Streamlit UI (Recommended)
|
|
@@ -66,6 +68,13 @@ python -m pip install -e ".[dev,semantic]"
|
|
|
66
68
|
streamlit run sdm_local_v2.py
|
|
67
69
|
```
|
|
68
70
|
|
|
71
|
+
After installing the package, the same workspace can be launched from any
|
|
72
|
+
working directory with:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
sdm-streamlit
|
|
76
|
+
```
|
|
77
|
+
|
|
69
78
|
Open http://localhost:8501 in your browser.
|
|
70
79
|
|
|
71
80
|
### Run FastAPI Backend
|
|
@@ -76,6 +85,16 @@ uvicorn backend.api.main:app --reload
|
|
|
76
85
|
|
|
77
86
|
API docs available at http://localhost:8000/docs
|
|
78
87
|
|
|
88
|
+
Set `SDM_API_KEY` in `.env` to require credentials for `/api/*` endpoints.
|
|
89
|
+
Clients may send `X-API-Key` or `Authorization: Bearer <key>`. Health probes
|
|
90
|
+
use the separate `SDM_HEALTH_PROBE_TOKEN` setting. An empty API key preserves
|
|
91
|
+
local access.
|
|
92
|
+
|
|
93
|
+
`SDM_API_WORKERS` (default 4) and `SDM_API_QUEUE_SIZE` (default 4) bound CPU
|
|
94
|
+
work. When all slots are occupied, requests receive HTTP 503 and a
|
|
95
|
+
`Retry-After` header. `SDM_REQUEST_TIMEOUT` returns HTTP 504 when a request
|
|
96
|
+
times out; running threads retain their capacity until they finish.
|
|
97
|
+
|
|
79
98
|
## 📖 Usage Examples
|
|
80
99
|
|
|
81
100
|
### Python SDK
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Optional shared API-key protection for business endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import secrets
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException, Request
|
|
7
|
+
|
|
8
|
+
from backend.core.config import settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def require_api_access(request: Request) -> None:
|
|
12
|
+
"""Require a configured key for /api endpoints while keeping probes separate."""
|
|
13
|
+
key = settings.api_key
|
|
14
|
+
if key is None or not request.url.path.startswith("/api/"):
|
|
15
|
+
return
|
|
16
|
+
expected = key.get_secret_value()
|
|
17
|
+
if not expected:
|
|
18
|
+
return
|
|
19
|
+
supplied = request.headers.get("X-API-Key", "")
|
|
20
|
+
if not supplied:
|
|
21
|
+
scheme, _, credential = request.headers.get("Authorization", "").partition(" ")
|
|
22
|
+
if scheme.lower() == "bearer":
|
|
23
|
+
supplied = credential
|
|
24
|
+
if not supplied or not secrets.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8")):
|
|
25
|
+
raise HTTPException(
|
|
26
|
+
status_code=401,
|
|
27
|
+
detail="Invalid or missing API key",
|
|
28
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
29
|
+
)
|
|
@@ -11,12 +11,14 @@ Enterprise-grade multi-database SQL conversion API:
|
|
|
11
11
|
Exact counts are derived from the canonical data files at runtime —
|
|
12
12
|
see backend.core.metadata — and must not be restated here.
|
|
13
13
|
"""
|
|
14
|
+
import asyncio
|
|
14
15
|
import logging
|
|
15
16
|
import time
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
16
18
|
from datetime import datetime
|
|
17
19
|
from typing import Any, Dict, List, Optional
|
|
18
20
|
|
|
19
|
-
from fastapi import FastAPI, HTTPException, Query, Request
|
|
21
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|
20
22
|
from fastapi.middleware.cors import CORSMiddleware
|
|
21
23
|
from fastapi.responses import JSONResponse
|
|
22
24
|
from pydantic import BaseModel, ConfigDict, Field
|
|
@@ -31,10 +33,43 @@ from backend.core.transpiler import SQLTranspiler
|
|
|
31
33
|
from backend.core.type_mapping import TypeMapper
|
|
32
34
|
|
|
33
35
|
from backend.api.readiness import health_probe_response
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
from backend.api.auth import require_api_access
|
|
37
|
+
from backend.api.workers import BoundedWorkerPool, WorkerCapacityError
|
|
38
|
+
from backend.api.rate_limit_store import InMemoryRateLimitStore
|
|
39
|
+
|
|
40
|
+
# Configure logging for the application from the validated settings object.
|
|
41
|
+
# Keep an INFO fallback for defensive compatibility with custom settings
|
|
42
|
+
# objects, while normal AppSettings values are resolved from SDM_LOG_LEVEL.
|
|
43
|
+
_log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
|
44
|
+
logger = setup_logging(level=_log_level, log_file=settings.log_file or None)
|
|
37
45
|
api_logger = logging.getLogger(__name__)
|
|
46
|
+
_worker_pool = BoundedWorkerPool(settings.api_workers, settings.api_queue_size)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@asynccontextmanager
|
|
50
|
+
async def lifespan(application: FastAPI):
|
|
51
|
+
"""Manage the service worker pool for a normal ASGI startup/shutdown."""
|
|
52
|
+
pool = BoundedWorkerPool(settings.api_workers, settings.api_queue_size)
|
|
53
|
+
application.state.worker_pool = pool
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
pool.shutdown()
|
|
58
|
+
del application.state.worker_pool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _run_cpu_bound(func, *args):
|
|
62
|
+
"""Run parser/transpiler work off-loop with the configured timeout."""
|
|
63
|
+
try:
|
|
64
|
+
pool = getattr(app.state, "worker_pool", _worker_pool)
|
|
65
|
+
return await pool.run(func, *args, timeout=settings.request_timeout)
|
|
66
|
+
except WorkerCapacityError as exc:
|
|
67
|
+
raise HTTPException(status_code=503, detail=str(exc), headers={"Retry-After": "1"}) from exc
|
|
68
|
+
except asyncio.TimeoutError as exc:
|
|
69
|
+
raise HTTPException(
|
|
70
|
+
status_code=504,
|
|
71
|
+
detail="Request processing timed out",
|
|
72
|
+
) from exc
|
|
38
73
|
|
|
39
74
|
# API metadata
|
|
40
75
|
API_VERSION = settings.api_version
|
|
@@ -86,6 +121,8 @@ print(response.json()["target_sql"])
|
|
|
86
121
|
|
|
87
122
|
# Initialize FastAPI app with enhanced metadata
|
|
88
123
|
app = FastAPI(
|
|
124
|
+
dependencies=[Depends(require_api_access)],
|
|
125
|
+
lifespan=lifespan,
|
|
89
126
|
title=API_TITLE,
|
|
90
127
|
description=API_DESCRIPTION,
|
|
91
128
|
version=API_VERSION,
|
|
@@ -137,7 +174,7 @@ app.add_middleware(
|
|
|
137
174
|
allow_origins=ALLOWED_ORIGINS,
|
|
138
175
|
allow_credentials=True,
|
|
139
176
|
allow_methods=["GET", "POST", "OPTIONS"], # Restrict to needed methods
|
|
140
|
-
allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
|
|
177
|
+
allow_headers=["Content-Type", "Authorization", "X-API-Key", "X-Request-ID"],
|
|
141
178
|
)
|
|
142
179
|
|
|
143
180
|
# Add security headers
|
|
@@ -147,6 +184,7 @@ app.add_middleware(SecurityHeadersMiddleware)
|
|
|
147
184
|
rate_limiter = RateLimiter(
|
|
148
185
|
requests_per_window=settings.rate_limit_requests,
|
|
149
186
|
window_seconds=settings.rate_limit_window,
|
|
187
|
+
store=None if settings.rate_limit_enabled else InMemoryRateLimitStore(),
|
|
150
188
|
)
|
|
151
189
|
app.add_middleware(
|
|
152
190
|
RateLimitMiddleware,
|
|
@@ -173,9 +211,8 @@ async def add_process_time_header(request: Request, call_next):
|
|
|
173
211
|
response.headers["X-API-Version"] = API_VERSION
|
|
174
212
|
|
|
175
213
|
# Log request details
|
|
176
|
-
api_logger.info(
|
|
177
|
-
|
|
178
|
-
)
|
|
214
|
+
api_logger.info("%s %r - %s - %.4fs", request.method, request.url.path,
|
|
215
|
+
response.status_code, process_time)
|
|
179
216
|
return response
|
|
180
217
|
|
|
181
218
|
# Dialect info for enhanced responses (from centralized config)
|
|
@@ -199,7 +236,10 @@ class ConvertRequest(BaseModel):
|
|
|
199
236
|
sql: str = Field(..., description="Source SQL statement to convert")
|
|
200
237
|
source_dialect: str = Field(..., description="Source database dialect")
|
|
201
238
|
target_dialect: str = Field(..., description="Target database dialect")
|
|
202
|
-
pretty: bool = Field(
|
|
239
|
+
pretty: bool = Field(
|
|
240
|
+
settings.transpiler_pretty_default,
|
|
241
|
+
description="Format output SQL with indentation",
|
|
242
|
+
)
|
|
203
243
|
|
|
204
244
|
model_config = ConfigDict(json_schema_extra={
|
|
205
245
|
"example": {
|
|
@@ -263,6 +303,20 @@ class NL2SQLResponse(BaseModel):
|
|
|
263
303
|
explanation: str = Field("", description="Explanation of generated SQL")
|
|
264
304
|
confidence: float = Field(0.0, description="Confidence score (0.0-1.0)")
|
|
265
305
|
suggestions: List[str] = Field(default_factory=list, description="Improvement suggestions")
|
|
306
|
+
parameter_style: Optional[str] = Field(
|
|
307
|
+
None,
|
|
308
|
+
description=(
|
|
309
|
+
"Placeholder style for generated DML: numeric ($1 in PostgreSQL/Redshift, "
|
|
310
|
+
":1 in Oracle), named (@p1 in T-SQL), format (%s in MySQL), or qmark (?)"
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
parameters: List[str] = Field(
|
|
314
|
+
default_factory=list,
|
|
315
|
+
description="Ordered placeholders that callers must bind for generated DML",
|
|
316
|
+
)
|
|
317
|
+
evidence: Optional[Dict[str, Any]] = Field(
|
|
318
|
+
None, description="Structured evidence supporting the confidence score",
|
|
319
|
+
)
|
|
266
320
|
|
|
267
321
|
class ParseRequest(BaseModel):
|
|
268
322
|
"""SQL parse request model."""
|
|
@@ -402,11 +456,14 @@ async def convert_sql(request: ConvertRequest):
|
|
|
402
456
|
detail=f"Unsupported target dialect: {request.target_dialect}. Supported: {SUPPORTED_DIALECTS}"
|
|
403
457
|
)
|
|
404
458
|
|
|
405
|
-
|
|
459
|
+
# sqlglot parsing/transpilation is CPU-bound. Keep it off the event loop
|
|
460
|
+
# so one large request cannot stall unrelated async requests.
|
|
461
|
+
result = await _run_cpu_bound(
|
|
462
|
+
transpiler.transpile,
|
|
406
463
|
request.sql,
|
|
407
464
|
source_dialect,
|
|
408
465
|
target_dialect,
|
|
409
|
-
request.pretty
|
|
466
|
+
request.pretty,
|
|
410
467
|
)
|
|
411
468
|
return ConvertResponse(
|
|
412
469
|
success=result.success,
|
|
@@ -431,7 +488,9 @@ async def parse_sql(request: ParseRequest):
|
|
|
431
488
|
"""
|
|
432
489
|
dialect = normalize_dialect(request.dialect)
|
|
433
490
|
parser = SQLParser(dialect)
|
|
434
|
-
elements = parser.extract_elements
|
|
491
|
+
elements = await _run_cpu_bound(parser.extract_elements, request.sql)
|
|
492
|
+
if "error" in elements:
|
|
493
|
+
raise HTTPException(status_code=422, detail=elements["error"] or "SQL parsing failed")
|
|
435
494
|
return {
|
|
436
495
|
"success": True,
|
|
437
496
|
"dialect": dialect,
|
|
@@ -455,10 +514,18 @@ async def list_functions(
|
|
|
455
514
|
- `/api/functions?category=string` - List all string functions
|
|
456
515
|
- `/api/functions?limit=100` - Get first 100 functions
|
|
457
516
|
"""
|
|
517
|
+
normalized_category = category.strip().lower() if category is not None else None
|
|
518
|
+
if category is not None:
|
|
519
|
+
if normalized_category not in func_encyclopedia.CATEGORIES:
|
|
520
|
+
raise HTTPException(
|
|
521
|
+
status_code=400,
|
|
522
|
+
detail=(f"Unknown function category: {category}. Available categories: "
|
|
523
|
+
+ ", ".join(func_encyclopedia.CATEGORIES)),
|
|
524
|
+
)
|
|
458
525
|
if search:
|
|
459
526
|
functions = func_encyclopedia.search(search, limit)
|
|
460
527
|
elif category:
|
|
461
|
-
functions = func_encyclopedia.list_by_category(
|
|
528
|
+
functions = func_encyclopedia.list_by_category(normalized_category)[:limit]
|
|
462
529
|
else:
|
|
463
530
|
functions = func_encyclopedia.functions[:limit]
|
|
464
531
|
|
|
@@ -598,7 +665,8 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
598
665
|
"dotted identifier"
|
|
599
666
|
),
|
|
600
667
|
)
|
|
601
|
-
result =
|
|
668
|
+
result = await _run_cpu_bound(
|
|
669
|
+
nl2sql_generator.generate,
|
|
602
670
|
request.text,
|
|
603
671
|
dialect,
|
|
604
672
|
request.table_hint,
|
|
@@ -611,7 +679,10 @@ async def generate_sql(request: NL2SQLRequest):
|
|
|
611
679
|
dialect=dialect,
|
|
612
680
|
explanation=result.explanation,
|
|
613
681
|
confidence=result.confidence,
|
|
614
|
-
suggestions=result.suggestions
|
|
682
|
+
suggestions=list(result.suggestions[: settings.nl2sql_max_suggestions]),
|
|
683
|
+
parameter_style=result.parameter_style,
|
|
684
|
+
parameters=list(result.parameters),
|
|
685
|
+
evidence=result.evidence.to_dict() if result.evidence is not None else None,
|
|
615
686
|
)
|
|
616
687
|
|
|
617
688
|
@app.get("/health", tags=["system"])
|
|
@@ -678,6 +749,7 @@ async def get_stats():
|
|
|
678
749
|
async def custom_http_exception_handler(request: Request, exc: HTTPException):
|
|
679
750
|
return JSONResponse(
|
|
680
751
|
status_code=exc.status_code,
|
|
752
|
+
headers=exc.headers,
|
|
681
753
|
content={
|
|
682
754
|
"success": False,
|
|
683
755
|
"error": {
|
|
@@ -14,10 +14,11 @@ from typing import Dict
|
|
|
14
14
|
from fastapi import Request, Response
|
|
15
15
|
from fastapi.responses import JSONResponse
|
|
16
16
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
|
+
from starlette.concurrency import run_in_threadpool
|
|
17
18
|
|
|
18
19
|
from backend.core.exceptions import ErrorCode
|
|
19
20
|
|
|
20
|
-
from .rate_limit_store import RateLimitStore, create_rate_limit_store
|
|
21
|
+
from .rate_limit_store import InMemoryRateLimitStore, RateLimitStore, create_rate_limit_store
|
|
21
22
|
from .readiness import health_probe_response
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
@@ -69,13 +70,17 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
69
70
|
super().__init__(app)
|
|
70
71
|
if max_body_bytes <= 0:
|
|
71
72
|
raise ValueError("max_body_bytes must be positive")
|
|
72
|
-
self.limiter = limiter or RateLimiter()
|
|
73
|
+
self.limiter = limiter or RateLimiter(store=None if enabled else InMemoryRateLimitStore())
|
|
73
74
|
self.enabled = enabled
|
|
74
75
|
self.max_body_bytes = max_body_bytes
|
|
75
76
|
|
|
76
77
|
async def _read_bounded_body(self, request: Request) -> Response | None:
|
|
77
78
|
"""Consume and cache request bodies without trusting Content-Length."""
|
|
78
|
-
|
|
79
|
+
# HEAD has no response body by definition and some ASGI servers do
|
|
80
|
+
# not expose a request stream for it. GET bodies are unusual but
|
|
81
|
+
# still need the same bound so an oversized payload cannot bypass
|
|
82
|
+
# request-size protection by changing the HTTP method.
|
|
83
|
+
if request.method == "HEAD":
|
|
79
84
|
return None
|
|
80
85
|
content_length = request.headers.get("content-length")
|
|
81
86
|
if content_length:
|
|
@@ -83,6 +88,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
83
88
|
declared_length = int(content_length)
|
|
84
89
|
except ValueError:
|
|
85
90
|
return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
|
|
91
|
+
if declared_length < 0:
|
|
92
|
+
return JSONResponse(status_code=400, content={"success": False, "error": {"code": 400, "message": "Invalid Content-Length"}, "timestamp": datetime.now().isoformat()})
|
|
86
93
|
if declared_length > self.max_body_bytes:
|
|
87
94
|
return JSONResponse(status_code=413, content={"success": False, "error": {"code": 413, "message": "Request body exceeds the maximum allowed size", "max_bytes": self.max_body_bytes}, "timestamp": datetime.now().isoformat()})
|
|
88
95
|
|
|
@@ -97,6 +104,30 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
97
104
|
return None
|
|
98
105
|
|
|
99
106
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
107
|
+
is_health_probe = (
|
|
108
|
+
request.url.path in {"/health", "/", "/ready", "/health/deep"}
|
|
109
|
+
and request.method in {"GET", "HEAD"}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Apply rate limiting before JSON/body parsing. This prevents a
|
|
113
|
+
# client from spending unbounded CPU on invalid payloads while still
|
|
114
|
+
# keeping the request-size guard active when rate limiting is disabled.
|
|
115
|
+
rate_limit_checked = False
|
|
116
|
+
remaining = reset_time = None
|
|
117
|
+
if self.enabled and not is_health_probe:
|
|
118
|
+
client_id = self._get_client_id(request)
|
|
119
|
+
# Redis checks use a synchronous client; a stalled store must not
|
|
120
|
+
# block the event loop used by unrelated liveness/API requests.
|
|
121
|
+
is_allowed, remaining, reset_time = await run_in_threadpool(self.limiter.is_allowed, client_id)
|
|
122
|
+
rate_limit_checked = True
|
|
123
|
+
if not is_allowed:
|
|
124
|
+
logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
|
|
125
|
+
return JSONResponse(
|
|
126
|
+
status_code=429,
|
|
127
|
+
content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
|
|
128
|
+
headers={"X-RateLimit-Limit": str(self.limiter._requests_per_window), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_time), "Retry-After": str(reset_time)},
|
|
129
|
+
)
|
|
130
|
+
|
|
100
131
|
body_error = await self._read_bounded_body(request)
|
|
101
132
|
if body_error is not None:
|
|
102
133
|
return body_error
|
|
@@ -118,24 +149,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
|
118
149
|
|
|
119
150
|
if not self.enabled:
|
|
120
151
|
return await call_next(request)
|
|
121
|
-
if
|
|
152
|
+
if is_health_probe:
|
|
122
153
|
if request.url.path in {"/ready", "/health/deep"}:
|
|
123
154
|
return await health_probe_response(request)
|
|
124
155
|
return await call_next(request)
|
|
125
|
-
|
|
126
|
-
client_id = self._get_client_id(request)
|
|
127
|
-
is_allowed, remaining, reset_time = self.limiter.is_allowed(client_id)
|
|
128
|
-
if not is_allowed:
|
|
129
|
-
logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
|
|
130
|
-
return JSONResponse(
|
|
131
|
-
status_code=429,
|
|
132
|
-
content={"success": False, "error": {"code": 429, "message": "Rate limit exceeded", "retry_after": reset_time}, "timestamp": datetime.now().isoformat()},
|
|
133
|
-
headers={"X-RateLimit-Limit": str(self.limiter._requests_per_window), "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": str(reset_time), "Retry-After": str(reset_time)},
|
|
134
|
-
)
|
|
135
156
|
response = await call_next(request)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
157
|
+
if rate_limit_checked:
|
|
158
|
+
response.headers["X-RateLimit-Limit"] = str(self.limiter._requests_per_window)
|
|
159
|
+
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
|
160
|
+
response.headers["X-RateLimit-Reset"] = str(reset_time)
|
|
139
161
|
return response
|
|
140
162
|
|
|
141
163
|
def _get_client_id(self, request: Request) -> str:
|
|
@@ -167,14 +189,19 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
167
189
|
"""Normalize standard HTTP 4xx JSON responses while preserving domain-specific codes."""
|
|
168
190
|
code_by_status = {
|
|
169
191
|
400: ErrorCode.BAD_REQUEST.value,
|
|
192
|
+
401: ErrorCode.UNAUTHORIZED.value,
|
|
170
193
|
404: ErrorCode.NOT_FOUND.value,
|
|
171
194
|
405: ErrorCode.METHOD_NOT_ALLOWED.value,
|
|
172
195
|
413: ErrorCode.PAYLOAD_TOO_LARGE.value,
|
|
173
196
|
422: ErrorCode.VALIDATION_FAILED.value,
|
|
174
197
|
429: ErrorCode.RATE_LIMITED.value,
|
|
198
|
+
503: ErrorCode.SERVICE_UNAVAILABLE.value,
|
|
199
|
+
504: ErrorCode.REQUEST_TIMEOUT.value,
|
|
175
200
|
}
|
|
176
201
|
if response.status_code not in code_by_status:
|
|
177
202
|
return response
|
|
203
|
+
if "json" not in response.headers.get("content-type", "").lower():
|
|
204
|
+
return response
|
|
178
205
|
|
|
179
206
|
try:
|
|
180
207
|
if hasattr(response, "body"):
|
|
@@ -188,6 +215,16 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
188
215
|
except (AttributeError, UnicodeDecodeError, json.JSONDecodeError):
|
|
189
216
|
return response
|
|
190
217
|
|
|
218
|
+
if response.status_code == 503 and isinstance(payload, dict) and "checks" in payload:
|
|
219
|
+
# Readiness has a public diagnostics schema of its own. Preserve
|
|
220
|
+
# status/checks when the service is unhealthy, including after
|
|
221
|
+
# consuming the downstream streaming response to decode JSON.
|
|
222
|
+
headers = dict(response.headers)
|
|
223
|
+
headers.pop("content-length", None)
|
|
224
|
+
headers.pop("content-type", None)
|
|
225
|
+
payload.setdefault("request_id", request_id)
|
|
226
|
+
return JSONResponse(status_code=503, content=payload, headers=headers)
|
|
227
|
+
|
|
191
228
|
error = payload.get("error") if isinstance(payload, dict) else None
|
|
192
229
|
expected_code = code_by_status[response.status_code]
|
|
193
230
|
if not isinstance(error, dict):
|
|
@@ -65,6 +65,8 @@ class InMemoryRateLimitStore:
|
|
|
65
65
|
class RedisRateLimitStore:
|
|
66
66
|
"""Atomic fixed-window store backed by Redis for multi-worker deployments."""
|
|
67
67
|
|
|
68
|
+
DEFAULT_TIMEOUT_SECONDS = 3.0
|
|
69
|
+
|
|
68
70
|
_SCRIPT = """
|
|
69
71
|
local count = redis.call('INCR', KEYS[1])
|
|
70
72
|
if count == 1 then
|
|
@@ -74,14 +76,29 @@ class RedisRateLimitStore:
|
|
|
74
76
|
return {count, ttl}
|
|
75
77
|
"""
|
|
76
78
|
|
|
77
|
-
def __init__(
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
url: str,
|
|
82
|
+
namespace: str = "sdm:rate-limit:",
|
|
83
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
84
|
+
) -> None:
|
|
78
85
|
if not url:
|
|
79
86
|
raise ValueError("Redis URL is required when rate-limit backend is redis")
|
|
87
|
+
if timeout_seconds <= 0:
|
|
88
|
+
raise ValueError("timeout_seconds must be positive")
|
|
80
89
|
try:
|
|
81
90
|
import redis
|
|
82
91
|
except ImportError as exc:
|
|
83
92
|
raise RuntimeError("Redis rate limiting requires the optional 'redis' package") from exc
|
|
84
|
-
|
|
93
|
+
# Bound both connection establishment and command execution. A
|
|
94
|
+
# stalled Redis node must not block API startup or every request that
|
|
95
|
+
# checks the distributed rate-limit store.
|
|
96
|
+
self._client = redis.Redis.from_url(
|
|
97
|
+
url,
|
|
98
|
+
decode_responses=False,
|
|
99
|
+
socket_connect_timeout=timeout_seconds,
|
|
100
|
+
socket_timeout=timeout_seconds,
|
|
101
|
+
)
|
|
85
102
|
self._client.ping()
|
|
86
103
|
self._script = self._client.register_script(self._SCRIPT)
|
|
87
104
|
self._namespace = namespace
|
|
@@ -7,6 +7,7 @@ import logging
|
|
|
7
7
|
import os
|
|
8
8
|
import secrets
|
|
9
9
|
import time
|
|
10
|
+
import uuid
|
|
10
11
|
import weakref
|
|
11
12
|
from datetime import datetime
|
|
12
13
|
from typing import Any
|
|
@@ -16,6 +17,8 @@ from fastapi.responses import JSONResponse
|
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger(__name__)
|
|
18
19
|
|
|
20
|
+
from backend.core.config import settings
|
|
21
|
+
|
|
19
22
|
_READINESS_CACHE_TTL_SECONDS = 5.0
|
|
20
23
|
_READINESS_TIMEOUT_SECONDS = 3.0
|
|
21
24
|
_READINESS_LOCKS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
|
|
@@ -26,22 +29,37 @@ _HEALTH_PROBE_PATHS = {"/ready", "/health/deep"}
|
|
|
26
29
|
|
|
27
30
|
def _probe_allowed(request: Request) -> bool:
|
|
28
31
|
"""Allow health probes only from loopback or with the configured probe secret."""
|
|
29
|
-
|
|
32
|
+
configured = settings.health_probe_token
|
|
33
|
+
token = configured.get_secret_value().strip() if configured else ""
|
|
34
|
+
# Keep process-level test/dev overrides effective after module import;
|
|
35
|
+
# production configuration still comes from the validated singleton above
|
|
36
|
+
# (including values loaded from .env).
|
|
37
|
+
if not token:
|
|
38
|
+
token = os.environ.get("SDM_HEALTH_PROBE_TOKEN", "").strip()
|
|
30
39
|
supplied = request.headers.get("X-Health-Probe-Token", "")
|
|
31
40
|
if token and supplied and secrets.compare_digest(supplied, token):
|
|
32
41
|
return True
|
|
42
|
+
# Once a token is configured, require it even for loopback callers. This
|
|
43
|
+
# avoids trusting an unverified X-Forwarded-For chain in front of a proxy.
|
|
44
|
+
if token:
|
|
45
|
+
return False
|
|
33
46
|
client = request.client
|
|
34
47
|
return bool(client and client.host in {"127.0.0.1", "::1", "localhost"})
|
|
35
48
|
|
|
36
49
|
|
|
37
|
-
def _health_probe_denied_response() -> JSONResponse:
|
|
50
|
+
def _health_probe_denied_response(request: Request) -> JSONResponse:
|
|
38
51
|
"""Return a stable denial response for public health probe requests."""
|
|
52
|
+
request_id = getattr(request.state, "request_id", None) or str(uuid.uuid4())
|
|
53
|
+
headers = {"X-Request-ID": request_id}
|
|
54
|
+
content = {
|
|
55
|
+
"success": False,
|
|
56
|
+
"error": {"code": 403, "message": "health probe access denied"},
|
|
57
|
+
}
|
|
58
|
+
content["request_id"] = request_id
|
|
39
59
|
return JSONResponse(
|
|
40
60
|
status_code=403,
|
|
41
|
-
content=
|
|
42
|
-
|
|
43
|
-
"error": {"code": 403, "message": "health probe access denied"},
|
|
44
|
-
},
|
|
61
|
+
content=content,
|
|
62
|
+
headers=headers,
|
|
45
63
|
)
|
|
46
64
|
|
|
47
65
|
|
|
@@ -50,7 +68,7 @@ async def health_probe_response(request: Request) -> JSONResponse:
|
|
|
50
68
|
if request.url.path not in _HEALTH_PROBE_PATHS or request.method not in {"GET", "HEAD"}:
|
|
51
69
|
raise ValueError("health_probe_response called for a non-probe request")
|
|
52
70
|
if not _probe_allowed(request):
|
|
53
|
-
return _health_probe_denied_response()
|
|
71
|
+
return _health_probe_denied_response(request)
|
|
54
72
|
if request.url.path == "/ready":
|
|
55
73
|
return await readiness_response(request)
|
|
56
74
|
return await deep_health_response(request)
|