Files
googlecloudplatform--genera…/agents/adk/contract-compliance-pipeline/python-extraction-agent/app/fallback_handler.py
T
2026-07-13 13:30:30 +08:00

206 lines
7.4 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
import logging
import time
from google.adk.tools import ToolContext
from app.state_schema import ComplianceStep
# NOTE: This module is retained as a reference implementation showing the
# resilience pattern: timeout, retry, and fail-close to MANUAL_REVIEW. The live
# cockpit path calls the Go compliance agent explicitly from fast_api_app.py so
# the handoff is observable in the browser. agent.py keeps the ADK
# RemoteA2aAgent architecture reference.
async def invoke_a2a_compliance_check(tool_context):
"""Placeholder for the legacy A2A compliance check.
In the original implementation, this function was imported from
a2a_client.py which used hand-rolled HTTP calls to the Go agent.
The live cockpit now uses fast_api_app.invoke_go_compliance_service().
"""
raise NotImplementedError(
"Legacy A2A client removed. Use fast_api_app.invoke_go_compliance_service()."
)
logger = logging.getLogger(__name__)
def _log_structured_json(severity: str, message: str, **kwargs) -> None:
"""Standardized structured JSON logger for GCP/Cloud Logging visibility.
Rule 53 Enforcement: Logs diagnostic messages in parseable JSON structures,
ensuring sensitive session tokens are never printed.
"""
payload = {
"severity": severity,
"message": message,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%Z"),
**kwargs
}
logger.info(json.dumps(payload))
async def invoke_resilient_compliance_check(
tool_context: ToolContext,
timeout_sec: float = 30.0,
max_retries: int = 3,
backoff_factor: float = 1.5,
) -> dict:
"""Wraps A2A task audit dispatching in high-resilience triggers:
1. Handles 503 Crashed Server exceptions using exponential retry loops.
2. Enforces a strict 30-second timeout boundary.
3. Triggers safe fail-close path migrations (transitions step to MANUAL_REVIEW).
"""
state = tool_context.state
case_id = state.get("case_id", "local-case")
# Check if a custom timeout is toggled in memory settings
simulated_timeout = state.get("simulated_timeout", timeout_sec)
_log_structured_json(
severity="INFO",
message=f"Resilience Handler: Initializing compliance check for case {case_id}",
event="compliance_audit_start",
case_id=case_id,
timeout_boundary_sec=simulated_timeout
)
attempt = 0
backoff_delay = 1.0
while attempt < max_retries:
attempt += 1
try:
# Wrap the A2A submission and check in a strict timeout boundary
_log_structured_json(
severity="INFO",
message=f"Dispatching A2A audit request (Attempt {attempt} of {max_retries})",
event="a2a_dispatch_attempt",
case_id=case_id,
attempt=attempt
)
# Execute A2A client validation with timeout caps
res = await asyncio.wait_for(
invoke_a2a_compliance_check(tool_context),
timeout=simulated_timeout
)
# Check response status
if res.get("status") == "dormant_paused":
_log_structured_json(
severity="INFO",
message="External connection reports lag. Task submitted and pipeline entered dormant state.",
event="dormancy_entered",
case_id=case_id,
task_id=res.get("task_id")
)
return res
_log_structured_json(
severity="INFO",
message="A2A compliance validation audit finished successfully.",
event="compliance_audit_completed",
case_id=case_id
)
return res
except asyncio.TimeoutError:
# 30-second timeout reached
_log_structured_json(
severity="WARNING",
message=f"A2A connection timed out after {simulated_timeout} seconds limit.",
event="timeout_reached",
case_id=case_id,
timeout_limit_sec=simulated_timeout
)
break # Move straight to MANUAL_REVIEW fallback
except ConnectionError as e:
# 503 connection error or socket faults -> retry loop triggers
_log_structured_json(
severity="WARNING",
message=f"A2A connection failed on attempt {attempt}: {e!s}",
event="connection_fault",
case_id=case_id,
error=str(e)
)
if attempt >= max_retries:
_log_structured_json(
severity="ERROR",
message="Max A2A delivery retries exhausted. Triggering safety manual routing.",
event="retries_exhausted",
case_id=case_id
)
break
# Perform backoff
logger.info(f"Retrying compliance check after {backoff_delay} seconds delay...")
await asyncio.sleep(backoff_delay)
backoff_delay *= backoff_factor
except Exception as e:
# Non-retryable errors — fail immediately
_log_structured_json(
severity="ERROR",
message=f"A2A audit failed with non-retryable error: {e!s}",
event="non_retryable_error",
case_id=case_id,
error=str(e)
)
break
# --- FALLBACK GATEWAY (Fail Safe Transition to MANUAL_REVIEW) ---
_log_structured_json(
severity="ALERT",
message="Resilience Gate triggered: Transitioning case to MANUAL_REVIEW checkpoint.",
event="fallback_recovery_triggered",
case_id=case_id
)
# Update persistent state metrics safely
state["current_step"] = ComplianceStep.MANUAL_REVIEW
state["pending_signals"] = []
fallback_verdict = {
"passed": False,
"violations": [
"SYSTEM TIMEOUT: External compliance service failed to respond within 30-second threshold.",
"FAIL-SAFE ACTION: Document routed for legal manager manual verification."
],
"verdict_timestamp": time.strftime("%Y-%m-%d %H:%M:%S %Z")
}
state["compliance_verdict"] = fallback_verdict
# Append trace event logs
state["trace_logs"] = state.get("trace_logs", [])
state["trace_logs"].append({
"span": "resilience_fallback_gate",
"service": "python-extraction-agent",
"duration_ms": 450,
"status": "manual_review_routed"
})
return {
"status": "fallback_manual_review",
"verdict": fallback_verdict,
"message": "Resilience safety check triggered. Case successfully routed for manual legal review."
}