draft-board 0.1.0-beta.0
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.
- package/app/backend/.env.example +9 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_patch.txt +195 -0
- package/app/backend/.smartkanban/evidence/8b383839-cbec-45af-86ee-c7708d075cbe/bddf2ed5-2e21-4d46-a62b-10b87f1642a6_stat.txt +6 -0
- package/app/backend/CURL_EXAMPLES.md +335 -0
- package/app/backend/ENV_SETUP.md +65 -0
- package/app/backend/alembic/env.py +71 -0
- package/app/backend/alembic/script.py.mako +28 -0
- package/app/backend/alembic/versions/001_initial_schema.py +104 -0
- package/app/backend/alembic/versions/002_add_jobs_table.py +52 -0
- package/app/backend/alembic/versions/003_add_workspace_table.py +48 -0
- package/app/backend/alembic/versions/004_add_evidence_table.py +56 -0
- package/app/backend/alembic/versions/005_add_verification_commands.py +32 -0
- package/app/backend/alembic/versions/006_add_planner_lock_table.py +39 -0
- package/app/backend/alembic/versions/007_add_revision_review_tables.py +126 -0
- package/app/backend/alembic/versions/008_add_revision_idempotency_and_traceability.py +52 -0
- package/app/backend/alembic/versions/009_add_job_health_fields.py +46 -0
- package/app/backend/alembic/versions/010_add_review_comment_line_content.py +36 -0
- package/app/backend/alembic/versions/011_add_analysis_cache.py +47 -0
- package/app/backend/alembic/versions/012_add_boards_table.py +102 -0
- package/app/backend/alembic/versions/013_add_ticket_blocking.py +45 -0
- package/app/backend/alembic/versions/014_add_agent_sessions.py +220 -0
- package/app/backend/alembic/versions/015_add_ticket_sort_order.py +33 -0
- package/app/backend/alembic/versions/03220f0b93ae_add_pr_fields_to_ticket.py +49 -0
- package/app/backend/alembic/versions/0c2d89fff3b1_seed_board_configs_from_yaml.py +206 -0
- package/app/backend/alembic/versions/3348e5cf54c1_add_merge_checklist_table.py +67 -0
- package/app/backend/alembic/versions/357c780ee445_add_goal_status.py +34 -0
- package/app/backend/alembic/versions/553340b7e26c_add_autonomy_fields_to_goal.py +65 -0
- package/app/backend/alembic/versions/774dc335c679_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/7b307e847cbd_merge_heads.py +23 -0
- package/app/backend/alembic/versions/82ecd978cc70_add_missing_indexes.py +48 -0
- package/app/backend/alembic/versions/8ef5054dc280_add_normalized_log_entries.py +173 -0
- package/app/backend/alembic/versions/8f3e2bd8ea3b_merge_migration_heads.py +23 -0
- package/app/backend/alembic/versions/9d17f0698d3b_add_config_column_to_boards_table.py +30 -0
- package/app/backend/alembic/versions/add_agent_conversation_history.py +72 -0
- package/app/backend/alembic/versions/add_job_variant.py +34 -0
- package/app/backend/alembic/versions/add_performance_indexes.py +95 -0
- package/app/backend/alembic/versions/add_repos_and_board_repos.py +174 -0
- package/app/backend/alembic/versions/add_session_id_to_jobs.py +27 -0
- package/app/backend/alembic/versions/add_sqlite_backend_tables.py +104 -0
- package/app/backend/alembic/versions/b10fb0b62240_add_diff_content_to_revisions.py +34 -0
- package/app/backend/alembic.ini +89 -0
- package/app/backend/app/__init__.py +3 -0
- package/app/backend/app/data_dir.py +85 -0
- package/app/backend/app/database.py +70 -0
- package/app/backend/app/database_sync.py +64 -0
- package/app/backend/app/dependencies/__init__.py +5 -0
- package/app/backend/app/dependencies/auth.py +80 -0
- package/app/backend/app/dependencies.py +43 -0
- package/app/backend/app/exceptions.py +178 -0
- package/app/backend/app/executors/__init__.py +1 -0
- package/app/backend/app/executors/adapters/__init__.py +1 -0
- package/app/backend/app/executors/adapters/aider.py +152 -0
- package/app/backend/app/executors/adapters/amazon_q.py +103 -0
- package/app/backend/app/executors/adapters/amp.py +123 -0
- package/app/backend/app/executors/adapters/claude.py +177 -0
- package/app/backend/app/executors/adapters/cline.py +127 -0
- package/app/backend/app/executors/adapters/codex.py +167 -0
- package/app/backend/app/executors/adapters/copilot.py +202 -0
- package/app/backend/app/executors/adapters/cursor.py +87 -0
- package/app/backend/app/executors/adapters/droid.py +123 -0
- package/app/backend/app/executors/adapters/gemini.py +132 -0
- package/app/backend/app/executors/adapters/goose.py +131 -0
- package/app/backend/app/executors/adapters/opencode.py +123 -0
- package/app/backend/app/executors/adapters/qwen.py +123 -0
- package/app/backend/app/executors/plugins/__init__.py +1 -0
- package/app/backend/app/executors/registry.py +202 -0
- package/app/backend/app/executors/spec.py +226 -0
- package/app/backend/app/main.py +486 -0
- package/app/backend/app/middleware/__init__.py +13 -0
- package/app/backend/app/middleware/idempotency.py +426 -0
- package/app/backend/app/middleware/rate_limit.py +312 -0
- package/app/backend/app/middleware/security_headers.py +43 -0
- package/app/backend/app/middleware/timeout.py +37 -0
- package/app/backend/app/models/__init__.py +56 -0
- package/app/backend/app/models/agent_conversation_history.py +56 -0
- package/app/backend/app/models/agent_session.py +127 -0
- package/app/backend/app/models/analysis_cache.py +49 -0
- package/app/backend/app/models/base.py +9 -0
- package/app/backend/app/models/board.py +79 -0
- package/app/backend/app/models/board_repo.py +68 -0
- package/app/backend/app/models/cost_budget.py +42 -0
- package/app/backend/app/models/enums.py +40 -0
- package/app/backend/app/models/evidence.py +132 -0
- package/app/backend/app/models/goal.py +102 -0
- package/app/backend/app/models/idempotency_entry.py +30 -0
- package/app/backend/app/models/job.py +163 -0
- package/app/backend/app/models/job_queue.py +39 -0
- package/app/backend/app/models/kv_store.py +28 -0
- package/app/backend/app/models/merge_checklist.py +87 -0
- package/app/backend/app/models/normalized_log.py +100 -0
- package/app/backend/app/models/planner_lock.py +43 -0
- package/app/backend/app/models/rate_limit_entry.py +25 -0
- package/app/backend/app/models/repo.py +66 -0
- package/app/backend/app/models/review_comment.py +91 -0
- package/app/backend/app/models/review_summary.py +69 -0
- package/app/backend/app/models/revision.py +130 -0
- package/app/backend/app/models/ticket.py +223 -0
- package/app/backend/app/models/ticket_event.py +83 -0
- package/app/backend/app/models/user.py +47 -0
- package/app/backend/app/models/workspace.py +71 -0
- package/app/backend/app/redis_client.py +119 -0
- package/app/backend/app/routers/__init__.py +29 -0
- package/app/backend/app/routers/agents.py +296 -0
- package/app/backend/app/routers/auth.py +94 -0
- package/app/backend/app/routers/board.py +885 -0
- package/app/backend/app/routers/dashboard.py +351 -0
- package/app/backend/app/routers/debug.py +528 -0
- package/app/backend/app/routers/evidence.py +96 -0
- package/app/backend/app/routers/executors.py +324 -0
- package/app/backend/app/routers/goals.py +574 -0
- package/app/backend/app/routers/jobs.py +448 -0
- package/app/backend/app/routers/maintenance.py +172 -0
- package/app/backend/app/routers/merge.py +360 -0
- package/app/backend/app/routers/planner.py +537 -0
- package/app/backend/app/routers/pull_requests.py +382 -0
- package/app/backend/app/routers/repos.py +263 -0
- package/app/backend/app/routers/revisions.py +939 -0
- package/app/backend/app/routers/settings.py +267 -0
- package/app/backend/app/routers/tickets.py +2003 -0
- package/app/backend/app/routers/webhooks.py +143 -0
- package/app/backend/app/routers/websocket.py +249 -0
- package/app/backend/app/schemas/__init__.py +109 -0
- package/app/backend/app/schemas/board.py +87 -0
- package/app/backend/app/schemas/common.py +33 -0
- package/app/backend/app/schemas/evidence.py +87 -0
- package/app/backend/app/schemas/goal.py +90 -0
- package/app/backend/app/schemas/job.py +97 -0
- package/app/backend/app/schemas/merge.py +139 -0
- package/app/backend/app/schemas/planner.py +500 -0
- package/app/backend/app/schemas/repo.py +187 -0
- package/app/backend/app/schemas/review.py +137 -0
- package/app/backend/app/schemas/revision.py +114 -0
- package/app/backend/app/schemas/ticket.py +238 -0
- package/app/backend/app/schemas/ticket_event.py +72 -0
- package/app/backend/app/schemas/workspace.py +19 -0
- package/app/backend/app/services/__init__.py +31 -0
- package/app/backend/app/services/agent_memory_service.py +223 -0
- package/app/backend/app/services/agent_registry.py +346 -0
- package/app/backend/app/services/agent_session_manager.py +318 -0
- package/app/backend/app/services/agent_session_service.py +219 -0
- package/app/backend/app/services/agent_tools.py +379 -0
- package/app/backend/app/services/auth_service.py +98 -0
- package/app/backend/app/services/autonomy_service.py +380 -0
- package/app/backend/app/services/board_repo_service.py +201 -0
- package/app/backend/app/services/board_service.py +326 -0
- package/app/backend/app/services/cleanup_service.py +1085 -0
- package/app/backend/app/services/config_service.py +908 -0
- package/app/backend/app/services/context_gatherer.py +557 -0
- package/app/backend/app/services/cost_tracking_service.py +293 -0
- package/app/backend/app/services/cursor_log_normalizer.py +536 -0
- package/app/backend/app/services/delivery_pipeline.py +440 -0
- package/app/backend/app/services/executor_service.py +634 -0
- package/app/backend/app/services/git_host/__init__.py +11 -0
- package/app/backend/app/services/git_host/factory.py +87 -0
- package/app/backend/app/services/git_host/github.py +270 -0
- package/app/backend/app/services/git_host/gitlab.py +194 -0
- package/app/backend/app/services/git_host/protocol.py +75 -0
- package/app/backend/app/services/git_merge_simple.py +346 -0
- package/app/backend/app/services/git_ops.py +384 -0
- package/app/backend/app/services/github_service.py +233 -0
- package/app/backend/app/services/goal_service.py +113 -0
- package/app/backend/app/services/job_service.py +423 -0
- package/app/backend/app/services/job_watchdog_service.py +424 -0
- package/app/backend/app/services/langchain_adapter.py +122 -0
- package/app/backend/app/services/llm_provider_clients.py +351 -0
- package/app/backend/app/services/llm_service.py +285 -0
- package/app/backend/app/services/log_normalizer.py +342 -0
- package/app/backend/app/services/log_stream_service.py +276 -0
- package/app/backend/app/services/merge_checklist_service.py +264 -0
- package/app/backend/app/services/merge_service.py +784 -0
- package/app/backend/app/services/orchestrator_log.py +84 -0
- package/app/backend/app/services/planner_service.py +1662 -0
- package/app/backend/app/services/planner_tick_sync.py +1040 -0
- package/app/backend/app/services/queued_message_service.py +156 -0
- package/app/backend/app/services/reliability_wrapper.py +389 -0
- package/app/backend/app/services/repo_discovery_service.py +318 -0
- package/app/backend/app/services/review_service.py +334 -0
- package/app/backend/app/services/revision_service.py +389 -0
- package/app/backend/app/services/safe_autopilot.py +510 -0
- package/app/backend/app/services/sqlite_worker.py +372 -0
- package/app/backend/app/services/task_dispatch.py +135 -0
- package/app/backend/app/services/ticket_generation_service.py +1781 -0
- package/app/backend/app/services/ticket_service.py +486 -0
- package/app/backend/app/services/udar_planner_service.py +1007 -0
- package/app/backend/app/services/webhook_service.py +126 -0
- package/app/backend/app/services/workspace_service.py +465 -0
- package/app/backend/app/services/worktree_file_service.py +92 -0
- package/app/backend/app/services/worktree_validator.py +213 -0
- package/app/backend/app/sqlite_kv.py +278 -0
- package/app/backend/app/state_machine.py +128 -0
- package/app/backend/app/templates/__init__.py +5 -0
- package/app/backend/app/templates/registry.py +243 -0
- package/app/backend/app/utils/__init__.py +5 -0
- package/app/backend/app/utils/artifact_reader.py +87 -0
- package/app/backend/app/utils/circuit_breaker.py +229 -0
- package/app/backend/app/utils/db_retry.py +136 -0
- package/app/backend/app/utils/ignored_fields.py +123 -0
- package/app/backend/app/utils/validators.py +54 -0
- package/app/backend/app/websocket/__init__.py +5 -0
- package/app/backend/app/websocket/manager.py +179 -0
- package/app/backend/app/websocket/state_tracker.py +113 -0
- package/app/backend/app/worker.py +3190 -0
- package/app/backend/calculator_tickets.json +40 -0
- package/app/backend/canary_tests.sh +591 -0
- package/app/backend/celerybeat-schedule +0 -0
- package/app/backend/celerybeat-schedule-shm +0 -0
- package/app/backend/celerybeat-schedule-wal +0 -0
- package/app/backend/logs/.gitkeep +3 -0
- package/app/backend/multiplication_division_implementation_tickets.json +55 -0
- package/app/backend/multiplication_division_tickets.json +42 -0
- package/app/backend/pyproject.toml +45 -0
- package/app/backend/requirements-dev.txt +8 -0
- package/app/backend/requirements.txt +20 -0
- package/app/backend/run.sh +30 -0
- package/app/backend/run_with_logs.sh +10 -0
- package/app/backend/scientific_calculator_tickets.json +40 -0
- package/app/backend/scripts/extract_openapi.py +21 -0
- package/app/backend/scripts/seed_demo.py +187 -0
- package/app/backend/setup_demo_review.py +302 -0
- package/app/backend/test_actual_parse.py +41 -0
- package/app/backend/test_agent_streaming.py +61 -0
- package/app/backend/test_parse.py +51 -0
- package/app/backend/test_streaming.py +51 -0
- package/app/backend/test_subprocess_streaming.py +50 -0
- package/app/backend/tests/__init__.py +1 -0
- package/app/backend/tests/conftest.py +46 -0
- package/app/backend/tests/test_auth.py +341 -0
- package/app/backend/tests/test_autonomy_service.py +391 -0
- package/app/backend/tests/test_cleanup_service_safety.py +417 -0
- package/app/backend/tests/test_middleware.py +279 -0
- package/app/backend/tests/test_planner_providers.py +290 -0
- package/app/backend/tests/test_planner_unblock.py +183 -0
- package/app/backend/tests/test_revision_invariants.py +618 -0
- package/app/backend/tests/test_sqlite_kv.py +290 -0
- package/app/backend/tests/test_sqlite_worker.py +353 -0
- package/app/backend/tests/test_task_dispatch.py +100 -0
- package/app/backend/tests/test_ticket_validation.py +304 -0
- package/app/backend/tests/test_udar_agent.py +693 -0
- package/app/backend/tests/test_webhook_service.py +184 -0
- package/app/backend/tickets_output.json +59 -0
- package/app/backend/user_management_tickets.json +50 -0
- package/app/backend/uvicorn.log +0 -0
- package/app/draft.yaml +313 -0
- package/app/frontend/dist/assets/index-LcjCczu5.js +155 -0
- package/app/frontend/dist/assets/index-_FP_279e.css +1 -0
- package/app/frontend/dist/index.html +14 -0
- package/app/frontend/dist/vite.svg +1 -0
- package/app/frontend/package.json +101 -0
- package/bin/cli.js +527 -0
- package/package.json +37 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Idempotency middleware with pluggable backend (Redis or SQLite).
|
|
2
|
+
|
|
3
|
+
Guarantees exactly-once execution for LLM operations.
|
|
4
|
+
|
|
5
|
+
Behavior Contract (Deterministic):
|
|
6
|
+
1. First request: acquires lock, executes, stores result with execution_id
|
|
7
|
+
2. Concurrent requests: blocking wait up to WAIT_TIMEOUT_SECONDS for result
|
|
8
|
+
3. If result appears within timeout: return it with X-Idempotency-Replayed
|
|
9
|
+
4. If timeout: return 202 Accepted with execution_id for polling
|
|
10
|
+
5. If same key + different body: return 409 Conflict
|
|
11
|
+
|
|
12
|
+
Key structure includes resource scope to prevent cross-goal/board collisions:
|
|
13
|
+
(client_id, route, resource_scope, idempotency_key)
|
|
14
|
+
|
|
15
|
+
Scope Precedence Rules (strict):
|
|
16
|
+
1. Path param (e.g., goal_id from /goals/{goal_id}/...) takes precedence
|
|
17
|
+
2. Body param used only if no path param
|
|
18
|
+
3. If both exist and DIFFER: return 400 scope_mismatch
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from fastapi import Request, Response
|
|
30
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
31
|
+
from starlette.responses import JSONResponse
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Endpoints that support idempotency (expensive LLM operations that mutate state)
|
|
36
|
+
IDEMPOTENT_ENDPOINTS = {
|
|
37
|
+
"/goals/{goal_id}/generate-tickets",
|
|
38
|
+
"/goals/{goal_id}/reflect-on-tickets",
|
|
39
|
+
"/boards/{board_id}/analyze-codebase",
|
|
40
|
+
"/board/analyze-codebase", # Legacy
|
|
41
|
+
"/tickets/bulk-update-priority",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# TTL for cached responses (10 minutes)
|
|
45
|
+
CACHE_TTL_SECONDS = 600
|
|
46
|
+
|
|
47
|
+
# TTL for processing lock (2 minutes - should be enough for any LLM call)
|
|
48
|
+
LOCK_TTL_SECONDS = 120
|
|
49
|
+
|
|
50
|
+
# Blocking wait timeout (10 seconds max)
|
|
51
|
+
WAIT_TIMEOUT_SECONDS = 10
|
|
52
|
+
POLL_INTERVAL_MS = 100
|
|
53
|
+
|
|
54
|
+
# Redis key prefixes
|
|
55
|
+
REDIS_LOCK_PREFIX = "idemp:lock:"
|
|
56
|
+
REDIS_RESULT_PREFIX = "idemp:result:"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _matches_pattern(path: str, patterns: set[str]) -> tuple[bool, dict[str, str]]:
|
|
60
|
+
"""Check if path matches any pattern.
|
|
61
|
+
|
|
62
|
+
Returns (matches, extracted_params) where extracted_params contains
|
|
63
|
+
path parameters like {goal_id} -> actual value.
|
|
64
|
+
"""
|
|
65
|
+
for pattern in patterns:
|
|
66
|
+
pattern_parts = pattern.split("/")
|
|
67
|
+
path_parts = path.split("/")
|
|
68
|
+
|
|
69
|
+
if len(pattern_parts) != len(path_parts):
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
match = True
|
|
73
|
+
params = {}
|
|
74
|
+
for p_part, path_part in zip(pattern_parts, path_parts, strict=False):
|
|
75
|
+
if p_part.startswith("{") and p_part.endswith("}"):
|
|
76
|
+
param_name = p_part[1:-1]
|
|
77
|
+
params[param_name] = path_part
|
|
78
|
+
elif p_part != path_part:
|
|
79
|
+
match = False
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
if match:
|
|
83
|
+
return True, params
|
|
84
|
+
return False, {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_client_id(request: Request) -> str:
|
|
88
|
+
"""Get client identifier for idempotency scoping."""
|
|
89
|
+
client_id = request.headers.get("X-Client-ID")
|
|
90
|
+
if client_id and len(client_id) <= 64:
|
|
91
|
+
return client_id
|
|
92
|
+
|
|
93
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
94
|
+
if forwarded:
|
|
95
|
+
return f"ip:{forwarded.split(',')[0].strip()}"
|
|
96
|
+
|
|
97
|
+
return f"ip:{request.client.host if request.client else 'unknown'}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _compute_body_hash(body: bytes) -> str:
|
|
101
|
+
"""Compute a hash of the request body."""
|
|
102
|
+
return hashlib.sha256(body).hexdigest()[:16]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _extract_resource_scope(
|
|
106
|
+
path: str, body_dict: dict[str, Any], path_params: dict[str, str]
|
|
107
|
+
) -> tuple[str, str | None]:
|
|
108
|
+
"""Extract resource scope with strict precedence rules.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
(scope_string, error_message)
|
|
112
|
+
- If error_message is not None, there's a scope mismatch
|
|
113
|
+
|
|
114
|
+
Precedence:
|
|
115
|
+
1. Path param takes precedence
|
|
116
|
+
2. Body param used only if no path param
|
|
117
|
+
3. If both exist and DIFFER: return error
|
|
118
|
+
"""
|
|
119
|
+
path_goal_id = path_params.get("goal_id")
|
|
120
|
+
path_board_id = path_params.get("board_id")
|
|
121
|
+
body_goal_id = body_dict.get("goal_id")
|
|
122
|
+
body_board_id = body_dict.get("board_id")
|
|
123
|
+
|
|
124
|
+
# Check for scope mismatch (path vs body)
|
|
125
|
+
if path_goal_id and body_goal_id:
|
|
126
|
+
if path_goal_id != body_goal_id:
|
|
127
|
+
return (
|
|
128
|
+
"",
|
|
129
|
+
f"Scope mismatch: path goal_id '{path_goal_id}' differs from body goal_id '{body_goal_id}'",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if path_board_id and body_board_id:
|
|
133
|
+
if path_board_id != body_board_id:
|
|
134
|
+
return (
|
|
135
|
+
"",
|
|
136
|
+
f"Scope mismatch: path board_id '{path_board_id}' differs from body board_id '{body_board_id}'",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Path takes precedence
|
|
140
|
+
if path_goal_id:
|
|
141
|
+
return f"goal:{path_goal_id}", None
|
|
142
|
+
if path_board_id:
|
|
143
|
+
return f"board:{path_board_id}", None
|
|
144
|
+
|
|
145
|
+
# Fall back to body
|
|
146
|
+
if body_goal_id:
|
|
147
|
+
return f"goal:{body_goal_id}", None
|
|
148
|
+
if body_board_id:
|
|
149
|
+
return f"board:{body_board_id}", None
|
|
150
|
+
|
|
151
|
+
# Board-level endpoints without explicit ID
|
|
152
|
+
if "/board/" in path or "/boards/" in path:
|
|
153
|
+
return "board:default", None
|
|
154
|
+
|
|
155
|
+
return "global", None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _generate_execution_id() -> str:
|
|
159
|
+
"""Generate a unique execution ID for tracking."""
|
|
160
|
+
return str(uuid.uuid4())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _backend_available() -> bool:
|
|
164
|
+
"""Check if the idempotency backend is available."""
|
|
165
|
+
return True # SQLite is always available
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class IdempotencyMiddleware(BaseHTTPMiddleware):
|
|
169
|
+
"""Atomic idempotency middleware using Redis SETNX or SQLite INSERT OR IGNORE.
|
|
170
|
+
|
|
171
|
+
Guarantees exactly-once execution with deterministic behavior:
|
|
172
|
+
1. Try to acquire lock atomically
|
|
173
|
+
2. If acquired: execute request, store result with execution_id
|
|
174
|
+
3. If not acquired: blocking wait up to WAIT_TIMEOUT_SECONDS
|
|
175
|
+
4. If result appears: return with X-Idempotency-Replayed
|
|
176
|
+
5. If timeout: return 202 with execution_id for polling
|
|
177
|
+
6. If body mismatch: return 409 Conflict
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
181
|
+
# Only handle POST requests to specific endpoints
|
|
182
|
+
if request.method != "POST":
|
|
183
|
+
return await call_next(request)
|
|
184
|
+
|
|
185
|
+
matches, path_params = _matches_pattern(request.url.path, IDEMPOTENT_ENDPOINTS)
|
|
186
|
+
if not matches:
|
|
187
|
+
return await call_next(request)
|
|
188
|
+
|
|
189
|
+
# Check for idempotency key header
|
|
190
|
+
idempotency_key = request.headers.get("Idempotency-Key")
|
|
191
|
+
if not idempotency_key:
|
|
192
|
+
# No key provided - still require backend for these endpoints
|
|
193
|
+
if not _backend_available():
|
|
194
|
+
return self._service_unavailable()
|
|
195
|
+
return await call_next(request)
|
|
196
|
+
|
|
197
|
+
# Validate key format
|
|
198
|
+
if len(idempotency_key) > 64:
|
|
199
|
+
return JSONResponse(
|
|
200
|
+
status_code=400,
|
|
201
|
+
content={"detail": "Idempotency-Key too long (max 64 chars)"},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Backend REQUIRED - NO fallback
|
|
205
|
+
if not _backend_available():
|
|
206
|
+
return self._service_unavailable()
|
|
207
|
+
|
|
208
|
+
client_id = _get_client_id(request)
|
|
209
|
+
|
|
210
|
+
# Read request body
|
|
211
|
+
body = await request.body()
|
|
212
|
+
body_hash = _compute_body_hash(body)
|
|
213
|
+
|
|
214
|
+
# Parse body for scope extraction
|
|
215
|
+
try:
|
|
216
|
+
body_dict = json.loads(body) if body else {}
|
|
217
|
+
except json.JSONDecodeError:
|
|
218
|
+
body_dict = {}
|
|
219
|
+
|
|
220
|
+
# Extract scope with strict precedence
|
|
221
|
+
resource_scope, scope_error = _extract_resource_scope(
|
|
222
|
+
request.url.path, body_dict, path_params
|
|
223
|
+
)
|
|
224
|
+
if scope_error:
|
|
225
|
+
return JSONResponse(
|
|
226
|
+
status_code=400,
|
|
227
|
+
content={
|
|
228
|
+
"detail": scope_error,
|
|
229
|
+
"error_type": "scope_mismatch",
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Build cache key
|
|
234
|
+
base_key = f"{client_id}:{request.url.path}:{resource_scope}:{idempotency_key}"
|
|
235
|
+
|
|
236
|
+
# Generate execution_id for this attempt
|
|
237
|
+
execution_id = _generate_execution_id()
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
return await self._dispatch_sqlite(
|
|
241
|
+
request,
|
|
242
|
+
call_next,
|
|
243
|
+
body,
|
|
244
|
+
body_hash,
|
|
245
|
+
base_key,
|
|
246
|
+
idempotency_key,
|
|
247
|
+
execution_id,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.error(f"Idempotency error: {e}")
|
|
252
|
+
return JSONResponse(
|
|
253
|
+
status_code=503,
|
|
254
|
+
content={
|
|
255
|
+
"detail": "Service temporarily unavailable due to cache error.",
|
|
256
|
+
"error_type": "service_unavailable",
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# ─── SQLite backend ───
|
|
261
|
+
|
|
262
|
+
async def _dispatch_sqlite(
|
|
263
|
+
self,
|
|
264
|
+
request,
|
|
265
|
+
call_next,
|
|
266
|
+
body,
|
|
267
|
+
body_hash,
|
|
268
|
+
base_key,
|
|
269
|
+
idempotency_key,
|
|
270
|
+
execution_id,
|
|
271
|
+
) -> Response:
|
|
272
|
+
from app.sqlite_kv import idempotency_try_acquire
|
|
273
|
+
|
|
274
|
+
lock_value = json.dumps(
|
|
275
|
+
{
|
|
276
|
+
"body_hash": body_hash,
|
|
277
|
+
"execution_id": execution_id,
|
|
278
|
+
"started_at": time.time(),
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
acquired = await asyncio.to_thread(
|
|
283
|
+
idempotency_try_acquire, base_key, lock_value, LOCK_TTL_SECONDS
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if acquired:
|
|
287
|
+
return await self._execute_and_cache_sqlite(
|
|
288
|
+
request,
|
|
289
|
+
call_next,
|
|
290
|
+
body,
|
|
291
|
+
body_hash,
|
|
292
|
+
base_key,
|
|
293
|
+
idempotency_key,
|
|
294
|
+
execution_id,
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
return await self._blocking_wait_sqlite(
|
|
298
|
+
base_key, body_hash, idempotency_key, execution_id
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
async def _execute_and_cache_sqlite(
|
|
302
|
+
self,
|
|
303
|
+
request,
|
|
304
|
+
call_next,
|
|
305
|
+
body,
|
|
306
|
+
body_hash,
|
|
307
|
+
cache_key,
|
|
308
|
+
idempotency_key,
|
|
309
|
+
execution_id,
|
|
310
|
+
) -> Response:
|
|
311
|
+
from app.sqlite_kv import idempotency_release_lock, idempotency_store_result
|
|
312
|
+
|
|
313
|
+
async def receive():
|
|
314
|
+
return {"type": "http.request", "body": body}
|
|
315
|
+
|
|
316
|
+
request._receive = receive
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
response = await call_next(request)
|
|
320
|
+
|
|
321
|
+
response_body = b""
|
|
322
|
+
async for chunk in response.body_iterator:
|
|
323
|
+
response_body += chunk
|
|
324
|
+
|
|
325
|
+
result_data = json.dumps(
|
|
326
|
+
{
|
|
327
|
+
"status_code": response.status_code,
|
|
328
|
+
"body": response_body.decode("utf-8"),
|
|
329
|
+
"body_hash": body_hash,
|
|
330
|
+
"execution_id": execution_id,
|
|
331
|
+
"completed_at": time.time(),
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
await asyncio.to_thread(
|
|
335
|
+
idempotency_store_result, cache_key, result_data, CACHE_TTL_SECONDS
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
logger.debug(
|
|
339
|
+
f"Executed and cached for key: {idempotency_key[:8]}... exec_id: {execution_id[:8]}..."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return Response(
|
|
343
|
+
content=response_body,
|
|
344
|
+
status_code=response.status_code,
|
|
345
|
+
media_type="application/json",
|
|
346
|
+
headers={"X-Execution-ID": execution_id},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
except Exception:
|
|
350
|
+
await asyncio.to_thread(idempotency_release_lock, cache_key)
|
|
351
|
+
raise
|
|
352
|
+
|
|
353
|
+
async def _blocking_wait_sqlite(
|
|
354
|
+
self,
|
|
355
|
+
cache_key,
|
|
356
|
+
body_hash,
|
|
357
|
+
idempotency_key,
|
|
358
|
+
our_execution_id,
|
|
359
|
+
) -> Response:
|
|
360
|
+
from app.sqlite_kv import idempotency_get_lock, idempotency_get_result
|
|
361
|
+
|
|
362
|
+
start_time = time.time()
|
|
363
|
+
original_execution_id: str | None = None
|
|
364
|
+
|
|
365
|
+
while time.time() - start_time < WAIT_TIMEOUT_SECONDS:
|
|
366
|
+
result_data = await asyncio.to_thread(idempotency_get_result, cache_key)
|
|
367
|
+
if result_data:
|
|
368
|
+
cached = json.loads(result_data)
|
|
369
|
+
if cached.get("body_hash") != body_hash:
|
|
370
|
+
return JSONResponse(
|
|
371
|
+
status_code=409,
|
|
372
|
+
content={
|
|
373
|
+
"detail": "Idempotency key already used with different request body",
|
|
374
|
+
"error_type": "idempotency_conflict",
|
|
375
|
+
"original_execution_id": cached.get("execution_id"),
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
return Response(
|
|
379
|
+
content=cached["body"].encode()
|
|
380
|
+
if isinstance(cached["body"], str)
|
|
381
|
+
else cached["body"],
|
|
382
|
+
status_code=cached["status_code"],
|
|
383
|
+
media_type="application/json",
|
|
384
|
+
headers={
|
|
385
|
+
"X-Idempotency-Replayed": "true",
|
|
386
|
+
"X-Execution-ID": cached.get("execution_id", "unknown"),
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
lock_data = await asyncio.to_thread(idempotency_get_lock, cache_key)
|
|
391
|
+
if not lock_data:
|
|
392
|
+
# Lock released but no result - original failed, allow retry
|
|
393
|
+
break
|
|
394
|
+
try:
|
|
395
|
+
lock_info = json.loads(lock_data)
|
|
396
|
+
original_execution_id = lock_info.get("execution_id")
|
|
397
|
+
except (json.JSONDecodeError, TypeError):
|
|
398
|
+
original_execution_id = "unknown"
|
|
399
|
+
|
|
400
|
+
await asyncio.sleep(POLL_INTERVAL_MS / 1000)
|
|
401
|
+
|
|
402
|
+
return JSONResponse(
|
|
403
|
+
status_code=202,
|
|
404
|
+
content={
|
|
405
|
+
"detail": "Request is being processed. Poll for result using execution_id.",
|
|
406
|
+
"error_type": "processing",
|
|
407
|
+
"execution_id": original_execution_id or our_execution_id,
|
|
408
|
+
"retry_after_seconds": 2,
|
|
409
|
+
},
|
|
410
|
+
headers={
|
|
411
|
+
"Retry-After": "2",
|
|
412
|
+
"X-Execution-ID": original_execution_id or our_execution_id,
|
|
413
|
+
},
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _service_unavailable(self) -> JSONResponse:
|
|
417
|
+
"""Return 503 when backend is unavailable."""
|
|
418
|
+
return JSONResponse(
|
|
419
|
+
status_code=503,
|
|
420
|
+
content={
|
|
421
|
+
"detail": "Service temporarily unavailable. Backend is required for this operation.",
|
|
422
|
+
"error_type": "service_unavailable",
|
|
423
|
+
"retry_after_seconds": 30,
|
|
424
|
+
},
|
|
425
|
+
headers={"Retry-After": "30"},
|
|
426
|
+
)
|