diff --git a/docsgpt/api/v1/routes.py b/docsgpt/api/v1/routes.py index c177ad6c..2b943088 100644 --- a/docsgpt/api/v1/routes.py +++ b/docsgpt/api/v1/routes.py @@ -21,6 +21,7 @@ from docsgpt.api.answer.services.continuation_service import ( RESUME_IN_PROGRESS_MESSAGE, ResumeInProgressError, ) +from docsgpt import tracing from docsgpt.api.answer.services.stream_processor import ( StreamProcessor, flush_trace_after_request, @@ -410,6 +411,9 @@ def chat_completions(): if not claimed: # ``completed`` cache hit, or a 409 for an in-flight same-key # request — either way return without re-running the agent. + # The original request already has its trace; this retry's + # setup (pre-fetch retrieval) is not a failed run to record. + tracing.discard(processor.trace) return replay # An exception from the agent run propagates to the ``except`` handlers diff --git a/tests/api/v1/test_v1_idempotency.py b/tests/api/v1/test_v1_idempotency.py index 08897b24..eda35b4d 100644 --- a/tests/api/v1/test_v1_idempotency.py +++ b/tests/api/v1/test_v1_idempotency.py @@ -437,3 +437,46 @@ class TestV1IdempotencyRoute: {"n": TASK_NAME}, ).scalar() assert completed == 1 + + +@pytest.mark.integration +class TestV1ReplayTrace: + """A replayed request must not leave a failed trace behind.""" + + QUESTION = TestV1IdempotencyRoute.QUESTION + + def test_replay_discards_its_setup_trace(self, pg_engine, monkeypatch): + from docsgpt import tracing + from docsgpt.core.settings import settings + + monkeypatch.setattr(settings, "TRACES_ENABLED", True) + monkeypatch.setattr(settings, "TRACES_OTEL_EXPORT", False) + user_id = f"user-{uuid.uuid4().hex[:8]}" + api_key = f"key-{uuid.uuid4().hex[:8]}" + with pg_engine.begin() as conn: + _seed_user(conn, user_id) + _seed_agent(conn, user_id, api_key) + + def _build_agent(processor, question): # noqa: ARG001 + # What the real setup does: a trace holding the pre-fetch retrieval. + processor.trace = tracing.start_trace(source="v1", capture_otel_context=False) + with tracing.activate(processor.trace): + with tracing.span(tracing.KIND_RETRIEVAL, "retrieval"): + pass + return _CountingAnswerAgent() + + written = [] + app = _build_app() + idem_key = f"idem-{uuid.uuid4().hex}" + with _wire_v1_route_db(pg_engine, monkeypatch), patch( + "docsgpt.api.answer.services.stream_processor.StreamProcessor.build_agent", + _build_agent, + ), patch("docsgpt.tracing.sink._write", side_effect=written.append): + _wire_idem_db(pg_engine, monkeypatch) + with app.test_client() as c: + resp1 = _post_chat(c, self.QUESTION, api_key, idem_key=idem_key) + resp2 = _post_chat(c, self.QUESTION, api_key, idem_key=idem_key) + + assert resp1.status_code == resp2.status_code == 200 + # Only the first request's trace is written, and it is not a failure. + assert [t.status for t in written] == ["ok"]