10xgraph-api 0.7.0__py3-none-any.whl
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.
- 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
- 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
- 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
- 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
- 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
- 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
- agentflow_cli/__init__.py +69 -0
- tenxgraph_api/__init__.py +23 -0
- tenxgraph_api/cli/__init__.py +6 -0
- tenxgraph_api/cli/capabilities.py +127 -0
- tenxgraph_api/cli/commands/__init__.py +49 -0
- tenxgraph_api/cli/commands/api.py +262 -0
- tenxgraph_api/cli/commands/audit.py +213 -0
- tenxgraph_api/cli/commands/build.py +305 -0
- tenxgraph_api/cli/commands/config.py +71 -0
- tenxgraph_api/cli/commands/demo.py +93 -0
- tenxgraph_api/cli/commands/eval.py +909 -0
- tenxgraph_api/cli/commands/init.py +531 -0
- tenxgraph_api/cli/commands/skills.py +532 -0
- tenxgraph_api/cli/commands/test.py +113 -0
- tenxgraph_api/cli/commands/version.py +47 -0
- tenxgraph_api/cli/config_editor/__init__.py +15 -0
- tenxgraph_api/cli/config_editor/schema.py +566 -0
- tenxgraph_api/cli/config_editor/server.py +240 -0
- tenxgraph_api/cli/config_editor/static/app.css +2 -0
- tenxgraph_api/cli/config_editor/static/app.js +3 -0
- tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
- tenxgraph_api/cli/config_editor/static/index.html +20 -0
- tenxgraph_api/cli/config_editor/store.py +120 -0
- tenxgraph_api/cli/config_editor/validation.py +377 -0
- tenxgraph_api/cli/constants.py +112 -0
- tenxgraph_api/cli/context.py +46 -0
- tenxgraph_api/cli/core/__init__.py +0 -0
- tenxgraph_api/cli/core/animations.py +550 -0
- tenxgraph_api/cli/core/config.py +279 -0
- tenxgraph_api/cli/core/output.py +625 -0
- tenxgraph_api/cli/core/prompts.py +220 -0
- tenxgraph_api/cli/core/screen.py +287 -0
- tenxgraph_api/cli/core/steps.py +621 -0
- tenxgraph_api/cli/core/theme.py +242 -0
- tenxgraph_api/cli/core/validation.py +251 -0
- tenxgraph_api/cli/exceptions.py +129 -0
- tenxgraph_api/cli/logger.py +84 -0
- tenxgraph_api/cli/main.py +936 -0
- tenxgraph_api/cli/templates/__init__.py +0 -0
- tenxgraph_api/cli/templates/defaults.py +844 -0
- tenxgraph_api/cli/templates/dev/.env.example +1 -0
- tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
- tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
- tenxgraph_api/cli/templates/prod/.env.example +75 -0
- tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
- tenxgraph_api/cli/templates/prod/.python-version +1 -0
- tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
- tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
- tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
- tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
- tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
- tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
- tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
- tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
- tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
- tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
- tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
- tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
- tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
- tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
- tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
- tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
- tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
- tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
- tenxgraph_api/py.typed +0 -0
- tenxgraph_api/src/__init__.py +0 -0
- tenxgraph_api/src/app/__init__.py +13 -0
- tenxgraph_api/src/app/core/__init__.py +15 -0
- tenxgraph_api/src/app/core/auth/__init__.py +0 -0
- tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
- tenxgraph_api/src/app/core/auth/authorization.py +379 -0
- tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
- tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
- tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
- tenxgraph_api/src/app/core/auth/permissions.py +354 -0
- tenxgraph_api/src/app/core/auth/request_config.py +130 -0
- tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
- tenxgraph_api/src/app/core/config/__init__.py +0 -0
- tenxgraph_api/src/app/core/config/graph_config.py +532 -0
- tenxgraph_api/src/app/core/config/media_settings.py +72 -0
- tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
- tenxgraph_api/src/app/core/config/settings.py +202 -0
- tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
- tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
- tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
- tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
- tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
- tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
- tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
- tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
- tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
- tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
- tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
- tenxgraph_api/src/app/core/utils/__init__.py +14 -0
- tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
- tenxgraph_api/src/app/loader.py +516 -0
- tenxgraph_api/src/app/main.py +180 -0
- tenxgraph_api/src/app/routers/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
- tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
- tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
- tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
- tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
- tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
- tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
- tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
- tenxgraph_api/src/app/routers/evals/router.py +38 -0
- tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
- tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
- tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
- tenxgraph_api/src/app/routers/graph/router.py +821 -0
- tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
- tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
- tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
- tenxgraph_api/src/app/routers/media/__init__.py +506 -0
- tenxgraph_api/src/app/routers/media/router.py +242 -0
- tenxgraph_api/src/app/routers/media/schemas.py +39 -0
- tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ping/router.py +28 -0
- tenxgraph_api/src/app/routers/setup_router.py +60 -0
- tenxgraph_api/src/app/routers/store/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/router.py +200 -0
- tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
- tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
- tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
- tenxgraph_api/src/app/tasks/__init__.py +0 -0
- tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
- tenxgraph_api/src/app/utils/__init__.py +12 -0
- tenxgraph_api/src/app/utils/callable_helper.py +26 -0
- tenxgraph_api/src/app/utils/media/__init__.py +15 -0
- tenxgraph_api/src/app/utils/media/extractor.py +172 -0
- tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
- tenxgraph_api/src/app/utils/parse_output.py +17 -0
- tenxgraph_api/src/app/utils/response_helper.py +129 -0
- tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
- tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
- tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
- tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
- tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
- tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
- tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
- tenxgraph_api/src/app/worker.py +91 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Shared client-key derivation for rate limiting.
|
|
2
|
+
|
|
3
|
+
Used by both the HTTP ``RateLimitMiddleware`` and the WebSocket connection guard so that
|
|
4
|
+
WebSocket handshakes are counted against the *same* rate-limit bucket as REST requests.
|
|
5
|
+
Works on any ``HTTPConnection`` (both ``Request`` and ``WebSocket`` subclass it).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ipaddress
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from starlette.requests import HTTPConnection
|
|
12
|
+
from starlette.responses import Response
|
|
13
|
+
|
|
14
|
+
from tenxgraph_api.src.app.core.config.graph_config import RateLimitConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("tenxgraph_api")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client_ip(connection: HTTPConnection, config: RateLimitConfig) -> str:
|
|
21
|
+
"""Resolve the client IP, honouring X-Forwarded-For only where it is trustworthy.
|
|
22
|
+
|
|
23
|
+
``X-Forwarded-For`` is a list that each proxy APPENDS to. Whatever the caller
|
|
24
|
+
sent arrives at the left; only the entries your own proxies appended, on the
|
|
25
|
+
right, are trustworthy.
|
|
26
|
+
|
|
27
|
+
Reading ``split(",")[0]`` -- the previous behaviour -- therefore took a value
|
|
28
|
+
the caller fully controls. An attacker could send a different
|
|
29
|
+
``X-Forwarded-For`` on every request, land in a fresh bucket each time, and
|
|
30
|
+
never be rate limited at all.
|
|
31
|
+
|
|
32
|
+
We instead count ``trusted_proxy_hops`` back from the RIGHT. With one proxy in
|
|
33
|
+
front (the default), the last entry is the address that proxy actually observed
|
|
34
|
+
-- the real peer.
|
|
35
|
+
|
|
36
|
+
All ``X-Forwarded-For`` header lines are combined in order: a proxy that adds its own
|
|
37
|
+
line instead of appending to the caller's must not let the caller's line decide. When
|
|
38
|
+
``trusted_proxies`` is configured, the header is only honoured for connections that come
|
|
39
|
+
from one of those networks; anyone reaching the app directly is keyed by their peer.
|
|
40
|
+
"""
|
|
41
|
+
peer = connection.client.host if connection.client else "unknown"
|
|
42
|
+
if config.trusted_proxy_headers and _from_trusted_proxy(peer, config):
|
|
43
|
+
lines = connection.headers.getlist("X-Forwarded-For")
|
|
44
|
+
if lines:
|
|
45
|
+
parts = [p.strip() for line in lines for p in line.split(",") if p.strip()]
|
|
46
|
+
hops = max(1, getattr(config, "trusted_proxy_hops", 1))
|
|
47
|
+
if len(parts) >= hops:
|
|
48
|
+
return parts[-hops]
|
|
49
|
+
# Fewer entries than our own infrastructure should have appended: the
|
|
50
|
+
# header is not shaped the way we expect, so trust none of it.
|
|
51
|
+
logger.warning(
|
|
52
|
+
"X-Forwarded-For has %d entries but %d trusted proxy hop(s) are "
|
|
53
|
+
"configured; ignoring the header and using the peer address.",
|
|
54
|
+
len(parts),
|
|
55
|
+
hops,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return peer
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _from_trusted_proxy(peer: str, config: RateLimitConfig) -> bool:
|
|
62
|
+
trusted = getattr(config, "trusted_proxies", ())
|
|
63
|
+
if not trusted:
|
|
64
|
+
return True # no list configured: trust the header (the hop count still applies)
|
|
65
|
+
try:
|
|
66
|
+
address = ipaddress.ip_address(peer)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return False
|
|
69
|
+
return any(address in ipaddress.ip_network(net) for net in trusted)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verified_user_id(connection: HTTPConnection) -> str | None:
|
|
73
|
+
"""Identify the caller from their credential, exactly as ``RequirePermission`` does.
|
|
74
|
+
|
|
75
|
+
The rate limiter runs before any route dependency, so it cannot read an authenticated
|
|
76
|
+
user from request state. It verifies the credential itself with the configured auth
|
|
77
|
+
backend instead. Returns ``None`` when auth is not configured, no credential was sent, or
|
|
78
|
+
the credential does not verify; the caller is then limited by address, so forged tokens
|
|
79
|
+
cannot buy fresh buckets.
|
|
80
|
+
"""
|
|
81
|
+
from injectq import InjectQ
|
|
82
|
+
|
|
83
|
+
from tenxgraph_api.src.app.core.auth.base_auth import BaseAuth
|
|
84
|
+
from tenxgraph_api.src.app.core.auth.permissions import _extract_credential
|
|
85
|
+
from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
|
|
86
|
+
|
|
87
|
+
credential = _extract_credential(connection)
|
|
88
|
+
if credential is None:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
container = InjectQ.get_instance()
|
|
92
|
+
graph_config = container.try_get(GraphConfig)
|
|
93
|
+
if graph_config is None or not graph_config.auth_config():
|
|
94
|
+
return None
|
|
95
|
+
auth_backend = container.try_get(BaseAuth)
|
|
96
|
+
if auth_backend is None:
|
|
97
|
+
return None
|
|
98
|
+
# A throwaway Response: anything the backend sets on it belongs to the real
|
|
99
|
+
# authentication in RequirePermission, which runs again for the route.
|
|
100
|
+
user = auth_backend.authenticate(connection, Response(), credential)
|
|
101
|
+
except Exception as exc: # invalid token, backend error: fall back to the address
|
|
102
|
+
logger.debug("Rate limit could not verify caller identity: %s", exc)
|
|
103
|
+
return None
|
|
104
|
+
user_id = user.get("user_id") if isinstance(user, dict) else None
|
|
105
|
+
return str(user_id) if user_id else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def client_key_for(connection: HTTPConnection, config: RateLimitConfig) -> str:
|
|
109
|
+
"""Derive the rate-limit bucket key for a connection.
|
|
110
|
+
|
|
111
|
+
Supports ``by``:
|
|
112
|
+
|
|
113
|
+
- ``"global"``: a single bucket for the whole service.
|
|
114
|
+
- ``"user"``: one bucket per authenticated user. This is what you want once auth
|
|
115
|
+
is enabled: limiting purely by IP gives one user roaming across addresses an
|
|
116
|
+
unlimited budget, while a NAT'd office sharing one address gets throttled as
|
|
117
|
+
though it were a single caller.
|
|
118
|
+
- ``"ip"``: one bucket per client address (the default).
|
|
119
|
+
|
|
120
|
+
``"user"`` falls back to the peer address when there is no authenticated user,
|
|
121
|
+
so anonymous traffic is still limited per-caller rather than sharing one bucket
|
|
122
|
+
that any single client could exhaust for everybody.
|
|
123
|
+
"""
|
|
124
|
+
if config.by == "global":
|
|
125
|
+
return "__global__"
|
|
126
|
+
|
|
127
|
+
if config.by == "user":
|
|
128
|
+
user_id = verified_user_id(connection)
|
|
129
|
+
if user_id:
|
|
130
|
+
return f"user:{user_id}"
|
|
131
|
+
return f"ip:{_client_ip(connection, config)}"
|
|
132
|
+
|
|
133
|
+
return _client_ip(connection, config)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from collections import deque
|
|
5
|
+
|
|
6
|
+
from .base import BaseRateLimitBackend, RateLimitDecision
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("tenxgraph_api.rate_limit")
|
|
10
|
+
|
|
11
|
+
_NUM_STRIPES = 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MemoryRateLimitBackend(BaseRateLimitBackend):
|
|
15
|
+
"""In-process sliding-window rate limiter.
|
|
16
|
+
|
|
17
|
+
This is useful for development and single-process deployments. It is not
|
|
18
|
+
shared across workers or containers; use Redis for distributed enforcement.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_SWEEP_INTERVAL = 2000
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_unique_keys: int = 50_000) -> None:
|
|
24
|
+
self._buckets: dict[str, deque[float]] = {}
|
|
25
|
+
self._last_seen: dict[str, float] = {}
|
|
26
|
+
self._locks = [asyncio.Lock() for _ in range(_NUM_STRIPES)]
|
|
27
|
+
self._sweep_lock = asyncio.Lock()
|
|
28
|
+
self._bg_tasks: set[asyncio.Task] = set()
|
|
29
|
+
self._check_count = 0
|
|
30
|
+
self._max_unique_keys = max_unique_keys
|
|
31
|
+
|
|
32
|
+
def _stripe_lock(self, key: str) -> asyncio.Lock:
|
|
33
|
+
return self._locks[hash(key) % _NUM_STRIPES]
|
|
34
|
+
|
|
35
|
+
def _schedule_sweep(self, window: int) -> None:
|
|
36
|
+
task = asyncio.ensure_future(self._sweep(window))
|
|
37
|
+
self._bg_tasks.add(task)
|
|
38
|
+
task.add_done_callback(self._bg_tasks.discard)
|
|
39
|
+
|
|
40
|
+
def _evict_oldest_bucket(self) -> None:
|
|
41
|
+
if not self._last_seen:
|
|
42
|
+
return
|
|
43
|
+
oldest_key = min(self._last_seen, key=self._last_seen.__getitem__)
|
|
44
|
+
self._buckets.pop(oldest_key, None)
|
|
45
|
+
self._last_seen.pop(oldest_key, None)
|
|
46
|
+
|
|
47
|
+
async def _sweep(self, window: int) -> None:
|
|
48
|
+
async with self._sweep_lock:
|
|
49
|
+
cutoff = time.monotonic() - window * 2
|
|
50
|
+
stale = [k for k, ts in self._last_seen.items() if ts < cutoff]
|
|
51
|
+
for key in stale:
|
|
52
|
+
self._buckets.pop(key, None)
|
|
53
|
+
self._last_seen.pop(key, None)
|
|
54
|
+
if stale:
|
|
55
|
+
logger.debug("Rate-limit memory sweep removed %d stale buckets", len(stale))
|
|
56
|
+
|
|
57
|
+
async def check(self, key: str, *, limit: int, window: int) -> RateLimitDecision:
|
|
58
|
+
now = time.monotonic()
|
|
59
|
+
window_start = now - window
|
|
60
|
+
|
|
61
|
+
self._check_count += 1
|
|
62
|
+
if self._check_count % self._SWEEP_INTERVAL == 0:
|
|
63
|
+
self._schedule_sweep(window)
|
|
64
|
+
|
|
65
|
+
async with self._stripe_lock(key):
|
|
66
|
+
if key not in self._buckets and len(self._buckets) >= self._max_unique_keys:
|
|
67
|
+
logger.warning(
|
|
68
|
+
"Rate-limit bucket cap (%d) reached; running emergency sweep",
|
|
69
|
+
self._max_unique_keys,
|
|
70
|
+
)
|
|
71
|
+
await self._sweep(window)
|
|
72
|
+
if len(self._buckets) >= self._max_unique_keys:
|
|
73
|
+
self._evict_oldest_bucket()
|
|
74
|
+
|
|
75
|
+
bucket = self._buckets.setdefault(key, deque())
|
|
76
|
+
self._last_seen[key] = now
|
|
77
|
+
|
|
78
|
+
while bucket and bucket[0] < window_start:
|
|
79
|
+
bucket.popleft()
|
|
80
|
+
|
|
81
|
+
count = len(bucket)
|
|
82
|
+
if count >= limit:
|
|
83
|
+
reset_after = max(1, int(bucket[0] + window - now) + 1)
|
|
84
|
+
return RateLimitDecision(allowed=False, remaining=0, reset_after=reset_after)
|
|
85
|
+
|
|
86
|
+
bucket.append(now)
|
|
87
|
+
return RateLimitDecision(
|
|
88
|
+
allowed=True,
|
|
89
|
+
remaining=limit - len(bucket),
|
|
90
|
+
reset_after=window,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def close(self) -> None:
|
|
94
|
+
for task in self._bg_tasks:
|
|
95
|
+
task.cancel()
|
|
96
|
+
if self._bg_tasks:
|
|
97
|
+
await asyncio.gather(*self._bg_tasks, return_exceptions=True)
|
|
98
|
+
self._bg_tasks.clear()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from fastapi import status
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
|
|
8
|
+
from tenxgraph_api.src.app.core import logger
|
|
9
|
+
from tenxgraph_api.src.app.core.config.graph_config import RateLimitConfig
|
|
10
|
+
|
|
11
|
+
from .base import BaseRateLimitBackend
|
|
12
|
+
from .keying import client_key_for
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Liveness/readiness probes hit /ping. Limiting it (or failing closed on a backend outage)
|
|
16
|
+
# would make the orchestrator restart healthy pods.
|
|
17
|
+
HEALTH_CHECK_PATHS = frozenset({"/ping"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitMiddleware(BaseHTTPMiddleware):
|
|
21
|
+
"""Backend-agnostic rate-limit middleware."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app,
|
|
26
|
+
config: RateLimitConfig,
|
|
27
|
+
backend: BaseRateLimitBackend,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(app)
|
|
30
|
+
self.config = config
|
|
31
|
+
self.backend = backend
|
|
32
|
+
self._exclude = frozenset(config.exclude_paths) | HEALTH_CHECK_PATHS
|
|
33
|
+
|
|
34
|
+
def _client_key(self, request: Request) -> str:
|
|
35
|
+
return client_key_for(request, self.config)
|
|
36
|
+
|
|
37
|
+
async def dispatch(self, request: Request, call_next):
|
|
38
|
+
if request.url.path in self._exclude:
|
|
39
|
+
return await call_next(request)
|
|
40
|
+
|
|
41
|
+
key = self._client_key(request)
|
|
42
|
+
decision = await self.backend.check(
|
|
43
|
+
key,
|
|
44
|
+
limit=self.config.requests,
|
|
45
|
+
window=self.config.window,
|
|
46
|
+
)
|
|
47
|
+
reset_at_epoch = int(time.time()) + decision.reset_after
|
|
48
|
+
|
|
49
|
+
if not decision.allowed:
|
|
50
|
+
request_id = getattr(request.state, "request_id", "unknown")
|
|
51
|
+
logger.warning(
|
|
52
|
+
"Rate limit exceeded for %s on %s %s",
|
|
53
|
+
key,
|
|
54
|
+
request.method,
|
|
55
|
+
request.url.path,
|
|
56
|
+
)
|
|
57
|
+
return JSONResponse(
|
|
58
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
59
|
+
content={
|
|
60
|
+
"error": {
|
|
61
|
+
"code": "RATE_LIMIT_EXCEEDED",
|
|
62
|
+
"message": (
|
|
63
|
+
f"Too many requests. Limit: {self.config.requests} "
|
|
64
|
+
f"per {self.config.window}s. "
|
|
65
|
+
f"Retry after {decision.reset_after}s."
|
|
66
|
+
),
|
|
67
|
+
"limit": self.config.requests,
|
|
68
|
+
"window_seconds": self.config.window,
|
|
69
|
+
"retry_after_seconds": decision.reset_after,
|
|
70
|
+
},
|
|
71
|
+
"metadata": {
|
|
72
|
+
"request_id": request_id,
|
|
73
|
+
"status": "error",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
headers={
|
|
77
|
+
"Retry-After": str(decision.reset_after),
|
|
78
|
+
"X-RateLimit-Limit": str(self.config.requests),
|
|
79
|
+
"X-RateLimit-Remaining": "0",
|
|
80
|
+
"X-RateLimit-Reset": str(reset_at_epoch),
|
|
81
|
+
"X-RateLimit-Reset-After": str(decision.reset_after),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
response = await call_next(request)
|
|
86
|
+
response.headers["X-RateLimit-Limit"] = str(self.config.requests)
|
|
87
|
+
response.headers["X-RateLimit-Remaining"] = str(decision.remaining)
|
|
88
|
+
response.headers["X-RateLimit-Reset"] = str(reset_at_epoch)
|
|
89
|
+
response.headers["X-RateLimit-Reset-After"] = str(decision.reset_after)
|
|
90
|
+
return response
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .base import BaseRateLimitBackend, RateLimitDecision
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from redis.asyncio import Redis as AsyncRedis # type: ignore[import]
|
|
11
|
+
|
|
12
|
+
_REDIS_AVAILABLE = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
_REDIS_AVAILABLE = False
|
|
15
|
+
AsyncRedis = None # type: ignore[assignment,misc]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("tenxgraph_api.rate_limit")
|
|
19
|
+
_REDIS_EXTRA_INSTALL = 'pip install "10xgraph-api[redis]"'
|
|
20
|
+
|
|
21
|
+
# Atomic sliding-window check using a Redis sorted set.
|
|
22
|
+
#
|
|
23
|
+
# The script:
|
|
24
|
+
# 1. Removes timestamps older than the current window.
|
|
25
|
+
# 2. Counts the remaining requests for the key.
|
|
26
|
+
# 3. If under the limit, adds the current request as a unique sorted-set member.
|
|
27
|
+
# 4. Sets an expiry so idle keys clean themselves up.
|
|
28
|
+
# 5. Returns whether the request is allowed, remaining quota, and reset time.
|
|
29
|
+
_SLIDING_WINDOW_LUA = """
|
|
30
|
+
local key = KEYS[1]
|
|
31
|
+
local now_ms = tonumber(ARGV[1])
|
|
32
|
+
local window_ms = tonumber(ARGV[2])
|
|
33
|
+
local limit = tonumber(ARGV[3])
|
|
34
|
+
local member = ARGV[4]
|
|
35
|
+
|
|
36
|
+
local window_start = now_ms - window_ms
|
|
37
|
+
|
|
38
|
+
redis.call('ZREMRANGEBYSCORE', key, '-inf', tostring(window_start))
|
|
39
|
+
|
|
40
|
+
local count = tonumber(redis.call('ZCARD', key))
|
|
41
|
+
|
|
42
|
+
if count < limit then
|
|
43
|
+
redis.call('ZADD', key, tostring(now_ms), member)
|
|
44
|
+
redis.call('EXPIRE', key, math.ceil(window_ms / 1000) + 1)
|
|
45
|
+
return {1, limit - count - 1, math.ceil(window_ms / 1000)}
|
|
46
|
+
else
|
|
47
|
+
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
|
48
|
+
local reset_after
|
|
49
|
+
if #oldest >= 2 then
|
|
50
|
+
local oldest_ms = tonumber(oldest[2])
|
|
51
|
+
reset_after = math.ceil((oldest_ms + window_ms - now_ms) / 1000) + 1
|
|
52
|
+
else
|
|
53
|
+
reset_after = math.ceil(window_ms / 1000)
|
|
54
|
+
end
|
|
55
|
+
return {0, 0, math.max(reset_after, 1)}
|
|
56
|
+
end
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class RedisRateLimitBackend(BaseRateLimitBackend):
|
|
61
|
+
"""Distributed sliding-window rate limiter backed by Redis."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
redis: Any,
|
|
66
|
+
prefix: str,
|
|
67
|
+
fail_open: bool = True,
|
|
68
|
+
close_redis: bool = False,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._redis = redis
|
|
71
|
+
self._prefix = prefix
|
|
72
|
+
self._fail_open = fail_open
|
|
73
|
+
self._close_redis = close_redis
|
|
74
|
+
self._script = self._redis.register_script(_SLIDING_WINDOW_LUA)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_url(
|
|
78
|
+
cls,
|
|
79
|
+
redis_url: str,
|
|
80
|
+
prefix: str,
|
|
81
|
+
fail_open: bool = True,
|
|
82
|
+
) -> "RedisRateLimitBackend":
|
|
83
|
+
if not _REDIS_AVAILABLE or AsyncRedis is None:
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"Redis backend requires the 'redis' package. "
|
|
86
|
+
f"Install the optional Redis extra with: {_REDIS_EXTRA_INSTALL}"
|
|
87
|
+
)
|
|
88
|
+
redis = AsyncRedis.from_url(redis_url, decode_responses=False)
|
|
89
|
+
return cls(redis=redis, prefix=prefix, fail_open=fail_open, close_redis=True)
|
|
90
|
+
|
|
91
|
+
async def check(self, key: str, *, limit: int, window: int) -> RateLimitDecision:
|
|
92
|
+
redis_key = f"{self._prefix}:{key}"
|
|
93
|
+
now_ms = int(time.time() * 1000)
|
|
94
|
+
window_ms = window * 1000
|
|
95
|
+
member = f"{now_ms}:{uuid.uuid4().hex}"
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = await self._script(
|
|
99
|
+
keys=[redis_key],
|
|
100
|
+
args=[now_ms, window_ms, limit, member],
|
|
101
|
+
)
|
|
102
|
+
return RateLimitDecision(
|
|
103
|
+
allowed=bool(result[0]),
|
|
104
|
+
remaining=int(result[1]),
|
|
105
|
+
reset_after=int(result[2]),
|
|
106
|
+
)
|
|
107
|
+
except Exception:
|
|
108
|
+
logger.exception("Redis rate-limit backend error")
|
|
109
|
+
if self._fail_open:
|
|
110
|
+
return RateLimitDecision(allowed=True, remaining=0, reset_after=window)
|
|
111
|
+
return RateLimitDecision(allowed=False, remaining=0, reset_after=window)
|
|
112
|
+
|
|
113
|
+
async def close(self) -> None:
|
|
114
|
+
if self._close_redis:
|
|
115
|
+
await self._redis.aclose()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Request size limit middleware for DoS protection."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import status
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from starlette.datastructures import Headers
|
|
8
|
+
from starlette.exceptions import HTTPException
|
|
9
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
10
|
+
|
|
11
|
+
from tenxgraph_api.src.app.core import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RequestBodyTooLarge(HTTPException):
|
|
15
|
+
"""Raised from ``receive`` once a streamed body passes the limit.
|
|
16
|
+
|
|
17
|
+
It is an ``HTTPException`` because FastAPI re-raises those when they come from reading
|
|
18
|
+
the body, instead of turning them into a generic 400, so the client gets a 413.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, max_size: int) -> None:
|
|
22
|
+
super().__init__(
|
|
23
|
+
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
24
|
+
detail=f"Request body too large. Maximum size is {max_size / (1024 * 1024):.1f}MB",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RequestSizeLimitMiddleware:
|
|
29
|
+
"""Enforce a maximum request body size, whether or not ``Content-Length`` is sent.
|
|
30
|
+
|
|
31
|
+
A declared ``Content-Length`` over the limit is rejected before any body is read. Bodies
|
|
32
|
+
without one (``Transfer-Encoding: chunked``) are counted as they stream in and cut off as
|
|
33
|
+
soon as they pass the limit. That matters because FastAPI reads and parses the whole body,
|
|
34
|
+
including spooling multipart uploads to disk, before auth dependencies run.
|
|
35
|
+
|
|
36
|
+
This is a pure ASGI middleware: ``BaseHTTPMiddleware`` cannot see body chunks.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
app: The ASGI application
|
|
40
|
+
max_size: Maximum request body size in bytes (default: 10MB)
|
|
41
|
+
path_limits: Per-path limits that replace ``max_size``, e.g. a larger one for the
|
|
42
|
+
file upload route so ``MEDIA_MAX_SIZE_MB`` is reachable.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
app: ASGIApp,
|
|
48
|
+
max_size: int = 10 * 1024 * 1024,
|
|
49
|
+
path_limits: dict[str, int] | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.app = app
|
|
52
|
+
self.max_size = max_size
|
|
53
|
+
self.max_size_mb = max_size / (1024 * 1024)
|
|
54
|
+
self.path_limits = dict(path_limits or {})
|
|
55
|
+
|
|
56
|
+
def _limit_for(self, scope: Scope) -> int:
|
|
57
|
+
path = scope.get("path", "")
|
|
58
|
+
root_path = (scope.get("root_path") or "").rstrip("/")
|
|
59
|
+
if root_path and path.startswith(root_path):
|
|
60
|
+
path = path[len(root_path) :] or "/"
|
|
61
|
+
return self.path_limits.get(path, self.max_size)
|
|
62
|
+
|
|
63
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
64
|
+
if scope["type"] != "http":
|
|
65
|
+
await self.app(scope, receive, send)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
max_size = self._limit_for(scope)
|
|
69
|
+
max_size_mb = max_size / (1024 * 1024)
|
|
70
|
+
|
|
71
|
+
content_length = Headers(scope=scope).get("content-length")
|
|
72
|
+
if content_length is not None:
|
|
73
|
+
try:
|
|
74
|
+
declared = int(content_length)
|
|
75
|
+
except ValueError:
|
|
76
|
+
# A malformed Content-Length is a client error, not a server 500.
|
|
77
|
+
await self._error(
|
|
78
|
+
scope,
|
|
79
|
+
send,
|
|
80
|
+
status.HTTP_400_BAD_REQUEST,
|
|
81
|
+
{"code": "INVALID_CONTENT_LENGTH", "message": "Invalid Content-Length header."},
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
if declared > max_size:
|
|
85
|
+
logger.warning(
|
|
86
|
+
f"Request rejected: size {declared} bytes exceeds limit of "
|
|
87
|
+
f"{max_size} bytes ({max_size_mb:.1f}MB)"
|
|
88
|
+
)
|
|
89
|
+
await self._too_large(scope, send, max_size)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
received = 0
|
|
93
|
+
response_started = False
|
|
94
|
+
|
|
95
|
+
async def limited_receive() -> Message:
|
|
96
|
+
nonlocal received
|
|
97
|
+
message = await receive()
|
|
98
|
+
if message["type"] == "http.request":
|
|
99
|
+
received += len(message.get("body", b""))
|
|
100
|
+
if received > max_size:
|
|
101
|
+
logger.warning(
|
|
102
|
+
f"Request rejected: streamed body passed the limit of "
|
|
103
|
+
f"{max_size} bytes ({max_size_mb:.1f}MB)"
|
|
104
|
+
)
|
|
105
|
+
raise RequestBodyTooLarge(max_size)
|
|
106
|
+
return message
|
|
107
|
+
|
|
108
|
+
async def tracking_send(message: Message) -> None:
|
|
109
|
+
nonlocal response_started
|
|
110
|
+
if message["type"] == "http.response.start":
|
|
111
|
+
response_started = True
|
|
112
|
+
await send(message)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
await self.app(scope, limited_receive, tracking_send)
|
|
116
|
+
except RequestBodyTooLarge:
|
|
117
|
+
# Normally the app's HTTPException handling answers with the 413. This covers a
|
|
118
|
+
# body read outside FastAPI's request handling (e.g. by another middleware).
|
|
119
|
+
if response_started:
|
|
120
|
+
raise
|
|
121
|
+
await self._too_large(scope, send, max_size)
|
|
122
|
+
|
|
123
|
+
async def _too_large(self, scope: Scope, send: Send, max_size: int) -> None:
|
|
124
|
+
max_size_mb = max_size / (1024 * 1024)
|
|
125
|
+
await self._error(
|
|
126
|
+
scope,
|
|
127
|
+
send,
|
|
128
|
+
status.HTTP_413_CONTENT_TOO_LARGE,
|
|
129
|
+
{
|
|
130
|
+
"code": "REQUEST_TOO_LARGE",
|
|
131
|
+
"message": f"Request body too large. Maximum size is {max_size_mb:.1f}MB",
|
|
132
|
+
"max_size_bytes": max_size,
|
|
133
|
+
"max_size_mb": max_size_mb,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
async def _error(scope: Scope, send: Send, status_code: int, error: dict[str, Any]) -> None:
|
|
139
|
+
state = scope.get("state") or {}
|
|
140
|
+
response = JSONResponse(
|
|
141
|
+
status_code=status_code,
|
|
142
|
+
content={
|
|
143
|
+
"error": error,
|
|
144
|
+
"metadata": {
|
|
145
|
+
"request_id": state.get("request_id", "unknown"),
|
|
146
|
+
"status": "error",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
await response(scope, _drain_receive, send)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _drain_receive() -> Message:
|
|
154
|
+
return {"type": "http.disconnect"}
|