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.
Files changed (203) hide show
  1. 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
  2. 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
  3. 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
  4. 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
  5. 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
  6. 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
  7. agentflow_cli/__init__.py +69 -0
  8. tenxgraph_api/__init__.py +23 -0
  9. tenxgraph_api/cli/__init__.py +6 -0
  10. tenxgraph_api/cli/capabilities.py +127 -0
  11. tenxgraph_api/cli/commands/__init__.py +49 -0
  12. tenxgraph_api/cli/commands/api.py +262 -0
  13. tenxgraph_api/cli/commands/audit.py +213 -0
  14. tenxgraph_api/cli/commands/build.py +305 -0
  15. tenxgraph_api/cli/commands/config.py +71 -0
  16. tenxgraph_api/cli/commands/demo.py +93 -0
  17. tenxgraph_api/cli/commands/eval.py +909 -0
  18. tenxgraph_api/cli/commands/init.py +531 -0
  19. tenxgraph_api/cli/commands/skills.py +532 -0
  20. tenxgraph_api/cli/commands/test.py +113 -0
  21. tenxgraph_api/cli/commands/version.py +47 -0
  22. tenxgraph_api/cli/config_editor/__init__.py +15 -0
  23. tenxgraph_api/cli/config_editor/schema.py +566 -0
  24. tenxgraph_api/cli/config_editor/server.py +240 -0
  25. tenxgraph_api/cli/config_editor/static/app.css +2 -0
  26. tenxgraph_api/cli/config_editor/static/app.js +3 -0
  27. tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
  28. tenxgraph_api/cli/config_editor/static/index.html +20 -0
  29. tenxgraph_api/cli/config_editor/store.py +120 -0
  30. tenxgraph_api/cli/config_editor/validation.py +377 -0
  31. tenxgraph_api/cli/constants.py +112 -0
  32. tenxgraph_api/cli/context.py +46 -0
  33. tenxgraph_api/cli/core/__init__.py +0 -0
  34. tenxgraph_api/cli/core/animations.py +550 -0
  35. tenxgraph_api/cli/core/config.py +279 -0
  36. tenxgraph_api/cli/core/output.py +625 -0
  37. tenxgraph_api/cli/core/prompts.py +220 -0
  38. tenxgraph_api/cli/core/screen.py +287 -0
  39. tenxgraph_api/cli/core/steps.py +621 -0
  40. tenxgraph_api/cli/core/theme.py +242 -0
  41. tenxgraph_api/cli/core/validation.py +251 -0
  42. tenxgraph_api/cli/exceptions.py +129 -0
  43. tenxgraph_api/cli/logger.py +84 -0
  44. tenxgraph_api/cli/main.py +936 -0
  45. tenxgraph_api/cli/templates/__init__.py +0 -0
  46. tenxgraph_api/cli/templates/defaults.py +844 -0
  47. tenxgraph_api/cli/templates/dev/.env.example +1 -0
  48. tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
  49. tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
  50. tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
  51. tenxgraph_api/cli/templates/prod/.env.example +75 -0
  52. tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
  53. tenxgraph_api/cli/templates/prod/.python-version +1 -0
  54. tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
  55. tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
  56. tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
  57. tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
  58. tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
  59. tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
  60. tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
  61. tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
  62. tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
  63. tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
  64. tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
  65. tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
  66. tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
  67. tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
  68. tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
  69. tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
  70. tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
  71. tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
  72. tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
  73. tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
  74. tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
  75. tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
  76. tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
  77. tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
  78. tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
  79. tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
  80. tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
  81. tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
  82. tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
  83. tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
  84. tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
  85. tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
  86. tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
  87. tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
  88. tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
  89. tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
  90. tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
  91. tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
  92. tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
  93. tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
  94. tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
  95. tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
  96. tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
  97. tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
  98. tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
  99. tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
  100. tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
  101. tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
  102. tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
  103. tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
  104. tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
  105. tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
  106. tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
  107. tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
  108. tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
  109. tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
  110. tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
  111. tenxgraph_api/py.typed +0 -0
  112. tenxgraph_api/src/__init__.py +0 -0
  113. tenxgraph_api/src/app/__init__.py +13 -0
  114. tenxgraph_api/src/app/core/__init__.py +15 -0
  115. tenxgraph_api/src/app/core/auth/__init__.py +0 -0
  116. tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
  117. tenxgraph_api/src/app/core/auth/authorization.py +379 -0
  118. tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
  119. tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
  120. tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
  121. tenxgraph_api/src/app/core/auth/permissions.py +354 -0
  122. tenxgraph_api/src/app/core/auth/request_config.py +130 -0
  123. tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
  124. tenxgraph_api/src/app/core/config/__init__.py +0 -0
  125. tenxgraph_api/src/app/core/config/graph_config.py +532 -0
  126. tenxgraph_api/src/app/core/config/media_settings.py +72 -0
  127. tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
  128. tenxgraph_api/src/app/core/config/settings.py +202 -0
  129. tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
  130. tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
  131. tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
  132. tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
  133. tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
  134. tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
  135. tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
  136. tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
  137. tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
  138. tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
  139. tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
  140. tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
  141. tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
  142. tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
  143. tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
  144. tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
  145. tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
  146. tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
  147. tenxgraph_api/src/app/core/utils/__init__.py +14 -0
  148. tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
  149. tenxgraph_api/src/app/loader.py +516 -0
  150. tenxgraph_api/src/app/main.py +180 -0
  151. tenxgraph_api/src/app/routers/__init__.py +4 -0
  152. tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
  153. tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
  154. tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
  155. tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
  156. tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
  157. tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
  158. tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
  159. tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
  160. tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
  161. tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
  162. tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
  163. tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
  164. tenxgraph_api/src/app/routers/evals/router.py +38 -0
  165. tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
  166. tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
  167. tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
  168. tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
  169. tenxgraph_api/src/app/routers/graph/router.py +821 -0
  170. tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
  171. tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
  172. tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
  173. tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
  174. tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
  175. tenxgraph_api/src/app/routers/media/__init__.py +506 -0
  176. tenxgraph_api/src/app/routers/media/router.py +242 -0
  177. tenxgraph_api/src/app/routers/media/schemas.py +39 -0
  178. tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
  179. tenxgraph_api/src/app/routers/ping/router.py +28 -0
  180. tenxgraph_api/src/app/routers/setup_router.py +60 -0
  181. tenxgraph_api/src/app/routers/store/__init__.py +4 -0
  182. tenxgraph_api/src/app/routers/store/router.py +200 -0
  183. tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
  184. tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
  185. tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
  186. tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
  187. tenxgraph_api/src/app/tasks/__init__.py +0 -0
  188. tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
  189. tenxgraph_api/src/app/utils/__init__.py +12 -0
  190. tenxgraph_api/src/app/utils/callable_helper.py +26 -0
  191. tenxgraph_api/src/app/utils/media/__init__.py +15 -0
  192. tenxgraph_api/src/app/utils/media/extractor.py +172 -0
  193. tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
  194. tenxgraph_api/src/app/utils/parse_output.py +17 -0
  195. tenxgraph_api/src/app/utils/response_helper.py +129 -0
  196. tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
  197. tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
  198. tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
  199. tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
  200. tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
  201. tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
  202. tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
  203. 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"}