Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

261 lines
10 KiB
Python

"""Layer-1 idempotency for the OpenAI-compatible ``/v1/chat/completions`` route.
The ``/v1`` tool round-trip is fully stateless (the pause finalizes the prior
turn as ``complete`` and resumes via ``build_continuation_from_messages`` with
no ``pending_tool_state``). Dropping the native ``resume_from_tool_actions``
path also dropped its ``mark_resuming`` guard, so a duplicated/retried POST
could re-run the agent → a duplicate answer row + double token billing.
This module restores protection the OpenAI-compatible way: a client-supplied
``Idempotency-Key`` header makes retries return the *stored first response*
instead of re-running the agent. It is opt-in (no header → today's behavior,
byte-for-byte) and scoped to **non-streaming** requests only (the b2b client
and the actual regression); streaming replay is intentionally unsupported.
Storage reuses the existing ``task_dedup`` table via
:class:`~application.storage.db.repositories.idempotency.IdempotencyRepository`
— no new table or migration. The contract maps onto its claim/finalize
semantics:
- **No record** → ``claim_task`` inserts a ``pending`` row (we run + finalize).
- **``completed`` within 24h TTL** → return the cached body + status code.
- **Fresh ``pending``** (in-flight) → HTTP 409 idempotency conflict.
- **Stale ``pending``** (older than :data:`STALE_PENDING_SECONDS` — the
original request likely died) → release and re-claim.
- **``failed`` / past-TTL** → ``claim_task`` re-claims automatically.
Only successful (2xx) responses are cached; a 4xx/5xx releases the claim so a
genuine retry can still succeed (matches OpenAI).
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Dict, Optional, Tuple
from flask import jsonify, make_response, request, Response
from sqlalchemy import text as sql_text
from application.storage.db.repositories.idempotency import IdempotencyRepository
from application.storage.db.session import db_readonly, db_session
logger = logging.getLogger(__name__)
# Distinct ``task_name`` so v1 chat dedup rows never collide with ingest /
# webhook rows that share the ``task_dedup`` table.
TASK_NAME = "v1_chat_completion"
_IDEMPOTENCY_KEY_MAX_LEN = 256
# A ``pending`` claim older than this is treated as a dead in-flight request
# (the process crashed before finalize), so a genuine retry may re-claim it
# rather than waiting out the full 24h TTL or getting a permanent 409. Kept
# short enough to unblock retries quickly, long enough that a normal
# non-streaming completion (which finalizes on the same request) never trips
# it while still running.
STALE_PENDING_SECONDS = 300
def read_idempotency_key() -> Tuple[Optional[str], Optional[Response]]:
"""Read and validate the ``Idempotency-Key`` request header.
Returns:
``(key, error_response)``. An absent/empty header yields
``(None, None)`` (idempotency is opt-in). An oversized header yields
``(None, <400 response>)`` so the caller can short-circuit.
"""
key = request.headers.get("Idempotency-Key")
if not key:
return None, None
if len(key) > _IDEMPOTENCY_KEY_MAX_LEN:
return None, make_response(
jsonify(
{
"error": {
"message": (
f"Idempotency-Key exceeds maximum length of "
f"{_IDEMPOTENCY_KEY_MAX_LEN} characters"
),
"type": "invalid_request",
}
}
),
400,
)
return key, None
def scoped_key(idempotency_key: Optional[str], agent_id: Optional[str]) -> Optional[str]:
"""Compose ``{agent_id}:{idempotency_key}`` so tenants never collide.
Two agents replaying the same key value resolve to distinct stored rows.
Falls back to ``api_key`` scoping at the call site when no agent id is
available; returns ``None`` when either component is missing (idempotency
is then skipped, preserving today's behavior).
"""
if not idempotency_key or not agent_id:
return None
return f"{agent_id}:{idempotency_key}"
def _release_stale_pending(key: str) -> None:
"""Delete a stale ``pending`` claim so the caller can re-claim it.
Scoped to ``status = 'pending'`` and the staleness window so we never
clobber a live in-flight claim or a ``completed`` cache row.
"""
try:
with db_session() as conn:
conn.execute(
sql_text(
"DELETE FROM task_dedup "
"WHERE idempotency_key = :k "
"AND status = 'pending' "
"AND created_at <= now() - make_interval(secs => :secs)"
),
{"k": key, "secs": STALE_PENDING_SECONDS},
)
except Exception:
logger.exception("Failed to release stale v1 idempotency claim for key=%s", key)
def claim_or_replay(key: str) -> Tuple[bool, Optional[Response]]:
"""Claim ``key`` for this request, or return the prior outcome.
Claim-before-process: atomically insert a ``pending`` row. The three
outcomes map onto the existing ``task_dedup`` contract:
- **claimed** → ``(True, None)``: this caller runs the agent and must call
:func:`finalize` (success) or :func:`release` (error) afterwards.
- **``completed`` within TTL** → ``(False, <cached response>)``: replay the
stored body + status code without re-running.
- **fresh ``pending``** → ``(False, <409 response>)``: a same-key request is
already in progress.
A ``pending`` row older than :data:`STALE_PENDING_SECONDS` is released and
re-claimed (the original request likely died). ``failed`` / past-TTL rows
are re-claimed by ``claim_task`` itself.
Args:
key: The tenant-scoped idempotency key.
Returns:
``(claimed, response)``. When ``claimed`` is True the caller owns the
run; otherwise ``response`` is the replay/409 to return immediately.
"""
predetermined_id = str(uuid.uuid4())
with db_session() as conn:
claimed = IdempotencyRepository(conn).claim_task(
key=key, task_name=TASK_NAME, task_id=predetermined_id,
)
if claimed is not None:
return True, None
# Lost the claim — resolve why against the within-TTL row.
with db_readonly() as conn:
existing = IdempotencyRepository(conn).get_task(key)
if existing is not None and existing.get("status") == "completed":
return False, _replay_response(existing.get("result_json"))
if existing is not None and existing.get("status") == "pending":
# In-flight? Re-claim only if the prior claim is stale (dead request).
_release_stale_pending(key)
with db_session() as conn:
reclaimed = IdempotencyRepository(conn).claim_task(
key=key, task_name=TASK_NAME, task_id=predetermined_id,
)
if reclaimed is not None:
return True, None
return False, _conflict_response()
# Row vanished between claim and read (TTL cleanup / release race) — one
# more claim attempt; treat a persistent loss as a conflict.
with db_session() as conn:
reclaimed = IdempotencyRepository(conn).claim_task(
key=key, task_name=TASK_NAME, task_id=predetermined_id,
)
if reclaimed is not None:
return True, None
with db_readonly() as conn:
existing = IdempotencyRepository(conn).get_task(key)
if existing is not None and existing.get("status") == "completed":
return False, _replay_response(existing.get("result_json"))
return False, _conflict_response()
def finalize(key: str, response: Response) -> None:
"""Cache a successful (2xx) response under ``key``; release otherwise.
Stores ``{"status_code", "body"}`` in ``task_dedup.result_json`` so a
retry replays byte-for-byte. Non-2xx responses are not cached — the claim
is released so a genuine retry can still succeed (matches OpenAI).
Args:
key: The tenant-scoped idempotency key claimed by :func:`claim_or_replay`.
response: The Flask response produced by running the request.
"""
status_code = response.status_code
if not (200 <= status_code < 300):
release(key)
return
try:
body = response.get_json(silent=True)
except Exception:
body = None
result_json = {"status_code": status_code, "body": body}
try:
with db_session() as conn:
IdempotencyRepository(conn).finalize_task(
key=key, result_json=result_json, status="completed",
)
except Exception:
logger.exception("Failed to finalize v1 idempotency record for key=%s", key)
def release(key: str) -> None:
"""Drop this request's ``pending`` claim so a retry can re-claim it.
Used on the error path (non-2xx or an exception before finalize) so a
failed first attempt never blocks a legitimate retry for the full TTL.
"""
try:
with db_session() as conn:
conn.execute(
sql_text(
"DELETE FROM task_dedup "
"WHERE idempotency_key = :k AND status = 'pending'"
),
{"k": key},
)
except Exception:
logger.exception("Failed to release v1 idempotency claim for key=%s", key)
def _replay_response(result_json: Optional[Dict[str, Any]]) -> Response:
"""Rebuild a Flask response from a cached ``result_json`` row."""
status_code = 200
body: Any = None
if isinstance(result_json, dict):
status_code = int(result_json.get("status_code", 200))
body = result_json.get("body")
return make_response(jsonify(body), status_code)
def _conflict_response() -> Response:
"""OpenAI-shaped 409 for a same-key request already in progress."""
return make_response(
jsonify(
{
"error": {
"message": (
"A request with this Idempotency-Key is already in progress"
),
"type": "idempotency_conflict",
}
}
),
409,
)