n8n: add Fleet Ops endpoint to receive workflow error reports
New POST /api/v1/integrations/n8n/workflow-error, service-token authenticated, for the central "Fleet Ops — Workflow Error Handler" n8n workflow to report a bounded, secret-free failure (workflow id/ name, execution id, safe error category, trigger context, correlation id, attempt, retry action). Idempotent on execution_id via the same audit-event precheck pattern used by /return-callback, so a redelivered error report is not registered twice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e0c107a94a
commit
bbdb4a9ae8
@@ -13,7 +13,7 @@ from app.core.config import get_settings
|
|||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models.audit import AuditEvent
|
from app.models.audit import AuditEvent
|
||||||
from app.models.outbox import OutboxEvent
|
from app.models.outbox import OutboxEvent
|
||||||
from app.schemas import ScanResultOut
|
from app.schemas import ScanResultOut, WorkflowErrorReportIn, WorkflowErrorReportResult
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
from app.services.data_quality import run_scan
|
from app.services.data_quality import run_scan
|
||||||
|
|
||||||
@@ -89,3 +89,60 @@ def scheduled_scan(
|
|||||||
|
|
||||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||||
return ScanResultOut(created=result.created)
|
return ScanResultOut(created=result.created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
|
||||||
|
def workflow_error(
|
||||||
|
body: WorkflowErrorReportIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> WorkflowErrorReportResult:
|
||||||
|
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
|
||||||
|
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
||||||
|
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||||
|
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||||
|
if service_token != settings.n8n_callback_token:
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
correlation_id: uuid.UUID | None = None
|
||||||
|
if body.correlation_id:
|
||||||
|
try:
|
||||||
|
correlation_id = uuid.UUID(body.correlation_id)
|
||||||
|
except ValueError:
|
||||||
|
correlation_id = None
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n error handler",
|
||||||
|
action="n8n_workflow_failure_registered",
|
||||||
|
entity_type="automation",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
after={
|
||||||
|
"workflow_id": body.workflow_id,
|
||||||
|
"workflow_name": body.workflow_name,
|
||||||
|
"error_category": body.error_category,
|
||||||
|
"error_summary": body.error_summary,
|
||||||
|
"trigger_context": body.trigger_context,
|
||||||
|
"attempt": body.attempt,
|
||||||
|
"retry_action": body.retry_action,
|
||||||
|
"failed_at": body.failed_at.isoformat(),
|
||||||
|
},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return WorkflowErrorReportResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|||||||
@@ -143,6 +143,27 @@ class ScanResultOut(BaseModel):
|
|||||||
created: dict[str, int]
|
created: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportIn(BaseModel):
|
||||||
|
workflow_id: str = Field(max_length=120)
|
||||||
|
workflow_name: str = Field(max_length=200)
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
failed_at: datetime
|
||||||
|
error_category: Literal[
|
||||||
|
"timeout", "authError", "connectionError", "httpError", "validationError", "unknown"
|
||||||
|
]
|
||||||
|
error_summary: str = Field(max_length=500)
|
||||||
|
trigger_context: str | None = Field(default=None, max_length=200)
|
||||||
|
correlation_id: str | None = None
|
||||||
|
attempt: int = Field(default=1, ge=1, le=1000)
|
||||||
|
retry_action: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ProvideFieldsRequest(BaseModel):
|
class ProvideFieldsRequest(BaseModel):
|
||||||
fields: dict[str, str]
|
fields: dict[str, str]
|
||||||
|
|
||||||
|
|||||||
@@ -122,3 +122,72 @@ def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
|
|||||||
assert first.status_code == 200
|
assert first.status_code == 200
|
||||||
assert second.status_code == 200
|
assert second.status_code == 200
|
||||||
assert second.json()["created"] == {}
|
assert second.json()["created"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_error_body(execution_id: str, **overrides):
|
||||||
|
body = {
|
||||||
|
"workflow_id": "mobilityops-return-processing",
|
||||||
|
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"execution_id": execution_id,
|
||||||
|
"failed_at": "2026-08-04T10:15:00Z",
|
||||||
|
"error_category": "httpError",
|
||||||
|
"error_summary": "Callback request failed with status 500",
|
||||||
|
"trigger_context": "webhook",
|
||||||
|
"correlation_id": None,
|
||||||
|
"attempt": 1,
|
||||||
|
"retry_action": "n8n will retry automatically",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_wrong_service_token(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4())),
|
||||||
|
headers={"X-Service-Token": "wrong-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_rejects_unknown_category(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||||
|
execution_id = str(uuid.uuid4())
|
||||||
|
body = _workflow_error_body(execution_id)
|
||||||
|
|
||||||
|
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["status"] == "registered"
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["status"] == "already_registered"
|
||||||
|
|
||||||
|
audit_events = ops_client.get(
|
||||||
|
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
|
||||||
|
).json()
|
||||||
|
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]["after"]["error_category"] == "httpError"
|
||||||
|
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_error_bounds_summary_length(client):
|
||||||
|
settings = get_settings()
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/integrations/n8n/workflow-error",
|
||||||
|
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
|
||||||
|
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|||||||
Reference in New Issue
Block a user