Skip to content

Feat(#22): AI 분석 결과 이벤트 및 Worker 장애 처리 구현 - #26

Open
pearseona wants to merge 12 commits into
developfrom
feat/22-publish-analysis-results
Open

Feat(#22): AI 분석 결과 이벤트 및 Worker 장애 처리 구현#26
pearseona wants to merge 12 commits into
developfrom
feat/22-publish-analysis-results

Conversation

@pearseona

@pearseona pearseona commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

📝 개요

FastAPI AI 서버가 RabbitMQ 분석 요청을 소비하고, 분석 결과를 COMPLETED, PARTIAL, FAILED 이벤트로 분류하여 발행하도록 AI Worker를 구현했습니다.

결과 발행 이후에만 요청을 ACK하며, 복구 불가능한 메시지는 원문을 제외한 정제된 DLQ 이벤트로 처리합니다.

외부 API 재시도와 Graceful Shutdown, Docker Healthcheck 및 GitHub Actions CI도 추가했습니다.

🔗 관련 이슈

🎯 주요 변경 사항

분석 결과 이벤트 계약 정의

  • 분석 결과를 COMPLETED, PARTIAL, FAILED 상태로 분류

결과 이벤트 발행 및 ACK 처리

  • 분석 결과를 Integration Event로 변환하는 Result Factory 구현
  • RabbitMQ Publisher를 통해 상태별 결과 이벤트를 발행하도록 구성
  • 결과 이벤트 발행 성공 이후에만 요청 메시지를 ACK

재시도 및 DLQ 처리

  • 일시적인 처리 오류는 제한적으로 재시도하도록 구현
  • 잘못된 요청과 재시도 초과 메시지는 정제된 DLQ 이벤트로 발행

외부 API 안정성 강화

  • Gemini(임시), Google Safe Browsing, VirusTotal 및 URL 추적 timeout을 설정으로 분리
  • timeout, 연결 오류, HTTP 429 및 5xx 응답에 제한적 재시도 적용
  • 재시도할 수 없는 오류는 즉시 실패 결과로 처리하도록 구분

Graceful Shutdown 구현

  • 애플리케이션 종료 시 신규 메시지 수신을 먼저 중단
  • 처리 중인 분석 요청은 설정된 제한 시간까지 완료되도록 대기
  • 제한 시간을 초과한 미완료 메시지는 ACK하지 않아 RabbitMQ가 재전달할 수 있도록 구현

Docker 및 CI 구성

  • Docker 이미지에 FastAPI Healthcheck를 추가
  • GitHub Actions에 테스트 및 Docker 이미지 빌드 Workflow 추가

📸 사진

✅ PR 체크리스트

  • 관련 이슈를 연결했습니다.
  • 구현 범위와 변경 이유를 설명했습니다.
  • 로컬 테스트(uvicorn 구동 또는 테스트 코드)를 통과했습니다.
  • API 변경 사항이 있다면 Swagger / API 명세에 반영했습니다.
  • 민감 정보(API Key, 시크릿 키 등)가 코드·로그·테스트 데이터에 포함되지 않았습니다.
  • 프론트엔드 또는 메인 백엔드(Spring)에 영향을 주는 응답 스키마 또는 Enum 변경이 있다면 팀에 공유했습니다.
  • 병합(Merge) 전 작업 브랜치를 삭제하지 않았습니다.

Summary by CodeRabbit

  • New Features
    • Added structured analysis outcome handling (completed/partial/failed) with richer result payloads and routing.
    • Enhanced URL scanning with provider availability details and standardized error codes.
    • Introduced dead-letter publishing and clearer failure codes; added external API timeout/retry configuration.
  • Bug Fixes
    • Improved risk scoring and execution classification when signals are missing/unavailable, including a safe “no reliable signal” failure path.
  • Tests & CI
    • Expanded unit and integration coverage for execution classification, scoring, URL analysis, and result/DLQ schemas; added CI for tests and Docker builds.
  • Chores
    • Docker updates: non-root runtime, healthcheck, and added .dockerignore.

@pearseona pearseona self-assigned this Jul 28, 2026
@pearseona pearseona added the feat New feature or functional additions to the application label Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The PR adds availability-aware scoring, centralized external API retries and timeouts, structured RabbitMQ result events, graceful consumer shutdown, retry/DLQ handling, Docker health checks, and CI validation.

Analysis reliability

Layer / File(s) Summary
Availability-aware scoring pipeline
app/analysis/*, tests/analysis/*
Execution results are classified as completed, partial, or failed; scoring normalizes weights across available tracks and pipeline aggregation handles unavailable signals.
External API availability and retries
app/core/config.py, app/infrastructure/http_retry.py, app/infrastructure/{gemini,google_safe_browsing,virustotal}/*, app/analysis/url/*
External clients and URL tracing use configured timeouts, retry behavior, provider status normalization, and structured error results.

RabbitMQ processing

Layer / File(s) Summary
RabbitMQ event contracts and publishers
app/infrastructure/rabbitmq/{schemas,result_factory,publisher,dead_letter,connection}.py, related tests
Result and DLQ schemas, event conversion, persistent publishing, DLQ topology, processing errors, and sanitized failure messages are implemented and tested.
Consumer processing and failure routing
app/infrastructure/rabbitmq/{consumer,handler}.py, app/main.py, related tests
The consumer validates requests, publishes classified results before ACKing, retries failures, routes terminal errors to the DLQ, and tracks in-flight tasks during shutdown.

Build validation

Layer / File(s) Summary
Build, container, and CI validation
.dockerignore, Dockerfile, .github/workflows/ai-ci.yml
Docker build contexts exclude development artifacts, the container gains an HTTP health check, and CI runs Python tests and a Docker build.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change set: AI analysis result event handling and worker failure processing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/22-publish-analysis-results

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (17)
Dockerfile (1)

17-18: 🩺 Stability & Availability | 🔵 Trivial

Make the healthcheck reflect worker readiness, or document it as liveness-only.

The probed / handler returns a static healthy payload and does not verify RabbitMQ or analysis-consumer state. A container with a live HTTP server but a disconnected consumer will therefore remain healthy. If this status drives readiness or traffic routing, add a dependency-aware readiness endpoint; otherwise explicitly treat this check as liveness-only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 17 - 18, Update the Dockerfile healthcheck to use a
dependency-aware readiness endpoint that verifies RabbitMQ and analysis-consumer
state, or explicitly document and configure the existing root check as
liveness-only if it is not used for readiness or traffic routing.
app/analysis/schemas.py (1)

40-44: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale rationale comment above the widened constraints.

The comment above (상한이 두 시나리오 중 더 큰 쪽 기준으로 설정됨) explains a two-scenario (has-url / no-url) cap rationale, but the fields now use a flat ge=0, le=100 for all three, driven by dynamic availability-based weight renormalization in scoring.py (any single track can approach 100% weight when others are unavailable) — not just the two static scenarios described. Worth updating the comment so it doesn't mislead future readers about why the bound is 100.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/schemas.py` around lines 40 - 44, Update the Korean rationale
comment above the llm, hybrid_url, and rules fields to explain that dynamic
availability-based weight renormalization can make any remaining track approach
100% when others are unavailable. Remove the outdated two-scenario URL/LLM/rules
redistribution explanation, while keeping the existing ge=0 and le=100
constraints unchanged.
app/analysis/execution.py (1)

41-47: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Asymmetric granularity: Gemini-only failure is indistinguishable from total text-track failure.

When Gemini fails but Naive Bayes already produced a trustworthy score, service.py treats the text track as text_available=True and keeps its full scoring weight (no fallback/derating) — the design comments explain this is intentionally low-risk (the NB score is still trusted). Yet classify_execution maps this case to the same generic "TEXT" tag (via grade == "UNKNOWN") used when both engines fail and an arbitrary BOTH_ENGINES_UNAVAILABLE_FALLBACK_SCORE is substituted — a materially different reliability situation. The mirror case (NB fails, Gemini rescues) already gets a distinct "TEXT:NAIVE_BAYES" tag; there's no equivalent "TEXT:GEMINI" tag for symmetry.

This doesn't change final_score/risk_grade (that's computed correctly in scoring.py), but it degrades the precision of the PARTIAL event's failed_tracks payload for downstream consumers/observability trying to distinguish "one sub-engine down, still reliable" from "no text signal at all, using a constant fallback."

♻️ Suggested distinction
     if (
-        text_result.get("grade") == "UNKNOWN"
+        text_result.get("grade") == "UNKNOWN"
+        and stage1_result.get("grade") in (None, "UNKNOWN")
         or text_result.get("error_message")
     ):
         failed_tracks.append("TEXT")
     elif stage1_result.get("error_message"):
         failed_tracks.append("TEXT:NAIVE_BAYES")
+    elif text_result.get("grade") == "UNKNOWN":
+        failed_tracks.append("TEXT:GEMINI")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/execution.py` around lines 41 - 47, Update classify_execution’s
text failure classification so a Gemini-only failure with a valid Naive Bayes
score appends "TEXT:GEMINI" instead of the generic "TEXT" tag. Preserve "TEXT"
for cases where both text engines fail and retain the existing
"TEXT:NAIVE_BAYES" behavior when Naive Bayes fails.
tests/analysis/test_execution.py (1)

1-108: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for TEXT / TEXT:NAIVE_BAYES / RULES failure branches.

Only URL-track failure paths and the terminal ERROR/PIPELINE path are tested. classify_execution's text-track branch ("TEXT" vs "TEXT:NAIVE_BAYES") and the rules-failure branch ("RULES") are untested, despite being the core logic that determines the COMPLETED/PARTIAL event classification for the majority of real-world failure modes (e.g., Gemini timeout, rule-engine crash).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/analysis/test_execution.py` around lines 1 - 108, Add focused tests for
classify_execution covering text analysis failures that produce the expected
TEXT or TEXT:NAIVE_BAYES failed-track identifiers, and rule_analysis failures
that produce RULES. Assert each scenario’s COMPLETED/PARTIAL status and
failed_tracks, while preserving the existing URL and pipeline error coverage.
app/analysis/url/analyzer.py (2)

27-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nit: annotate as ClassVar[frozenset[str]].

Silences Ruff RUF012 and makes the constant immutable.

-    AVAILABLE_STATUSES = {
-        "safe",
-        "completed",
-    }
+    AVAILABLE_STATUSES: ClassVar[frozenset[str]] = frozenset(
+        {"safe", "completed"}
+    )

Requires from typing import ClassVar.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/url/analyzer.py` around lines 27 - 30, Update the class-level
AVAILABLE_STATUSES constant to be annotated as ClassVar[frozenset[str]], import
ClassVar from typing, and ensure its value remains an immutable frozenset to
satisfy Ruff RUF012.

Source: Linters/SAST tools


223-265: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the shared unavailable payload.

_scan_gsb and _scan_virustotal are identical apart from the client and log message; a single helper (_call_provider(client, name)) removes the duplication and keeps the fallback payload in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/url/analyzer.py` around lines 223 - 265, Extract the duplicated
provider-call and unavailable-response logic from _scan_gsb and _scan_virustotal
into a shared helper such as _call_provider(client, name). Have the helper
invoke the supplied client, log the provider-specific failure, and return the
common fallback payload; update both scan methods to delegate to it while
preserving their existing client and provider names.
app/analysis/url/tracker.py (1)

181-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bind the loop variables in the retry lambdas (Ruff B023).

The lambdas are invoked within the same iteration, so behavior is correct today, but the closures still capture current_url/headers late and Ruff flags them. Binding them as default args silences the warning and keeps the call safe against future refactors.

♻️ Suggested change
-                response = await request_with_retry(
-                    lambda: client.head(
-                        current_url,
-                        headers=headers,
+                response = await request_with_retry(
+                    lambda url=current_url, hdrs=headers: client.head(
+                        url,
+                        headers=hdrs,
                         timeout=timeout,
                     ),
                     max_retries=settings.EXTERNAL_API_MAX_RETRIES,
                     operation_name="URL trace HEAD",
                 )
 
                 # HEAD를 차단하거나 거부하는 서버(400, 404, 405)에 대응하기 위한 GET 폴백
                 if response.status_code in [400, 404, 405]:
-                    response = await request_with_retry(
-                        lambda: client.get(
-                            current_url,
-                            headers=headers,
+                    response = await request_with_retry(
+                        lambda url=current_url, hdrs=headers: client.get(
+                            url,
+                            headers=hdrs,
                             timeout=timeout,
                         ),
                         max_retries=settings.EXTERNAL_API_MAX_RETRIES,
                         operation_name="URL trace GET",
                     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/analysis/url/tracker.py` around lines 181 - 201, Update the retry lambdas
in the URL trace flow to bind the current iteration’s current_url and headers as
default arguments, for both the HEAD request and the GET fallback. Preserve the
existing client.head/client.get calls and retry behavior while eliminating Ruff
B023 late-binding warnings.

Source: Linters/SAST tools

app/infrastructure/http_retry.py (1)

34-68: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Retries fire immediately with no backoff.

On 429/5xx and network errors the next attempt is issued instantly, which amplifies load on an already-struggling upstream and makes rate-limit recovery unlikely. Add exponential backoff with jitter (and ideally honor Retry-After on 429).

♻️ Sketch
+import asyncio
+import random
+
+_BASE_DELAY_SECONDS = 0.5
+
+
+async def _sleep_before_retry(attempt: int, retry_after: str | None = None) -> None:
+    if retry_after:
+        try:
+            await asyncio.sleep(min(float(retry_after), 10.0))
+            return
+        except ValueError:
+            pass
+    delay = _BASE_DELAY_SECONDS * (2 ** attempt)
+    await asyncio.sleep(delay * (0.5 + random.random() / 2))

Then await _sleep_before_retry(attempt) before each continue (passing response.headers.get("Retry-After") in the status-code branch).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/http_retry.py` around lines 34 - 68, Update the retry loop
around the network-error and retryable-status branches to await a backoff helper
before each retry continue. Implement or reuse _sleep_before_retry(attempt) with
exponential backoff and jitter, and pass the 429 response’s Retry-After header
in the status branch so that server guidance is honored when available.
app/infrastructure/rabbitmq/result_factory.py (1)

187-190: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Redundant int() cast flagged by Ruff.

round(float(value)) without ndigits already returns an int; the outer int(...) is unnecessary.

🧹 Suggested fix
 def _integer_score(value) -> int | None:
     if value is None:
         return None
-    return max(0, min(100, int(round(float(value)))))
+    return max(0, min(100, round(float(value))))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/result_factory.py` around lines 187 - 190, Update
_integer_score to remove the redundant int() wrapper around round(float(value)),
preserving the existing clamping behavior and return type.

Source: Linters/SAST tools

app/infrastructure/rabbitmq/publisher.py (1)

61-71: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No failure classification on publish timeout/errors, unlike DeadLetterPublisher.publish().

DeadLetterPublisher.publish() wraps its asyncio.wait_for call in try/except to raise RetryableProcessingError with a specific failure_code on timeout vs. generic failure. AnalysisResultPublisher.publish() has no equivalent handling, so a timeout/unroutable-message failure here propagates as a raw exception without a descriptive failure code for downstream retry/DLQ logging.

♻️ Suggested fix mirroring dead_letter.py
-        await asyncio.wait_for(
-            self.exchange.publish(
-                message,
-                routing_key=routing_key,
-                mandatory=True,
-            ),
-            timeout=(
-                self.settings
-                .RABBITMQ_PUBLISH_TIMEOUT_SECONDS
-            ),
-        )
+        try:
+            await asyncio.wait_for(
+                self.exchange.publish(
+                    message,
+                    routing_key=routing_key,
+                    mandatory=True,
+                ),
+                timeout=(
+                    self.settings
+                    .RABBITMQ_PUBLISH_TIMEOUT_SECONDS
+                ),
+            )
+        except TimeoutError as exception:
+            raise RetryableProcessingError(
+                message="Result event publication timed out",
+                failure_code="RESULT_PUBLISH_TIMEOUT",
+            ) from exception
+        except Exception as exception:
+            raise RetryableProcessingError(
+                message="Failed to publish result event",
+                failure_code="RESULT_PUBLISH_FAILED",
+            ) from exception
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/publisher.py` around lines 61 - 71, Update
AnalysisResultPublisher.publish() around its asyncio.wait_for call to classify
failures like DeadLetterPublisher.publish(): convert asyncio.TimeoutError into
RetryableProcessingError with the timeout-specific failure_code, and convert
other publish exceptions into RetryableProcessingError with the generic publish
failure_code. Preserve the existing publish arguments and timeout configuration.
app/infrastructure/rabbitmq/schemas.py (3)

121-127: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

riskGrade/phishingType are unconstrained strings instead of reusing the existing RiskGrade enum.

app.analysis.schemas.RiskGrade already exists (used by result_factory.py via getattr(result.risk_grade, "value", ...) and imported directly in tests). Typing riskGrade as str | None here means any string passes validation except the single hard-coded "LOW" check on FAILED — typos or inconsistent casing wouldn't be caught.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/schemas.py` around lines 121 - 127, Update
AnalysisResultPayload to type riskGrade and phishingType with the existing
RiskGrade enum from app.analysis.schemas instead of unconstrained strings, while
preserving their optional nullable defaults and existing validation behavior.

121-138: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

AnalysisResultPayload doesn't enforce mutual exclusivity between success/failure fields.

finalScore/riskGrade and failureCode/failedTracks can all be populated simultaneously on the same payload; nothing in the model prevents a caller from setting both a completed score and a failure code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/schemas.py` around lines 121 - 138, Update
AnalysisResultPayload with model-level validation that enforces mutually
exclusive success and failure states: completed score fields
(finalScore/riskGrade) must not coexist with failure indicators
(failureCode/failedTracks). Preserve valid success-only, failure-only, and
existing optional analysis-field payloads while rejecting mixed states.

154-187: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

validate_event_result only checks required-field presence, not forbidden combinations.

For COMPLETED, nothing stops failureCode being set or failedTracks being non-empty; for PARTIAL/FAILED there's likewise no check that fields belonging to the "wrong" state are absent (the FAILED branch only special-cases riskGrade == "LOW"). If result_factory.py (or a future caller) ever produces an inconsistent combination, this validator will still accept it and publish a self-contradictory event to Spring.

♻️ Example hardening for the COMPLETED branch (apply similarly to PARTIAL/FAILED)
         if self.eventType == AnalysisEventType.COMPLETED:
             if self.payload.finalScore is None:
                 raise ValueError(
                     "completed event requires finalScore"
                 )
             if self.payload.riskGrade is None:
                 raise ValueError(
                     "completed event requires riskGrade"
                 )
+            if self.payload.failureCode is not None:
+                raise ValueError(
+                    "completed event must not set failureCode"
+                )
+            if self.payload.failedTracks:
+                raise ValueError(
+                    "completed event must not report failedTracks"
+                )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/schemas.py` around lines 154 - 187, Strengthen
validate_event_result in AnalysisResultEvent to reject state-incompatible
payload fields, not just missing required fields: COMPLETED must not include
failureCode or non-empty failedTracks, and PARTIAL/FAILED must likewise reject
fields belonging to other event states. Preserve the existing required-field
checks and FAILED riskGrade restriction while enforcing mutually exclusive
payload combinations for every AnalysisEventType.
tests/infrastructure/rabbitmq/test_result_factory.py (1)

102-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding coverage for the untested branches flagged in result_factory.py.

No test currently exercises a stage1_naive_bayes-absent + Gemini-only scenario (to pin down whether TextAnalysisMethod.GEMINI should ever be produced) or a url_risk_score of exactly 1 (0-100 vs 0-1 scale ambiguity).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/infrastructure/rabbitmq/test_result_factory.py` around lines 102 - 159,
Extend the result-factory tests around AnalysisResultEventFactory.create to
cover a result with stage1_naive_bayes absent and Gemini-only text analysis,
asserting the intended TextAnalysisMethod behavior, and add a case where
url_risk_score is exactly 1, asserting the expected 0–100 score mapping. Reuse
the existing request/result helpers and preserve current successful and failed
execution coverage.
app/infrastructure/rabbitmq/connection.py (1)

49-93: 🧹 Nitpick | 🔵 Trivial

Operational note: mandatory=True result/DLQ publishes require the consumer-side queue binding to already exist.

With on_return_raises=True and mandatory=True on the result routing keys (bound only by the downstream Spring service, not declared here), publishing before Spring has declared its queue bindings will raise on the AI worker side. Worth confirming deployment/rollout ordering guarantees this, or that the retry path in the consumer tolerates it gracefully.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/connection.py` around lines 49 - 93, The RabbitMQ
setup in the connection initialization flow must ensure result/DLQ publishes are
not attempted before the downstream Spring consumer has established its queue
bindings. Confirm and enforce the deployment or rollout ordering around the
existing exchange and queue declarations, or update the consumer retry path to
gracefully handle mandatory publish failures raised by on_return_raises=True.
tests/infrastructure/rabbitmq/test_consumer.py (1)

94-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Graceful-shutdown drain is untested.

stop() gained non-trivial logic (subscription cancel, in-flight task snapshot, asyncio.wait timeout branch), but no test here or in test_lifecycle.py exercises it. Worth adding cases for: pending task completes within the timeout, and timeout reached with tasks still pending.

Want me to draft those tests?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/infrastructure/rabbitmq/test_consumer.py` around lines 94 - 120, The
graceful-shutdown behavior of AnalysisRequestConsumer.stop is untested. Add
tests using create_consumer that cover both an in-flight task completing within
the shutdown timeout and asyncio.wait timing out while tasks remain pending,
asserting subscription cancellation and the corresponding drain behavior in each
case.
app/infrastructure/rabbitmq/consumer.py (1)

58-64: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

retry_attempts can grow unboundedly.

Entries are only removed on success, DLQ routing, or exhaustion. Messages that are nacked with requeue=True but never redelivered (connection drop, queue purge, broker-side loss) leave a permanent entry. For a long-lived consumer, consider a bounded structure (e.g. OrderedDict with an LRU cap or a TTL sweep) instead of a plain dict.

♻️ Sketch
-        self.retry_attempts: dict[str, int] = {}
+        # bounded LRU so abandoned retries cannot accumulate
+        self.retry_attempts: OrderedDict[str, int] = OrderedDict()
+        self.max_tracked_retries = 10_000

and when inserting:

self.retry_attempts[event_id] = retry_attempt + 1
self.retry_attempts.move_to_end(event_id)
while len(self.retry_attempts) > self.max_tracked_retries:
    self.retry_attempts.popitem(last=False)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/consumer.py` around lines 58 - 64, Bound
retry_attempts growth in the consumer initialization and retry-tracking logic.
Replace the unbounded dict with an OrderedDict or equivalent bounded structure,
define a suitable max_tracked_retries limit, and when recording a retry update
its recency and evict the oldest entries until the limit is respected. Preserve
existing removal behavior for successful, DLQ-routed, and exhausted messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ai-ci.yml:
- Around line 19-20: Disable persisted checkout credentials in both checkout
steps in .github/workflows/ai-ci.yml at lines 19-20 and 46-47 by setting
persist-credentials to false under each actions/checkout@v4 configuration.

In `@app/analysis/service.py`:
- Around line 133-140: Update the zero-score guard in the analysis service to
account for rules availability: a valid rules-only verdict with rules_available
true must not raise ValueError solely because rule_score is zero. Preserve the
existing failure behavior when no reliable signal is available, using the
availability flags consistently with the scoring engine.

In `@app/analysis/url/analyzer.py`:
- Around line 192-196: Update the is_final_malicious calculation to stop
treating vt_malicious_count >= 1 as independently malicious; rely on the
VirusTotalClient.scan_url result is_vt_malicious, which already applies the
configured threshold, while preserving the Google Safe Browsing and existing
VirusTotal checks.
- Around line 265-277: Remove the unreachable duplicate except Exception handler
from _scan_virustotal, retaining the first handler’s logging and fallback
response unchanged.

In `@app/analysis/url/tracker.py`:
- Around line 31-37: Move the triple-quoted documentation block to the first
statement inside _is_public_host, before the dns_timeout defaulting logic, so
Python recognizes it as the function docstring while preserving the existing
timeout behavior.

In `@app/infrastructure/google_safe_browsing/client.py`:
- Around line 13-16: Update the Safe Browsing client’s api_url construction and
request handling so API keys are not embedded in URLs that may appear in
retryable failure logs. Keep a key-free base URL in the client, pass the key
separately as a query parameter for requests, and ensure exceptions propagated
to http_retry do not contain the key.

In `@app/infrastructure/rabbitmq/consumer.py`:
- Around line 331-342: Update the exception path surrounding the sanitized DLQ
publish and message.nack in the consumer flow to await a short backoff before
requeueing. Keep the existing requeue=True behavior and error logging, ensuring
repeated DLQ failures are throttled while preserving retry handling.
- Around line 266-315: Replace the per-process retry_attempts logic in the
consumer retry/DLQ flow with broker-persisted delivery count state, using
x-death or x-delivery-count as the authoritative attempt count so retries remain
bounded across replicas and restarts. Preserve the existing
PROCESSING_RETRIES_EXHAUSTED DLQ routing and logging behavior, and change the
requeue path around message.nack to apply broker-supported delayed
redelivery/backoff instead of immediate requeueing.
- Around line 176-209: Update the success path in the consumer method containing
handler.handle, result_factory.create, result_publisher.publish, and message.ack
so redelivery cannot publish a duplicate result event for the same
request.eventId/analysisId. Prefer making the result event identity
deterministic or publishing idempotently using those request fields;
alternatively ensure ACK failure is treated as fatal and cannot trigger normal
redelivery. Preserve existing retry and dead-letter handling for processing
failures.

In `@app/infrastructure/rabbitmq/result_factory.py`:
- Around line 160-169: Update _url_detail so UrlAnalysisDetail.errorCode uses
the machine-readable provider error-code field rather than the human-readable
error_message text. Reuse the existing URL payload key containing the stable
provider code, and preserve error_message separately only if the model exposes a
dedicated message field.

---

Nitpick comments:
In `@app/analysis/execution.py`:
- Around line 41-47: Update classify_execution’s text failure classification so
a Gemini-only failure with a valid Naive Bayes score appends "TEXT:GEMINI"
instead of the generic "TEXT" tag. Preserve "TEXT" for cases where both text
engines fail and retain the existing "TEXT:NAIVE_BAYES" behavior when Naive
Bayes fails.

In `@app/analysis/schemas.py`:
- Around line 40-44: Update the Korean rationale comment above the llm,
hybrid_url, and rules fields to explain that dynamic availability-based weight
renormalization can make any remaining track approach 100% when others are
unavailable. Remove the outdated two-scenario URL/LLM/rules redistribution
explanation, while keeping the existing ge=0 and le=100 constraints unchanged.

In `@app/analysis/url/analyzer.py`:
- Around line 27-30: Update the class-level AVAILABLE_STATUSES constant to be
annotated as ClassVar[frozenset[str]], import ClassVar from typing, and ensure
its value remains an immutable frozenset to satisfy Ruff RUF012.
- Around line 223-265: Extract the duplicated provider-call and
unavailable-response logic from _scan_gsb and _scan_virustotal into a shared
helper such as _call_provider(client, name). Have the helper invoke the supplied
client, log the provider-specific failure, and return the common fallback
payload; update both scan methods to delegate to it while preserving their
existing client and provider names.

In `@app/analysis/url/tracker.py`:
- Around line 181-201: Update the retry lambdas in the URL trace flow to bind
the current iteration’s current_url and headers as default arguments, for both
the HEAD request and the GET fallback. Preserve the existing
client.head/client.get calls and retry behavior while eliminating Ruff B023
late-binding warnings.

In `@app/infrastructure/http_retry.py`:
- Around line 34-68: Update the retry loop around the network-error and
retryable-status branches to await a backoff helper before each retry continue.
Implement or reuse _sleep_before_retry(attempt) with exponential backoff and
jitter, and pass the 429 response’s Retry-After header in the status branch so
that server guidance is honored when available.

In `@app/infrastructure/rabbitmq/connection.py`:
- Around line 49-93: The RabbitMQ setup in the connection initialization flow
must ensure result/DLQ publishes are not attempted before the downstream Spring
consumer has established its queue bindings. Confirm and enforce the deployment
or rollout ordering around the existing exchange and queue declarations, or
update the consumer retry path to gracefully handle mandatory publish failures
raised by on_return_raises=True.

In `@app/infrastructure/rabbitmq/consumer.py`:
- Around line 58-64: Bound retry_attempts growth in the consumer initialization
and retry-tracking logic. Replace the unbounded dict with an OrderedDict or
equivalent bounded structure, define a suitable max_tracked_retries limit, and
when recording a retry update its recency and evict the oldest entries until the
limit is respected. Preserve existing removal behavior for successful,
DLQ-routed, and exhausted messages.

In `@app/infrastructure/rabbitmq/publisher.py`:
- Around line 61-71: Update AnalysisResultPublisher.publish() around its
asyncio.wait_for call to classify failures like DeadLetterPublisher.publish():
convert asyncio.TimeoutError into RetryableProcessingError with the
timeout-specific failure_code, and convert other publish exceptions into
RetryableProcessingError with the generic publish failure_code. Preserve the
existing publish arguments and timeout configuration.

In `@app/infrastructure/rabbitmq/result_factory.py`:
- Around line 187-190: Update _integer_score to remove the redundant int()
wrapper around round(float(value)), preserving the existing clamping behavior
and return type.

In `@app/infrastructure/rabbitmq/schemas.py`:
- Around line 121-127: Update AnalysisResultPayload to type riskGrade and
phishingType with the existing RiskGrade enum from app.analysis.schemas instead
of unconstrained strings, while preserving their optional nullable defaults and
existing validation behavior.
- Around line 121-138: Update AnalysisResultPayload with model-level validation
that enforces mutually exclusive success and failure states: completed score
fields (finalScore/riskGrade) must not coexist with failure indicators
(failureCode/failedTracks). Preserve valid success-only, failure-only, and
existing optional analysis-field payloads while rejecting mixed states.
- Around line 154-187: Strengthen validate_event_result in AnalysisResultEvent
to reject state-incompatible payload fields, not just missing required fields:
COMPLETED must not include failureCode or non-empty failedTracks, and
PARTIAL/FAILED must likewise reject fields belonging to other event states.
Preserve the existing required-field checks and FAILED riskGrade restriction
while enforcing mutually exclusive payload combinations for every
AnalysisEventType.

In `@Dockerfile`:
- Around line 17-18: Update the Dockerfile healthcheck to use a dependency-aware
readiness endpoint that verifies RabbitMQ and analysis-consumer state, or
explicitly document and configure the existing root check as liveness-only if it
is not used for readiness or traffic routing.

In `@tests/analysis/test_execution.py`:
- Around line 1-108: Add focused tests for classify_execution covering text
analysis failures that produce the expected TEXT or TEXT:NAIVE_BAYES
failed-track identifiers, and rule_analysis failures that produce RULES. Assert
each scenario’s COMPLETED/PARTIAL status and failed_tracks, while preserving the
existing URL and pipeline error coverage.

In `@tests/infrastructure/rabbitmq/test_consumer.py`:
- Around line 94-120: The graceful-shutdown behavior of
AnalysisRequestConsumer.stop is untested. Add tests using create_consumer that
cover both an in-flight task completing within the shutdown timeout and
asyncio.wait timing out while tasks remain pending, asserting subscription
cancellation and the corresponding drain behavior in each case.

In `@tests/infrastructure/rabbitmq/test_result_factory.py`:
- Around line 102-159: Extend the result-factory tests around
AnalysisResultEventFactory.create to cover a result with stage1_naive_bayes
absent and Gemini-only text analysis, asserting the intended TextAnalysisMethod
behavior, and add a case where url_risk_score is exactly 1, asserting the
expected 0–100 score mapping. Reuse the existing request/result helpers and
preserve current successful and failed execution coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a36d21f9-168a-430d-b67d-86cf911314b6

📥 Commits

Reviewing files that changed from the base of the PR and between 2d2a60c and 730728d.

📒 Files selected for processing (34)
  • .dockerignore
  • .github/workflows/ai-ci.yml
  • Dockerfile
  • app/analysis/execution.py
  • app/analysis/schemas.py
  • app/analysis/scoring.py
  • app/analysis/service.py
  • app/analysis/text/gemini_analyzer.py
  • app/analysis/url/analyzer.py
  • app/analysis/url/tracker.py
  • app/core/config.py
  • app/infrastructure/errors.py
  • app/infrastructure/gemini/client.py
  • app/infrastructure/google_safe_browsing/client.py
  • app/infrastructure/http_retry.py
  • app/infrastructure/rabbitmq/connection.py
  • app/infrastructure/rabbitmq/consumer.py
  • app/infrastructure/rabbitmq/dead_letter.py
  • app/infrastructure/rabbitmq/handler.py
  • app/infrastructure/rabbitmq/publisher.py
  • app/infrastructure/rabbitmq/result_factory.py
  • app/infrastructure/rabbitmq/schemas.py
  • app/infrastructure/virustotal/client.py
  • app/main.py
  • tests/analysis/test_execution.py
  • tests/analysis/test_scoring.py
  • tests/analysis/url/test_analyzer.py
  • tests/infrastructure/rabbitmq/test_connection.py
  • tests/infrastructure/rabbitmq/test_consumer.py
  • tests/infrastructure/rabbitmq/test_dead_letter.py
  • tests/infrastructure/rabbitmq/test_handler.py
  • tests/infrastructure/rabbitmq/test_lifecycle.py
  • tests/infrastructure/rabbitmq/test_result_factory.py
  • tests/infrastructure/rabbitmq/test_result_schemas.py
💤 Files with no reviewable changes (1)
  • app/analysis/text/gemini_analyzer.py

Comment thread .github/workflows/ai-ci.yml
Comment thread app/analysis/service.py Outdated
Comment thread app/analysis/url/analyzer.py
Comment thread app/analysis/url/analyzer.py Outdated
Comment thread app/analysis/url/tracker.py
Comment thread app/infrastructure/google_safe_browsing/client.py
Comment on lines 176 to 209
try:
await self.handler.handle(event)
except Exception:
await self._handle_processing_failure(
result = await self.handler.handle(event)

execution = classify_execution(result)

result_event = self.result_factory.create(
request=event,
execution=execution,
)

await self.result_publisher.publish(
result_event
)
except NonRetryableProcessingError as exception:
await self._route_to_dead_letter(
message=message,
event=event,
failure_code=exception.failure_code,
)
return
except Exception as exception:
await self._handle_retryable_failure(
message=message,
event=event,
exception=exception,
)
return

self.retry_attempts.pop(str(event.eventId), None)
self.retry_attempts.pop(
str(event.eventId),
None,
)

await message.ack()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f result_factory.py --exec cat -n {}
fd -t f publisher.py -p app/infrastructure/rabbitmq --exec cat -n {}

Repository: SafeFam/SafeFam_AI

Length of output: 15255


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- consumer.py relevant sections ---\n'
sed -n '120,230p' app/infrastructure/rabbitmq/consumer.py | cat -n

printf '\n--- result schema fields ---\n'
fd -t f schemas.py -p app/infrastructure/rabbitmq --exec sed -n '1,220p' {} \;

printf '\n--- consumers/downstream references to result fields ---\n'
rg -n "AnalysisResultEvent|result.*Event|eventId|causationId|analysisId|correlation_id|message_id|RABBITMQ_ANALYSIS" app tests 2>/dev/null || true

Repository: SafeFam/SafeFam_AI

Length of output: 247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- consumer.py relevant sections ---'
sed -n '120,230p' app/infrastructure/rabbitmq/consumer.py | cat -n

printf '%s\n' ''
printf '%s\n' '--- result schema fields ---'
fd -t f schemas.py -p app/infrastructure/rabbitmq --exec sed -n '1,240p' {} \;

printf '%s\n' ''
printf '%s\n' '--- consumers/downstream references to result fields ---'
rg -n "AnalysisResultEvent|result.*Event|eventId|causationId|analysisId|correlation_id|message_id|RABBITMQ_ANALYSIS" app tests 2>/dev/null || true

Repository: SafeFam/SafeFam_AI

Length of output: 23552


Avoid publishing duplicate result events after successful ACK.

result_publisher.publish(...) is sent before message.ack(), so if ACK delivery fails or the process dies after publish, the queue redelivers and AnalysisResultEventFactory.create(...) generates a fresh result eventId for the same request.eventId/analysisId payload. Either make ack failure fatal to avoid redelivery, or publish retry-safe/idempotent result events keyed by request fields downstream.

🧰 Tools
🪛 Ruff (0.16.0)

[warning] 196-196: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/infrastructure/rabbitmq/consumer.py` around lines 176 - 209, Update the
success path in the consumer method containing handler.handle,
result_factory.create, result_publisher.publish, and message.ack so redelivery
cannot publish a duplicate result event for the same request.eventId/analysisId.
Prefer making the result event identity deterministic or publishing idempotently
using those request fields; alternatively ensure ACK failure is treated as fatal
and cannot trigger normal redelivery. Preserve existing retry and dead-letter
handling for processing failures.

Comment thread app/infrastructure/rabbitmq/consumer.py Outdated
Comment thread app/infrastructure/rabbitmq/consumer.py
Comment thread app/infrastructure/rabbitmq/result_factory.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/infrastructure/http_retry.py`:
- Around line 36-40: Update the retry-after parsing in the visible retry delay
logic to support HTTP-date strings using email.utils.parsedate_to_datetime,
converting the parsed date into a nonnegative delay relative to the current
time. Preserve the existing numeric-seconds handling and invalid-value fallback,
while ensuring both supported formats honor the provider’s retry window.

In `@tests/analysis/test_execution.py`:
- Around line 131-151: Extend classify_execution so a Naive Bayes result with
grade="UNKNOWN" is classified as failed even when error_message is None,
matching the existing stage1_failed behavior. Preserve the current PARTIAL
status and failed_tracks entry for valid Gemini results, and add coverage for
the missing-error-code case alongside
test_classifies_naive_bayes_failure_with_valid_gemini.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 325bf32e-713f-4d34-8c2f-2e92b2f30d4d

📥 Commits

Reviewing files that changed from the base of the PR and between 730728d and 89ffb64.

📒 Files selected for processing (17)
  • .github/workflows/ai-ci.yml
  • app/analysis/execution.py
  • app/analysis/service.py
  • app/analysis/url/analyzer.py
  • app/analysis/url/tracker.py
  • app/core/config.py
  • app/infrastructure/google_safe_browsing/client.py
  • app/infrastructure/http_retry.py
  • app/infrastructure/rabbitmq/consumer.py
  • app/infrastructure/rabbitmq/publisher.py
  • app/infrastructure/rabbitmq/result_factory.py
  • app/infrastructure/rabbitmq/schemas.py
  • tests/analysis/test_execution.py
  • tests/analysis/url/test_analyzer.py
  • tests/infrastructure/rabbitmq/test_consumer.py
  • tests/infrastructure/rabbitmq/test_publisher.py
  • tests/infrastructure/rabbitmq/test_result_factory.py
🚧 Files skipped from review as they are similar to previous changes (11)
  • app/infrastructure/rabbitmq/publisher.py
  • tests/analysis/url/test_analyzer.py
  • app/core/config.py
  • app/infrastructure/google_safe_browsing/client.py
  • app/analysis/url/analyzer.py
  • app/analysis/url/tracker.py
  • app/infrastructure/rabbitmq/result_factory.py
  • app/analysis/service.py
  • .github/workflows/ai-ci.yml
  • app/analysis/execution.py
  • tests/infrastructure/rabbitmq/test_consumer.py

Comment thread app/infrastructure/http_retry.py Outdated
Comment thread tests/analysis/test_execution.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Dockerfile`:
- Around line 26-27: Fix the Dockerfile CMD declaration by using valid multiline
JSON-array syntax with backslash continuations, or place the entire array on one
line. Ensure all quoted uvicorn arguments remain part of the same CMD
instruction and the image builds successfully.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e958dcc6-8f25-4188-9670-4244ea976412

📥 Commits

Reviewing files that changed from the base of the PR and between 89ffb64 and a246755.

📒 Files selected for processing (1)
  • Dockerfile

Comment thread Dockerfile Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/analysis/test_service.py`:
- Line 144: Update the docstring of
test_analyze_pipeline_uses_available_zero_score_rules_when_text_engines_are_down
to describe the current contract: when the rules track is available but produces
a zero score, the analysis returns SUCCESS with LOW confidence and a score of 0;
do not describe LOW as a fail-open error.

In `@tests/infrastructure/test_http_retry.py`:
- Around line 26-46: Make test_sleep_honors_http_date_retry_after deterministic
by freezing or mocking the clock used by _sleep_before_retry in
app.infrastructure.http_retry, then construct retry_at from that fixed time.
Keep the assertion focused on the expected approximately 20-second delay without
relying on the real system clock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: af9bd467-eb6e-427f-b76d-e62e298999a0

📥 Commits

Reviewing files that changed from the base of the PR and between a246755 and a474f3b.

📒 Files selected for processing (7)
  • Dockerfile
  • app/analysis/execution.py
  • app/analysis/service.py
  • app/infrastructure/http_retry.py
  • tests/analysis/test_execution.py
  • tests/analysis/test_service.py
  • tests/infrastructure/test_http_retry.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • app/analysis/execution.py
  • app/infrastructure/http_retry.py
  • app/analysis/service.py
  • Dockerfile
  • tests/analysis/test_execution.py

@patch("app.analysis.service.analyze_text_with_gemini", new_callable=AsyncMock)
@patch("app.analysis.service.analyze_text_with_naive_bayes", new_callable=AsyncMock)
async def test_analyze_pipeline_does_not_fail_open_when_both_text_engines_are_down(mock_nb, mock_gemini):
async def test_analyze_pipeline_uses_available_zero_score_rules_when_text_engines_are_down(mock_nb, mock_gemini):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the docstring with the new test contract.

The renamed test now intentionally expects SUCCESS/LOW/0 when the rules track is available but produces a zero score. The existing docstring still says that returning LOW is a fail-open error, contradicting the assertions and the scoring behavior.

Proposed docstring update
-    URL/규칙 신호가 전혀 없는 문자라도 최종 등급이 조용히 LOW로 나와선 안 된다.
-    두 분류기가 동시에 다운됐다는 인프라 장애가 "안전 확인됨"으로 둔갑하면 안 됨 (fail-open 방지).
+    텍스트 엔진이 다운되어도 규칙 트랙이 가용하고 점수가 0이면
+    정상적으로 LOW 등급을 반환해야 한다.
+    모든 분석 트랙이 unavailable한 경우에만 fail-closed 처리한다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def test_analyze_pipeline_uses_available_zero_score_rules_when_text_engines_are_down(mock_nb, mock_gemini):
async def test_analyze_pipeline_uses_available_zero_score_rules_when_text_engines_are_down(mock_nb, mock_gemini):
"""
텍스트 엔진이 다운되어도 규칙 트랙이 가용하고 점수가 0이면
정상적으로 LOW 등급을 반환해야 한다.
모든 분석 트랙이 unavailable한 경우에만 fail-closed 처리한다.
"""
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/analysis/test_service.py` at line 144, Update the docstring of
test_analyze_pipeline_uses_available_zero_score_rules_when_text_engines_are_down
to describe the current contract: when the rules track is available but produces
a zero score, the analysis returns SUCCESS with LOW confidence and a score of 0;
do not describe LOW as a fail-open error.

Comment on lines +26 to +46
@pytest.mark.asyncio
async def test_sleep_honors_http_date_retry_after() -> None:
retry_at = datetime.now(timezone.utc) + timedelta(
seconds=20
)
sleep = AsyncMock()

with patch(
"app.infrastructure.http_retry.asyncio.sleep",
sleep,
):
await _sleep_before_retry(
attempt=0,
retry_after=format_datetime(
retry_at,
usegmt=True,
),
)

delay = sleep.await_args.args[0]
assert 18.0 <= delay <= 20.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Make the HTTP-date test deterministic.

The test derives retry_at from the real clock and requires the calculated delay to remain within two seconds of 20. A slow CI runner or scheduling pause can make this test fail even when the retry logic is correct. Freeze or mock the clock used by app/infrastructure/http_retry.py.

Proposed fix
 async def test_sleep_honors_http_date_retry_after() -> None:
-    retry_at = datetime.now(timezone.utc) + timedelta(
-        seconds=20
-    )
+    now = datetime(2026, 1, 1, tzinfo=timezone.utc)
+    retry_at = now + timedelta(seconds=20)
     sleep = AsyncMock()

     with (
+        patch(
+            "app.infrastructure.http_retry.datetime"
+        ) as datetime_mock,
         patch(
             "app.infrastructure.http_retry.asyncio.sleep",
             sleep,
         ),
     ):
+        datetime_mock.now.return_value = now
         await _sleep_before_retry(
             attempt=0,
             retry_after=format_datetime(
@@
-    assert 18.0 <= delay <= 20.0
+    assert delay == pytest.approx(20.0)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@pytest.mark.asyncio
async def test_sleep_honors_http_date_retry_after() -> None:
retry_at = datetime.now(timezone.utc) + timedelta(
seconds=20
)
sleep = AsyncMock()
with patch(
"app.infrastructure.http_retry.asyncio.sleep",
sleep,
):
await _sleep_before_retry(
attempt=0,
retry_after=format_datetime(
retry_at,
usegmt=True,
),
)
delay = sleep.await_args.args[0]
assert 18.0 <= delay <= 20.0
`@pytest.mark.asyncio`
async def test_sleep_honors_http_date_retry_after() -> None:
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
retry_at = now + timedelta(seconds=20)
sleep = AsyncMock()
with (
patch(
"app.infrastructure.http_retry.datetime"
) as datetime_mock,
patch(
"app.infrastructure.http_retry.asyncio.sleep",
sleep,
),
):
datetime_mock.now.return_value = now
await _sleep_before_retry(
attempt=0,
retry_after=format_datetime(
retry_at,
usegmt=True,
),
)
delay = sleep.await_args.args[0]
assert delay == pytest.approx(20.0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/infrastructure/test_http_retry.py` around lines 26 - 46, Make
test_sleep_honors_http_date_retry_after deterministic by freezing or mocking the
clock used by _sleep_before_retry in app.infrastructure.http_retry, then
construct retry_at from that fixed time. Keep the assertion focused on the
expected approximately 20-second delay without relying on the real system clock.

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

Labels

feat New feature or functional additions to the application

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant