sql-dialect-master 1.1.6__tar.gz → 1.1.8__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.
Files changed (68) hide show
  1. {sql_dialect_master-1.1.6/sql_dialect_master.egg-info → sql_dialect_master-1.1.8}/PKG-INFO +17 -2
  2. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/README.md +15 -1
  3. sql_dialect_master-1.1.8/backend/api/client_identity.py +57 -0
  4. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/main.py +39 -5
  5. sql_dialect_master-1.1.8/backend/api/metrics.py +171 -0
  6. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/middleware.py +51 -7
  7. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/rate_limit_store.py +2 -2
  8. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/workers.py +34 -2
  9. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/config.py +48 -0
  10. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/metadata.py +2 -2
  11. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/pyproject.toml +18 -1
  12. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8/sql_dialect_master.egg-info}/PKG-INFO +17 -2
  13. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/SOURCES.txt +2 -0
  14. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/requires.txt +1 -0
  15. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/LICENSE +0 -0
  16. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/__init__.py +0 -0
  17. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/__init__.py +0 -0
  18. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/auth.py +0 -0
  19. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/api/readiness.py +0 -0
  20. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/__init__.py +0 -0
  21. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/cache.py +0 -0
  22. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/column_hint_validation.py +0 -0
  23. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/constants.py +0 -0
  24. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/exceptions.py +0 -0
  25. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/function_call_scanner.py +0 -0
  26. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/function_semantics.py +0 -0
  27. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/functions_db.json +0 -0
  28. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/functions_lookup.py +0 -0
  29. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql.py +0 -0
  30. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/__init__.py +0 -0
  31. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/aggregations.py +0 -0
  32. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
  33. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/core_extract.py +0 -0
  34. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/evidence.py +0 -0
  35. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/mappings.py +0 -0
  36. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/relations.py +0 -0
  37. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/templates.py +0 -0
  38. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_components/tokenizer.py +0 -0
  39. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_legacy.py +0 -0
  40. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/__init__.py +0 -0
  41. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
  42. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
  43. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/dialect.py +0 -0
  44. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/intent.py +0 -0
  45. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/ir.py +0 -0
  46. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/models.py +0 -0
  47. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/orchestrator.py +0 -0
  48. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/predicates.py +0 -0
  49. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/renderer.py +0 -0
  50. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/resolution.py +0 -0
  51. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
  52. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/templates.py +0 -0
  53. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/nl2sql_pipeline/validation.py +0 -0
  54. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/p1_sql_scanner.py +0 -0
  55. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/p2_data_validation.py +0 -0
  56. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/parser.py +0 -0
  57. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/post_processor.py +0 -0
  58. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/rules.py +0 -0
  59. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/semantic_diff.py +0 -0
  60. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/sql_statements.py +0 -0
  61. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/transpiler.py +0 -0
  62. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/type_mapping.json +0 -0
  63. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/core/type_mapping.py +0 -0
  64. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/utils/__init__.py +0 -0
  65. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/backend/utils/validators.py +0 -0
  66. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/setup.cfg +0 -0
  67. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
  68. {sql_dialect_master-1.1.6 → sql_dialect_master-1.1.8}/sql_dialect_master.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-dialect-master
3
- Version: 1.1.6
3
+ Version: 1.1.8
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -10,6 +10,7 @@ Requires-Dist: fastapi>=0.115.0
10
10
  Requires-Dist: uvicorn>=0.30.0
11
11
  Requires-Dist: pydantic>=2.9.0
12
12
  Requires-Dist: pydantic-settings>=2.0.0
13
+ Requires-Dist: prometheus-client<1.0.0,>=0.20.0
13
14
  Provides-Extra: dev
14
15
  Requires-Dist: hypothesis>=6.112.0; extra == "dev"
15
16
  Requires-Dist: pytest>=8.3.0; extra == "dev"
@@ -35,7 +36,7 @@ Dynamic: license-file
35
36
 
36
37
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
37
38
 
38
- Current release: 1.1.6
39
+ Current release: 1.1.8
39
40
 
40
41
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
41
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -359,3 +360,17 @@ Rate limiting is on by default. The two backends are:
359
360
  - `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
360
361
 
361
362
  `SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
363
+
364
+ By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
365
+ use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
366
+ only when the direct peer is a trusted proxy, after which the rightmost
367
+ non-trusted address in the XFF chain is used. Deployments behind a proxy must
368
+ also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
369
+ uses XFF for authorization.
370
+
371
+ SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
372
+ public catalog responses use short `private` caching. Full deployment details
373
+ will be documented in `docs/deployment.md`.
374
+
375
+ See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
376
+ Metrics configuration and scraping guidance are documented in the same guide.
@@ -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.6
5
+ Current release: 1.1.8
6
6
 
7
7
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -326,3 +326,17 @@ Rate limiting is on by default. The two backends are:
326
326
  - `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
327
327
 
328
328
  `SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
329
+
330
+ By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
331
+ use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
332
+ only when the direct peer is a trusted proxy, after which the rightmost
333
+ non-trusted address in the XFF chain is used. Deployments behind a proxy must
334
+ also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
335
+ uses XFF for authorization.
336
+
337
+ SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
338
+ public catalog responses use short `private` caching. Full deployment details
339
+ will be documented in `docs/deployment.md`.
340
+
341
+ See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
342
+ Metrics configuration and scraping guidance are documented in the same guide.
@@ -0,0 +1,57 @@
1
+ """Resolve client IPs without trusting spoofable forwarding headers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ from collections.abc import Iterable
8
+
9
+ from fastapi import Request
10
+
11
+ _BRACKETED_IPV6 = re.compile(r"^\[([^\]]+)\](?::\d+)?$")
12
+
13
+
14
+ def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
15
+ candidate = value.strip()
16
+ if not candidate or "%" in candidate:
17
+ return None
18
+ bracketed = _BRACKETED_IPV6.fullmatch(candidate)
19
+ if bracketed:
20
+ candidate = bracketed.group(1)
21
+ elif candidate.count(":") == 1:
22
+ host, port = candidate.rsplit(":", 1)
23
+ if port.isdigit():
24
+ candidate = host
25
+ try:
26
+ return ipaddress.ip_address(candidate)
27
+ except ValueError:
28
+ return None
29
+
30
+
31
+ def _is_trusted(
32
+ address: ipaddress.IPv4Address | ipaddress.IPv6Address,
33
+ trusted_networks: Iterable,
34
+ ) -> bool:
35
+ return any(
36
+ address.version == network.version and address in network
37
+ for network in trusted_networks
38
+ )
39
+
40
+
41
+ def resolve_client_ip(request: Request, trusted_networks: tuple = ()) -> str:
42
+ """Resolve the client address, trusting XFF only behind trusted peers."""
43
+ peer = request.client.host if request.client else "unknown"
44
+ if not trusted_networks or peer == "unknown":
45
+ return peer
46
+
47
+ peer_address = _parse_ip(peer)
48
+ if peer_address is None or not _is_trusted(peer_address, trusted_networks):
49
+ return peer
50
+
51
+ forwarded_for = request.headers.get("X-Forwarded-For", "")
52
+ for value in reversed(forwarded_for.split(",")):
53
+ address = _parse_ip(value)
54
+ if address is None or _is_trusted(address, trusted_networks):
55
+ continue
56
+ return str(address)
57
+ return peer
@@ -30,6 +30,7 @@ from fastapi.responses import JSONResponse
30
30
  from pydantic import BaseModel, ConfigDict, Field
31
31
 
32
32
  from backend.api.auth import require_api_access
33
+ from backend.api.metrics import MetricsMiddleware, configure_runtime_metrics, metrics_response, record_transpile_request
33
34
  from backend.api.rate_limit_store import InMemoryRateLimitStore
34
35
  from backend.api.readiness import health_probe_response
35
36
  from backend.api.workers import BoundedWorkerPool, WorkerCapacityError
@@ -235,6 +236,7 @@ app = FastAPI(
235
236
 
236
237
  # Import middleware
237
238
  from backend.api.middleware import (
239
+ CacheControlMiddleware,
238
240
  RateLimiter,
239
241
  RateLimitMiddleware,
240
242
  SecurityHeadersMiddleware,
@@ -244,6 +246,7 @@ from backend.core.exceptions import SDMException, UnsupportedDialectError, Error
244
246
 
245
247
  # Configure CORS from the centralized Pydantic settings source.
246
248
  ALLOWED_ORIGINS = [origin.strip() for origin in settings.allowed_origins.split(",") if origin.strip()]
249
+ TRUSTED_PROXY_NETWORKS = settings.trusted_proxy_networks
247
250
 
248
251
  app.add_middleware(
249
252
  CORSMiddleware,
@@ -285,10 +288,15 @@ app.add_middleware(
285
288
  RateLimitMiddleware,
286
289
  limiter=rate_limiter,
287
290
  enabled=settings.rate_limit_enabled,
291
+ trusted_proxies=TRUSTED_PROXY_NETWORKS,
288
292
  )
289
293
 
290
294
  # Structured request logging must wrap rate limiting so rejected requests also get IDs.
291
- app.add_middleware(StructuredLoggingMiddleware)
295
+ app.add_middleware(
296
+ StructuredLoggingMiddleware,
297
+ trusted_proxies=TRUSTED_PROXY_NETWORKS,
298
+ )
299
+ app.add_middleware(CacheControlMiddleware)
292
300
 
293
301
  # Initialize services
294
302
  transpiler = SQLTranspiler()
@@ -296,6 +304,12 @@ func_encyclopedia = FunctionEncyclopedia()
296
304
  type_mapper = TypeMapper()
297
305
  nl2sql_generator = NL2SQLGenerator()
298
306
 
307
+ configure_runtime_metrics(
308
+ transpiler_provider=lambda: transpiler,
309
+ worker_provider=_get_worker_pool,
310
+ limiter_provider=lambda: rate_limiter,
311
+ )
312
+
299
313
  # Request timing middleware with logging
300
314
  @app.middleware("http")
301
315
  async def add_process_time_header(request: Request, call_next):
@@ -313,6 +327,11 @@ async def add_process_time_header(request: Request, call_next):
313
327
  response.status_code, process_time)
314
328
  return response
315
329
 
330
+
331
+ # This is the outermost application middleware, so it also observes short
332
+ # circuit responses returned by rate limiting and worker-capacity guards.
333
+ app.add_middleware(MetricsMiddleware)
334
+
316
335
  # Dialect info for enhanced responses (from centralized config)
317
336
  DIALECT_INFO = get_dialect_api_info()
318
337
 
@@ -453,6 +472,10 @@ class APIResponse(BaseModel):
453
472
 
454
473
  # === API Endpoints ===
455
474
 
475
+ @app.get("/metrics", include_in_schema=False)
476
+ async def metrics_endpoint(request: Request):
477
+ return await metrics_response(request)
478
+
456
479
  @app.get("/", tags=["system"])
457
480
  async def root():
458
481
  """
@@ -553,11 +576,13 @@ async def convert_sql(request: ConvertRequest):
553
576
  source_dialect = request.source_dialect.strip().lower()
554
577
  target_dialect = request.target_dialect.strip().lower()
555
578
  if source_dialect not in SUPPORTED_DIALECTS:
579
+ record_transpile_request("unknown", "unknown", "validation_error")
556
580
  raise HTTPException(
557
581
  status_code=400,
558
582
  detail=f"Unsupported source dialect: {request.source_dialect}. Supported: {SUPPORTED_DIALECTS}"
559
583
  )
560
584
  if target_dialect not in SUPPORTED_DIALECTS:
585
+ record_transpile_request(source_dialect, "unknown", "validation_error")
561
586
  raise HTTPException(
562
587
  status_code=400,
563
588
  detail=f"Unsupported target dialect: {request.target_dialect}. Supported: {SUPPORTED_DIALECTS}"
@@ -565,12 +590,21 @@ async def convert_sql(request: ConvertRequest):
565
590
 
566
591
  # sqlglot parsing/transpilation is CPU-bound. Keep it off the event loop
567
592
  # so one large request cannot stall unrelated async requests.
568
- result = await _run_cpu_bound(
569
- transpiler.transpile,
570
- request.sql,
593
+ try:
594
+ result = await _run_cpu_bound(
595
+ transpiler.transpile,
596
+ request.sql,
597
+ source_dialect,
598
+ target_dialect,
599
+ request.pretty,
600
+ )
601
+ except HTTPException:
602
+ record_transpile_request(source_dialect, target_dialect, "error")
603
+ raise
604
+ record_transpile_request(
571
605
  source_dialect,
572
606
  target_dialect,
573
- request.pretty,
607
+ "success" if result.success else "error",
574
608
  )
575
609
  return ConvertResponse(
576
610
  success=result.success,
@@ -0,0 +1,171 @@
1
+ """Protected Prometheus metrics for the API process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from collections.abc import Callable
7
+
8
+ from fastapi import Request
9
+ from fastapi.responses import PlainTextResponse, Response
10
+ from prometheus_client import (
11
+ CONTENT_TYPE_LATEST,
12
+ CollectorRegistry,
13
+ Counter,
14
+ Gauge,
15
+ Histogram,
16
+ generate_latest,
17
+ )
18
+ from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
19
+
20
+ from backend.core.config import settings
21
+ from backend.core.metadata import PROJECT_VERSION
22
+
23
+ REGISTRY = CollectorRegistry(auto_describe=True)
24
+
25
+ BUILD_INFO = Gauge("sdm_build_info", "SQL Dialect Master build information", ["version"], registry=REGISTRY)
26
+ BUILD_INFO.labels(version=PROJECT_VERSION).set(1)
27
+
28
+ HTTP_REQUESTS = Counter(
29
+ "sdm_http_requests_total",
30
+ "HTTP requests handled by the API",
31
+ ["method", "route", "status_class"],
32
+ registry=REGISTRY,
33
+ )
34
+ HTTP_DURATION = Histogram(
35
+ "sdm_http_request_duration_seconds",
36
+ "HTTP request duration in seconds",
37
+ ["method", "route", "status_class"],
38
+ registry=REGISTRY,
39
+ )
40
+ RATE_LIMIT_REJECTIONS = Counter(
41
+ "sdm_rate_limit_rejections_total",
42
+ "Rate-limited requests",
43
+ registry=REGISTRY,
44
+ )
45
+ TRANSPILE_REQUESTS = Counter(
46
+ "sdm_transpile_requests_total",
47
+ "Conversion requests by dialect pair and result",
48
+ ["source", "target", "result"],
49
+ registry=REGISTRY,
50
+ )
51
+
52
+ _transpiler_provider: Callable[[], object] | None = None
53
+ _worker_provider: Callable[[], object] | None = None
54
+ _limiter_provider: Callable[[], object] | None = None
55
+
56
+
57
+ def configure_runtime_metrics(*, transpiler_provider=None, worker_provider=None, limiter_provider=None) -> None:
58
+ """Register process-local runtime providers used when /metrics is scraped."""
59
+ global _transpiler_provider, _worker_provider, _limiter_provider
60
+ _transpiler_provider = transpiler_provider
61
+ _worker_provider = worker_provider
62
+ _limiter_provider = limiter_provider
63
+
64
+
65
+ class RuntimeCollector:
66
+ """Read low-frequency runtime snapshots without copying them per request."""
67
+
68
+ def collect(self):
69
+ if _transpiler_provider is not None:
70
+ try:
71
+ stats = _transpiler_provider().get_stats()
72
+ cache = stats.get("cache", {})
73
+ if cache.get("enabled", True) is not False:
74
+ yield CounterMetricFamily("sdm_transpile_cache_hits_total", "Transpiler cache hits", value=cache.get("hits", 0))
75
+ yield CounterMetricFamily("sdm_transpile_cache_misses_total", "Transpiler cache misses", value=cache.get("misses", 0))
76
+ yield CounterMetricFamily("sdm_transpile_cache_evictions_total", "Transpiler cache evictions", value=cache.get("evictions", 0))
77
+ yield GaugeMetricFamily("sdm_transpile_cache_size", "Current transpiler cache size", value=cache.get("size", 0))
78
+ except Exception:
79
+ pass
80
+
81
+ if _worker_provider is not None:
82
+ try:
83
+ stats = _worker_provider().stats()
84
+ yield GaugeMetricFamily("sdm_api_worker_capacity", "API worker pool capacity", value=stats["capacity"])
85
+ yield GaugeMetricFamily("sdm_api_worker_in_use", "API worker slots in use", value=stats["in_use"])
86
+ yield CounterMetricFamily("sdm_api_worker_rejections_total", "API worker capacity rejections", value=stats["rejections"])
87
+ yield CounterMetricFamily("sdm_api_request_timeouts_total", "API request timeouts", value=stats["timeouts"])
88
+ except Exception:
89
+ pass
90
+
91
+ if _limiter_provider is not None:
92
+ try:
93
+ stats = _limiter_provider().get_stats()
94
+ if stats.get("backend") == "memory":
95
+ yield GaugeMetricFamily("sdm_rate_limit_active_clients", "Active in-memory rate-limit clients", value=stats.get("active_clients", 0))
96
+ except Exception:
97
+ pass
98
+
99
+
100
+ REGISTRY.register(RuntimeCollector())
101
+
102
+
103
+ def normalize_method(method: str) -> str:
104
+ return method if method in {"GET", "POST", "HEAD", "OPTIONS"} else "other"
105
+
106
+
107
+ def route_template(request: Request) -> str:
108
+ route = request.scope.get("route")
109
+ template = getattr(route, "path_format", None) or getattr(route, "path", None)
110
+ return template if template else "other"
111
+
112
+
113
+ def status_class(status_code: int) -> str:
114
+ return f"{status_code // 100}xx" if 100 <= status_code < 600 else "other"
115
+
116
+
117
+ def record_http_request(request: Request, status_code: int, duration: float) -> None:
118
+ if request.url.path == "/metrics":
119
+ return
120
+ labels = (normalize_method(request.method), route_template(request), status_class(status_code))
121
+ HTTP_REQUESTS.labels(*labels).inc()
122
+ HTTP_DURATION.labels(*labels).observe(duration)
123
+
124
+
125
+ def record_rate_limit_rejection() -> None:
126
+ RATE_LIMIT_REJECTIONS.inc()
127
+
128
+
129
+ def record_transpile_request(source: str, target: str, result: str) -> None:
130
+ TRANSPILE_REQUESTS.labels(source=source, target=target, result=result).inc()
131
+
132
+
133
+ def _authorized(request: Request) -> bool:
134
+ configured = settings.metrics_token
135
+ expected = configured.get_secret_value().strip() if configured else ""
136
+ supplied = request.headers.get("X-Metrics-Token", "")
137
+ return bool(expected and supplied and secrets.compare_digest(supplied, expected))
138
+
139
+
140
+ async def metrics_response(request: Request) -> Response:
141
+ if not settings.metrics_enabled:
142
+ return Response(status_code=404)
143
+ if not _authorized(request):
144
+ return Response(status_code=401, content="Metrics token required")
145
+ return PlainTextResponse(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
146
+
147
+
148
+ class MetricsMiddleware:
149
+ """ASGI middleware that records final HTTP status and elapsed time."""
150
+
151
+ def __init__(self, app):
152
+ self.app = app
153
+
154
+ async def __call__(self, scope, receive, send):
155
+ if scope.get("type") != "http":
156
+ await self.app(scope, receive, send)
157
+ return
158
+ import time
159
+
160
+ started = time.perf_counter()
161
+ status_code = 500
162
+
163
+ async def send_wrapper(message):
164
+ nonlocal status_code
165
+ if message.get("type") == "http.response.start":
166
+ status_code = message.get("status", 500)
167
+ await send(message)
168
+
169
+ await self.app(scope, receive, send_wrapper)
170
+ request = Request(scope)
171
+ record_http_request(request, status_code, time.perf_counter() - started)
@@ -19,6 +19,8 @@ from starlette.concurrency import run_in_threadpool
19
19
 
20
20
  from backend.core.exceptions import ErrorCode
21
21
 
22
+ from .client_identity import resolve_client_ip
23
+ from .metrics import record_rate_limit_rejection
22
24
  from .rate_limit_store import InMemoryRateLimitStore, RateLimitStore, create_rate_limit_store
23
25
  from .readiness import health_probe_response
24
26
 
@@ -35,7 +37,7 @@ def _sanitize_log_value(value: str, max_length: int = 500) -> str:
35
37
  class RateLimitEntry:
36
38
  """Legacy compatibility entry."""
37
39
  requests: int = 0
38
- window_start: float = field(default_factory=time.time)
40
+ window_start: float = field(default_factory=time.monotonic)
39
41
 
40
42
 
41
43
  class RateLimiter:
@@ -67,13 +69,14 @@ class RateLimiter:
67
69
  class RateLimitMiddleware(BaseHTTPMiddleware):
68
70
  """Rate limiting middleware with a bounded request-body guard."""
69
71
 
70
- def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES):
72
+ def __init__(self, app, limiter: RateLimiter = None, enabled: bool = True, max_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES, trusted_proxies: tuple = ()):
71
73
  super().__init__(app)
72
74
  if max_body_bytes <= 0:
73
75
  raise ValueError("max_body_bytes must be positive")
74
76
  self.limiter = limiter or RateLimiter(store=None if enabled else InMemoryRateLimitStore())
75
77
  self.enabled = enabled
76
78
  self.max_body_bytes = max_body_bytes
79
+ self.trusted_proxies = trusted_proxies
77
80
 
78
81
  async def _read_bounded_body(self, request: Request) -> Response | None:
79
82
  """Consume and cache request bodies without trusting Content-Length."""
@@ -106,7 +109,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
106
109
 
107
110
  async def dispatch(self, request: Request, call_next) -> Response:
108
111
  is_health_probe = (
109
- request.url.path in {"/health", "/", "/ready", "/health/deep"}
112
+ request.url.path in {"/health", "/", "/ready", "/health/deep", "/metrics"}
110
113
  and request.method in {"GET", "HEAD"}
111
114
  )
112
115
  # Browser CORS preflight requests carry no business payload and are
@@ -131,6 +134,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
131
134
  is_allowed, remaining, reset_time = await run_in_threadpool(self.limiter.is_allowed, client_id)
132
135
  rate_limit_checked = True
133
136
  if not is_allowed:
137
+ record_rate_limit_rejection()
134
138
  logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
135
139
  return JSONResponse(
136
140
  status_code=429,
@@ -162,6 +166,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
162
166
  if is_health_probe:
163
167
  if request.url.path in {"/ready", "/health/deep"}:
164
168
  return await health_probe_response(request)
169
+ if request.url.path == "/metrics":
170
+ from .metrics import metrics_response
171
+ return await metrics_response(request)
165
172
  return await call_next(request)
166
173
  response = await call_next(request)
167
174
  if rate_limit_checked:
@@ -171,15 +178,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
171
178
  return response
172
179
 
173
180
  def _get_client_id(self, request: Request) -> str:
174
- return request.client.host if request.client else "unknown"
181
+ return resolve_client_ip(request, self.trusted_proxies)
175
182
 
176
183
 
177
184
  class StructuredLoggingMiddleware(BaseHTTPMiddleware):
178
185
  """Structured request logging and request-id propagation."""
179
186
 
180
- def __init__(self, app, log_body: bool = False):
187
+ def __init__(self, app, log_body: bool = False, trusted_proxies: tuple = ()):
181
188
  super().__init__(app)
182
189
  self.log_body = log_body
190
+ self.trusted_proxies = trusted_proxies
183
191
 
184
192
  @staticmethod
185
193
  def _get_request_id(request: Request) -> str:
@@ -346,7 +354,7 @@ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
346
354
  request.state.request_id = request_id
347
355
  status_code = 500
348
356
  error = None
349
- log_data = {"event": "request_start", "request_id": request_id, "method": _sanitize_log_value(request.method), "path": _sanitize_log_value(request.url.path), "query": _sanitize_log_value(str(request.query_params)), "client": _sanitize_log_value(request.client.host if request.client else "unknown"), "timestamp": datetime.now().isoformat()}
357
+ log_data = {"event": "request_start", "request_id": request_id, "method": _sanitize_log_value(request.method), "path": _sanitize_log_value(request.url.path), "query": _sanitize_log_value(str(request.query_params)), "client": _sanitize_log_value(resolve_client_ip(request, self.trusted_proxies)), "timestamp": datetime.now().isoformat()}
350
358
  logger.info(json.dumps(log_data))
351
359
  try:
352
360
  response = await call_next(request)
@@ -370,6 +378,42 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
370
378
  response = await call_next(request)
371
379
  response.headers["X-Content-Type-Options"] = "nosniff"
372
380
  response.headers["X-Frame-Options"] = "DENY"
373
- response.headers["X-XSS-Protection"] = "1; mode=block"
381
+ response.headers["X-XSS-Protection"] = "0"
374
382
  response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
375
383
  return response
384
+
385
+
386
+ class CacheControlMiddleware(BaseHTTPMiddleware):
387
+ """Apply explicit browser/proxy caching policy to API responses."""
388
+
389
+ @staticmethod
390
+ def _policy(request: Request) -> str | None:
391
+ if request.method == "OPTIONS":
392
+ return None
393
+ path = request.url.path
394
+ if path == "/metrics":
395
+ return "no-store"
396
+ if path in {"/docs", "/redoc", "/openapi.json"}:
397
+ return None
398
+ if request.method == "POST" and path.startswith("/api/"):
399
+ return "no-store"
400
+ if request.method in {"GET", "HEAD"}:
401
+ if path in {"/health", "/ready", "/health/deep"}:
402
+ return "no-store"
403
+ if path == "/api/dialects":
404
+ return "private, max-age=60"
405
+ if path == "/api/functions" or path.startswith("/api/functions/"):
406
+ return "private, max-age=60"
407
+ if path == "/api/types" or path.startswith("/api/types/"):
408
+ return "private, max-age=60"
409
+ if path in {"/", "/api/stats"}:
410
+ return "private, max-age=15"
411
+ return None
412
+
413
+ async def dispatch(self, request: Request, call_next) -> Response:
414
+ response = await call_next(request)
415
+ if not response.headers.get("Cache-Control"):
416
+ policy = self._policy(request)
417
+ if policy:
418
+ response.headers["Cache-Control"] = policy
419
+ return response
@@ -44,7 +44,7 @@ class InMemoryRateLimitStore:
44
44
 
45
45
  def check(self, key: str, limit: int, window_seconds: int) -> tuple[bool, int, int]:
46
46
  with self._lock:
47
- now = time.time()
47
+ now = time.monotonic()
48
48
  self._prune_expired(now)
49
49
  count, window_start = self._entries.get(key, (0, now))
50
50
  if now - window_start >= window_seconds:
@@ -58,7 +58,7 @@ class InMemoryRateLimitStore:
58
58
 
59
59
  def stats(self) -> dict:
60
60
  with self._lock:
61
- self._prune_expired(time.time())
61
+ self._prune_expired(time.monotonic())
62
62
  return {"backend": "memory", "active_clients": len(self._entries)}
63
63
 
64
64
 
@@ -24,22 +24,54 @@ class BoundedWorkerPool:
24
24
  self._slots = BoundedSemaphore(workers + queue_size)
25
25
  self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="sdm-api")
26
26
  self._shutdown_lock = Lock()
27
+ self._stats_lock = Lock()
28
+ self._capacity = workers + queue_size
29
+ self._in_use = 0
30
+ self._rejections = 0
31
+ self._timeouts = 0
27
32
  self._shutdown = False
28
33
 
29
34
  async def run(self, function, *args, timeout: float):
30
35
  if not self._slots.acquire(blocking=False):
36
+ with self._stats_lock:
37
+ self._rejections += 1
31
38
  raise WorkerCapacityError("Request processing capacity is exhausted")
39
+ with self._stats_lock:
40
+ self._in_use += 1
32
41
  try:
33
42
  future = self._executor.submit(function, *args)
34
43
  except BaseException:
35
44
  self._slots.release()
45
+ with self._stats_lock:
46
+ self._in_use -= 1
36
47
  raise
37
- future.add_done_callback(lambda _future: self._slots.release())
48
+
49
+ def release_slot(_future):
50
+ self._slots.release()
51
+ with self._stats_lock:
52
+ self._in_use -= 1
53
+
54
+ future.add_done_callback(release_slot)
38
55
  wrapped = asyncio.wrap_future(future)
39
56
  # A timed-out caller no longer awaits this result. Retrieve exceptions
40
57
  # to avoid an unhandled-future warning when background work later fails.
41
58
  wrapped.add_done_callback(lambda completed: None if completed.cancelled() else completed.exception())
42
- return await asyncio.wait_for(asyncio.shield(wrapped), timeout=timeout)
59
+ try:
60
+ return await asyncio.wait_for(asyncio.shield(wrapped), timeout=timeout)
61
+ except asyncio.TimeoutError:
62
+ with self._stats_lock:
63
+ self._timeouts += 1
64
+ raise
65
+
66
+ def stats(self) -> dict[str, int]:
67
+ """Return a thread-safe snapshot of pool capacity counters."""
68
+ with self._stats_lock:
69
+ return {
70
+ "capacity": self._capacity,
71
+ "in_use": self._in_use,
72
+ "rejections": self._rejections,
73
+ "timeouts": self._timeouts,
74
+ }
43
75
 
44
76
  def shutdown(self) -> None:
45
77
  """Cancel queued work; already-running calls retain their slots."""
@@ -10,6 +10,7 @@ Supports configuration via:
10
10
  - Default values
11
11
  """
12
12
  import logging
13
+ import ipaddress
13
14
  import sys
14
15
  from enum import Enum
15
16
  from typing import Any, Dict, List, Literal, Optional, TypedDict
@@ -225,6 +226,7 @@ class AppSettings(BaseSettings):
225
226
  "http://localhost:5174,http://127.0.0.1:5174,"
226
227
  "http://localhost:4174,http://127.0.0.1:4174"
227
228
  )
229
+ trusted_proxies: str = ""
228
230
  max_batch_size: int = Field(100, gt=0)
229
231
  request_timeout: int = Field(30, gt=0)
230
232
 
@@ -274,6 +276,10 @@ class AppSettings(BaseSettings):
274
276
  # Readiness probes. SecretStr keeps the value out of settings reprs and
275
277
  # structured logs while still allowing deployment through SDM_ env vars.
276
278
  health_probe_token: Optional[SecretStr] = None
279
+
280
+ # Prometheus metrics are opt-in and use a dedicated secret.
281
+ metrics_enabled: bool = False
282
+ metrics_token: Optional[SecretStr] = None
277
283
 
278
284
  # Security settings
279
285
  security_check_enabled: bool = True
@@ -290,6 +296,14 @@ class AppSettings(BaseSettings):
290
296
  self.parser_max_sql_length = self.transpiler_max_sql_length
291
297
  return self
292
298
 
299
+ @model_validator(mode="after")
300
+ def _validate_metrics_token(self) -> "AppSettings":
301
+ """Require a non-blank dedicated token when metrics are enabled."""
302
+ token = self.metrics_token.get_secret_value().strip() if self.metrics_token else ""
303
+ if self.metrics_enabled and not token:
304
+ raise ValueError("metrics_token is required when metrics_enabled is true")
305
+ return self
306
+
293
307
  @field_validator("log_level", mode="before")
294
308
  @classmethod
295
309
  def _normalize_log_level(cls, value: str) -> str:
@@ -299,6 +313,40 @@ class AppSettings(BaseSettings):
299
313
  "log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL"
300
314
  )
301
315
  return normalized
316
+
317
+ @field_validator("trusted_proxies", mode="before")
318
+ @classmethod
319
+ def _normalize_trusted_proxies(cls, value: str) -> str:
320
+ if value is None:
321
+ return ""
322
+ if not isinstance(value, str):
323
+ raise ValueError("trusted_proxies must be a comma-separated string")
324
+ normalized = []
325
+ for raw_value in value.split(","):
326
+ candidate = raw_value.strip()
327
+ if not candidate:
328
+ continue
329
+ try:
330
+ network = ipaddress.ip_network(candidate, strict=False)
331
+ except ValueError as exc:
332
+ raise ValueError(
333
+ f"trusted_proxies contains an invalid IP/CIDR: {candidate!r}"
334
+ ) from exc
335
+ if network.prefixlen == 0:
336
+ raise ValueError("trusted_proxies must not contain a catch-all network")
337
+ normalized.append(str(network))
338
+ return ",".join(normalized)
339
+
340
+ @property
341
+ def trusted_proxy_networks(self) -> tuple:
342
+ """Return parsed trusted proxy networks for request identity checks."""
343
+ if not self.trusted_proxies:
344
+ return ()
345
+ return tuple(
346
+ ipaddress.ip_network(value, strict=False)
347
+ for value in self.trusted_proxies.split(",")
348
+ if value
349
+ )
302
350
 
303
351
  def to_dict(self) -> Dict[str, Any]:
304
352
  """Convert settings to dictionary."""
@@ -17,8 +17,8 @@ Design rules:
17
17
  from __future__ import annotations
18
18
 
19
19
  # ── Release version facts (sole source of truth) ────────────────────────
20
- PROJECT_VERSION = "1.1.6"
21
- API_VERSION = "1.1.6"
20
+ PROJECT_VERSION = "1.1.8"
21
+ API_VERSION = "1.1.8"
22
22
  # Frontend v2 architecture — independent of the product release version.
23
23
  FRONTEND_ARCHITECTURE_VERSION = 2
24
24
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-dialect-master"
3
- version = "1.1.6"
3
+ version = "1.1.8"
4
4
  description = "SQL Dialect Master - Multi-database SQL conversion engine"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -10,6 +10,7 @@ dependencies = [
10
10
  "uvicorn>=0.30.0",
11
11
  "pydantic>=2.9.0",
12
12
  "pydantic-settings>=2.0.0",
13
+ "prometheus-client>=0.20.0,<1.0.0",
13
14
  ]
14
15
 
15
16
  [project.optional-dependencies]
@@ -51,3 +52,19 @@ exclude = ["backend.tests*", "backend.benchmarks*"]
51
52
  testpaths = ["backend/tests"]
52
53
  python_files = ["test_*.py"]
53
54
  addopts = "-v --tb=short"
55
+
56
+ [tool.coverage.run]
57
+ source = ["backend"]
58
+ omit = [
59
+ "backend/tests/*",
60
+ "backend/benchmarks/*",
61
+ ]
62
+
63
+ [tool.coverage.report]
64
+ show_missing = true
65
+ skip_covered = false
66
+ fail_under = 93
67
+ exclude_lines = [
68
+ "pragma: no cover",
69
+ "if __name__ == .__main__.:",
70
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-dialect-master
3
- Version: 1.1.6
3
+ Version: 1.1.8
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -10,6 +10,7 @@ Requires-Dist: fastapi>=0.115.0
10
10
  Requires-Dist: uvicorn>=0.30.0
11
11
  Requires-Dist: pydantic>=2.9.0
12
12
  Requires-Dist: pydantic-settings>=2.0.0
13
+ Requires-Dist: prometheus-client<1.0.0,>=0.20.0
13
14
  Provides-Extra: dev
14
15
  Requires-Dist: hypothesis>=6.112.0; extra == "dev"
15
16
  Requires-Dist: pytest>=8.3.0; extra == "dev"
@@ -35,7 +36,7 @@ Dynamic: license-file
35
36
 
36
37
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
37
38
 
38
- Current release: 1.1.6
39
+ Current release: 1.1.8
39
40
 
40
41
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
41
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -359,3 +360,17 @@ Rate limiting is on by default. The two backends are:
359
360
  - `SDM_RATE_LIMIT_BACKEND=redis` — shared, atomic limits across all workers. Set `SDM_REDIS_URL` when using this backend. The store connects and pings Redis at startup and fails fast if it is unreachable; the `redis` package is required (install with `pip install redis`).
360
361
 
361
362
  `SDM_RATE_LIMIT_ENABLED=false` disables enforcement entirely. Use the in-memory backend for local development and the Redis backend for any deployment with more than one worker or instance that must enforce a single global limit.
363
+
364
+ By default, the API does not trust `X-Forwarded-For`; rate limiting and logs
365
+ use the direct peer address. Set `SDM_TRUSTED_PROXIES` to the proxy IPs/CIDRs
366
+ only when the direct peer is a trusted proxy, after which the rightmost
367
+ non-trusted address in the XFF chain is used. Deployments behind a proxy must
368
+ also configure `SDM_HEALTH_PROBE_TOKEN` for readiness probes; readiness never
369
+ uses XFF for authorization.
370
+
371
+ SQL-bearing POST responses and health probes use `Cache-Control: no-store`;
372
+ public catalog responses use short `private` caching. Full deployment details
373
+ will be documented in `docs/deployment.md`.
374
+
375
+ See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
376
+ Metrics configuration and scraping guidance are documented in the same guide.
@@ -4,7 +4,9 @@ pyproject.toml
4
4
  backend/__init__.py
5
5
  backend/api/__init__.py
6
6
  backend/api/auth.py
7
+ backend/api/client_identity.py
7
8
  backend/api/main.py
9
+ backend/api/metrics.py
8
10
  backend/api/middleware.py
9
11
  backend/api/rate_limit_store.py
10
12
  backend/api/readiness.py
@@ -3,6 +3,7 @@ fastapi>=0.115.0
3
3
  uvicorn>=0.30.0
4
4
  pydantic>=2.9.0
5
5
  pydantic-settings>=2.0.0
6
+ prometheus-client<1.0.0,>=0.20.0
6
7
 
7
8
  [dev]
8
9
  hypothesis>=6.112.0