Skip to content

fix(agent): enforce idempotency_key during create-run admission - #39692

Open
ccl125 wants to merge 1 commit into
langgenius:mainfrom
ccl125:fix/agent-run-idempotency
Open

fix(agent): enforce idempotency_key during create-run admission#39692
ccl125 wants to merge 1 commit into
langgenius:mainfrom
ccl125:fix/agent-run-idempotency

Conversation

@ccl125

@ccl125 ccl125 commented Jul 28, 2026

Copy link
Copy Markdown

Summary

Fixes #39685

The standalone dify-agent run server accepted CreateRunRequest.idempotency_key but never used it during run admission: RunScheduler.create_run() always persisted a new run record and started another background task, so client retries (and concurrent duplicate requests hitting different server processes sharing the same Redis prefix) repeated model invocations and tool side effects.

Changes

  • RedisRunStore.create_run_idempotent(): claims the idempotency key behind an atomic SET NX (TTL = run retention window), keyed by the key digest. Only the request fingerprint digest and the claimed run id are persisted — never the create-run payload, which may carry model credentials. A stale claim whose original run record is already gone is taken over by the fresh run.
  • RunScheduler.create_run(): when the request carries an idempotency_key, computes a stable fingerprint over the normalized request (excluding the key itself) and deduplicates admission. An exact replay within the retention window returns the original run id and current status without scheduling a second task.
  • POST /runs: reusing the key with a different normalized request returns HTTP 409.
  • Requests without an idempotency key keep the existing behavior.
  • Regression tests for the store claim/replay/conflict paths, scheduler dedup (including fingerprint normalization of semantically equal payloads), and the 409 route mapping.

Testing

  • pytest tests — 649 passed; the 2 failures in test_run_scheduler.py and the 4 collection errors from duplicate test-module basenames also reproduce on a clean main checkout and are unrelated to this change.
  • ruff check, ruff format --check, and basedpyright --level error are clean on the touched files.

The standalone dify-agent run server accepted CreateRunRequest.idempotency_key
but never consulted it: RunScheduler.create_run() always persisted a new run
record and started another background task, so retries and concurrent duplicate
requests repeated model invocations and tool side effects.

Deduplicate admission in RedisRunStore behind an atomic SET NX claim keyed by
the digest of the idempotency key, storing only the request fingerprint digest
and the claimed run id (never the create-run payload, which may carry model
credentials). Within the run retention window an exact replay now returns the
original run id and current status without scheduling a second task, reusing
the key with a different normalized request is rejected with HTTP 409, and
requests without a key keep the existing behavior.
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 28, 2026
@dosubot

dosubot Bot commented Jul 28, 2026

Copy link
Copy Markdown

📄 Knowledge review

✏️ Suggested updates

1 page suggestion needs review.

Page Library Status
Dify Agent Server 模块分析 (Commit 55f95dbc) dify 🟡 Review
📝 Dify Agent Server 模块分析 (Commit 55f95db)
@@ -161,6 +161,7 @@
 - **Run 记录**:以 JSON 字符串存储在 Redis Key 中,记录 `run_id`、`status`、`created_at`、`updated_at`、`error`
 - **事件流**:每个 run 对应一条 Redis Stream,事件以 `xadd` 追加写入
 - **TTL 刷新**:每次写入状态或事件时,同步刷新 run 记录和事件流的过期时间(默认保留 3 天)
+- **幂等键声明**:用于去重的幂等键(`idempotency_key`)以其摘要为键存储在 Redis 中,TTL 与 run 保留窗口一致。仅持久化键摘要、请求指纹摘要和声明的 run ID,从不存储创建 run 负载(可能包含模型凭证)
 
 Redis Stream ID 作为公开的事件游标,`0-0` 表示从头回放,支持断点续传。
 
@@ -184,8 +185,10 @@
 
 - 检查 `stopping` 标志
 - 执行 run 请求验证
-- 持久化 run 记录
+- 持久化 run 记录(或幂等键声明)
 - 注册后台任务
+
+当请求携带 `idempotency_key` 时,调度器通过 `RedisRunStore.create_run_idempotent()` 执行幂等准入控制:计算请求负载的稳定指纹(排除幂等键本身),声明幂等键,并在保留窗口内的精确重放返回原始 run 而不启动第二个后台任务。
 
 ### FastAPI Lifespan 生命周期
 
@@ -576,7 +579,7 @@
 
 | 方法 | 路径 | 功能 | 状态码 |
 |---|---|---|---|
-| `POST` | `/runs` | 创建并调度一个 Agent 运行 | 202 / 422 / 503 |
+| `POST` | `/runs` | 创建并调度一个 Agent 运行 | 202 / 409 / 422 / 503 |
 | `GET` | `/runs/{run_id}` | 查询运行状态 | 200 / 404 |
 | `GET` | `/runs/{run_id}/events` | 轮询事件列表(游标分页)| 200 / 404 |
 | `GET` | `/runs/{run_id}/events/sse` | SSE 事件流(实时推送)| 200 / 404 |
@@ -598,9 +601,12 @@
     ]
   },
   "session_snapshot": null,
-  "on_exit": { "default": "suspend", "layers": {} }
+  "on_exit": { "default": "suspend", "layers": {} },
+  "idempotency_key": "unique-request-identifier"
 }
 ```
+
+可选的 `idempotency_key` 字段用于防止客户端重试和并发重复请求重复模型调用和工具副作用。在保留窗口内使用相同键的精确重放返回原始 run;使用相同键但不同请求负载返回 HTTP 409。
 
 **响应体(202 Accepted)**:`CreateRunResponse`
 
@@ -1135,7 +1141,7 @@
 
 | 环境变量 | 默认值 | 说明 |
 |---|---|---|
-| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis 连接 URL,用于存储运行记录和事件流 |
+| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis 连接 URL,用于存储运行记录、事件流和幂等键声明 |
 | `DIFY_AGENT_REDIS_PREFIX` | `dify-agent` | Redis 键名前缀,用于多实例部署时的命名空间隔离 |
 
 #### 关闭与数据保留

Accept · Edit · Decline


Leave Feedback Ask Dosu about dify

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(agent): CreateRunRequest.idempotency_key is accepted but not enforced

1 participant