# AGENTS.md · n8n 에러 처리·예외 대응 작업 규칙 (76일차 실습 폴더) 이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 75일차 규칙과 같고, 3절이 오늘 만드는 **문의 일괄 접수 · 에러 처리 · 재처리의 계약**, 5절에 **항목마다 실패 · 재시도 · 멱등 키 · DLQ · Error Workflow** 노드가 더해졌습니다. ## 1. 작업 순서 (항상 이 순서) 1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 에러 처리 요청이면 **어떤 실패가 어디로 가는지(거절 · 재시도 · DLQ · 알림)** 를 표로 함께 보여 준다. 2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다). 3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다. 4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄. ```bash uv run tools/flow.py push my-work/<이름>.json uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json ``` 5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다 (push는 약 10초, n8n이 잠깐 재시작됨). 6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다. 7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다. ## 2. 환경 (바꾸지 말 것) | 항목 | 값 | | --- | --- | | n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`, 컨테이너 보통 `day65-n8n-n8n-1`), 브라우저 http://localhost:5678 | | n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) | | 게시된 웹훅 주소 | 밖에서 `http://localhost:5678/webhook/`, **n8n 안에서도** `http://localhost:5678/webhook/` | | OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` (분류). `gpt-4.1-nano`는 OpenAI가 받지 않음 | | 다른 Credential | 쓰지 않음 (Ollama 등 없음) | | 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**76일차 판** `Last3Mon/76/data-api`: 75일차 + 접수 건 만들기 · DLQ · 알림) | | 비교 기준 · 제공 | `reference/baseline-intake.json` (`base-intake`), `reference/nightly-report.json` (`nightly-report`, 일부러 버그가 있음). 고치지 않는다 | ### 데이터 API (76일차 판) | 요청 | 응답 | | --- | --- | | `POST /tickets` 본문 `{"key", "title", "requester", "category", "priority"}` | 201 `{"ticket_id": "T-2001", "duplicate": false}` · **같은 `key`면** 200 `{"ticket_id"(처음 것), "duplicate": true}` · `key` · `title`이 없으면 400 · 고장 스위치 `tickets`를 따른다 | | `GET /tickets-created` (`POST /tickets-created/list`) | `{"count", "tickets": [...]}` | | `POST /dlq` 본문 `{"items": [{"item": <원래 요청>, "reason", "workflow"}]}` | `{"stored", "size"}` · **같은 `item.id`면 새 줄 없이 `attempts` +1** | | `GET /dlq` (`POST /dlq/list`) | `{"size", "parked", "items": [{"dlq_id", "item", "reason", "attempts", "parked"}]}` · 고장 스위치 `dlq`를 따른다 (`POST /dlq`가 500) | | `POST /dlq/update` | `{"dlq_id", "remove": true}` 또는 `{"dlq_id", "parked": true, "reason"}` | | `POST /alerts` 본문 `{"workflow", "node", "message", "severity", "reason", ...}` | `{"stored", "count"}` | | `GET /alerts` · `POST /alerts/list` · `POST /alerts/latest` | 모든 알림 · **거르기** 본문 `{"workflow", "reason", "severity", "within": 초}` (예: `{"workflow": "intake", "reason": "dlq", "within": 600}`는 10분 안의 것만) · 가장 최근 알림 | | `POST /admin/fault` | `{"tickets": "ok" \| "slow" \| "error" \| "flaky", "dlq": ...}` — `flaky`: 접수 건 만들기에서 **키마다 첫 번째 시도만 실패**, `slow`: 8초 기다린 뒤 **그래도 접수 건을 만든다**(부른 쪽이 포기했어도) (**사람이** `flow.py fault`로) | | `POST /tickets-reset` · `/dlq-clear` · `/alerts-clear` | 비우기 (`flow.py ops --reset`이 한 번에) | ## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로) | 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 | | --- | --- | --- | --- | | 문의 일괄 접수 | `my-work/intake.json` · `POST /webhook/my-intake` | `{"requests": [{"id": "R-101", "user": "이준호", "text": "..."}]}` | HTTP 200 `{"total", "created", "failed", "rejected", "duplicates", "unclassified", "results": [{"id", "status": "created" \| "failed" \| "rejected", "ticket_id", "duplicate", "category", "priority", "reason"}]}` | | 에러 처리기 | `my-work/error-handler.json`, 파일 안에 **`"id": "errHandler76001"`**, Error Trigger로 시작 | (n8n이 부름) | `POST /alerts` `{"workflow", "workflow_id", "node", "message", "execution_id", "severity": "critical", "reason": "unexpected"}` | | 재처리 | `my-work/replay.json` · `POST /webhook/my-replay` | `{"max_attempts": 3}`(선택) | `{"replayed", "ok", "still_failed", "parked", "duplicates"}` | | 운영 상태 (Lab 03 과제 2) | `my-work/ops-status.json` · `POST /webhook/my-ops-status` | `{}` | `{"status": "ok" \| "warn" \| "critical", "dlq", "parked", "created", "alerts": {"critical", "high"}, "reasons": [...]}` | - `requests`가 목록이 아니면 HTTP 400 `{"error": "requests must be a list"}`. (Lab 01 과제 2) 10건이 넘으면 HTTP 400 `{"error": "too many requests (max 10)"}`, 같은 요청 안에 같은 `id`가 또 있으면 두 번째부터 거절 `duplicate id in this request`. - 항목 하나가 잘못돼도(**거절** `rejected`: id · user · text 없음) 나머지는 처리한다. 결과는 늘 HTTP 200 (부분 성공). - 접수 건을 만들 때 **`key` = 요청의 `id`** (멱등 키). 같은 요청을 다시 보내도 접수 건이 두 번 생기지 않는다. - 데이터 API 실패는 timeout 3초 · 재시도 3번 뒤 **실패** `failed`로 표시하고 원래 요청을 DLQ에 넣는다. - LLM 분류가 실패하면 `category: 미분류`, `priority: 보통`으로 접수한다 (멈추지 않는다). - (Lab 02 과제 1) 처리한 실패(DLQ로 간 것)가 1건 이상이면 `POST /alerts` `{"workflow": "intake", "severity": "high", "reason": "dlq", "count"}`. DLQ에 넣지 **못했으면**(`stored`가 없음) `{"severity": "critical", "reason": "dlq_down", "count", "message"(잃을 뻔한 id들)}`. 결과에 `dlq_saved`(true/false, 실패가 있을 때)와 `alert`(`sent` · `none`). - (Lab 02 과제 2) 같은 `reason`의 high 알림이 10분 안에 이미 있으면 보내지 않고 `alert: "suppressed"`. critical은 늘 보낸다. - (Lab 03 과제 2) 운영 상태: parked가 있거나 10분 안 critical 알림이 있으면 `critical`, parked가 아닌 DLQ가 있거나 10분 안 high 알림이 있으면 `warn`, 아니면 `ok`. `reasons`에 까닭을 한국어로. - 접수 워크플로우와 `nightly-report`의 설정 `settings.errorWorkflow`는 `errHandler76001`. - 재처리: DLQ에서 `parked`가 아닌 것을 `my-intake`로 하나씩 다시 보내고, 성공하면 DLQ에서 지운다. `attempts`가 `max_attempts` 이상이면 다시 보내지 않고 `parked: true` + 알림 `{"severity": "high", "reason": "parked"}`. ## 4. 워크플로우 파일 규칙 ```json { "name": "76 Intake (my)", "nodes": [ ... ], "connections": { ... }, "settings": { "executionOrder": "v1", "errorWorkflow": "errHandler76001" } } ``` - 파일 하나에 워크플로우 하나. 노드 `name`은 영어로, 겹치지 않게. 노드마다 `type`, `typeVersion`, `position`, `parameters`. - `id`를 쓰지 않으면 `push`가 파일 이름으로 정한다. **에러 처리기는 다른 워크플로우가 id로 부르므로** 파일 맨 위에 `"id": "errHandler76001"`을 쓴다 (70일차 모듈과 같음). - **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다. ## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인) ### 항목마다 확인 (Code → If) · 두 갈래 모으기 (Merge) ```json {"name": "Check items", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [440, 300], "parameters": {"jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nreturn requests.map((r, index) => ({ json: { index, valid: ..., reason: ..., id: r.id, user: r.user, text: r.text } }));"}} ``` ```json {"name": "Collect", "type": "n8n-nodes-base.merge", "typeVersion": 3, "position": [1760, 300], "parameters": {"numberInputs": 2}} ``` - 처리한 갈래 → `Collect` 입력 0번, 거절한 갈래 → 입력 1번 (`"index": 1`). 한쪽이 비어도 모은다. ### AI Agent (항목마다 분류) — 실패해도 계속 ```json {"name": "Classify", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [880, 200], "parameters": {"promptType": "define", "text": "={{ $json.text }}", "options": {"systemMessage": "... Return only JSON ...", "maxIterations": 1}}, "onError": "continueRegularOutput"} ``` - Agent는 들어온 항목마다 한 번씩 돈다. `continueRegularOutput`이면 실패한 항목은 `output` 대신 `error`를 가진 채 다음으로 간다. ### 접수 건 만들기 (HTTP) — 재시도 · 멱등 키 · 실패해도 계속 ```json {"name": "Create ticket", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 200], "parameters": {"method": "POST", "url": "http://data-api:8800/tickets", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ key: $json.id, title: $json.text, requester: $json.user, category: $json.category, priority: $json.priority }) }}", "options": {"timeout": 3000}}, "retryOnFail": true, "maxTries": 3, "waitBetweenTries": 300, "onError": "continueRegularOutput"} ``` - **노드의 재시도는 노드에 들어온 항목을 모두 다시 보낸다.** 그래서 멱등 키(`key`)가 없으면 이미 만든 접수 건이 또 생긴다. - `continueRegularOutput`: 재시도 뒤에도 실패한 항목은 `{"error": {...}}`를 가진 채 다음 노드로. 다음 Code에서 앞 노드 항목과 같은 순서로 맞춰 `created` / `failed`를 정한다. ### Error Workflow (에러 처리기) ```json {"name": "On error", "type": "n8n-nodes-base.errorTrigger", "typeVersion": 1, "position": [0, 300], "parameters": {}} ``` - 다른 워크플로우가 **처리하지 못한 오류로 멈추면** n8n이 이 워크플로우를 부른다. 받는 값: `$json.workflow.name`, `$json.workflow.id`, `$json.execution.id`, `$json.execution.lastNodeExecuted`, `$json.execution.error.message`. - 부르는 쪽 설정: `"settings": {"executionOrder": "v1", "errorWorkflow": "errHandler76001"}`. 에러 처리기도 push(게시)해야 한다. - 웹훅 응답(500)이 나간 **뒤에** 돈다 — 알림은 1~3초 늦게 생긴다. ### DLQ · 알림 (HTTP POST) ```json {"name": "Send to DLQ", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2420, 140], "parameters": {"method": "POST", "url": "http://data-api:8800/dlq", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ items: ... }) }}", "options": {"response": {"response": {"neverError": true}}}}} ``` ### 내 웹훅 다시 부르기 (재처리, 항목마다) ```json {"name": "Run intake again", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1100, 200], "parameters": {"method": "POST", "url": "http://localhost:5678/webhook/my-intake", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ requests: [$json.item] }) }}", "options": {"response": {"response": {"neverError": true}}, "timeout": 60000}}} ``` ### 그 밖 (72~75일차와 같음) - Webhook · If · Code · Respond to Webhook. 비어 있을 수 있는 결과에는 노드에 `"alwaysOutputData": true`. ## 6. 연결 (`connections`) ```json { "Valid?": {"main": [[{"node": "Classify", "type": "main", "index": 0}], [{"node": "Mark rejected", "type": "main", "index": 0}]]}, "Mark results": {"main": [[{"node": "Collect", "type": "main", "index": 0}]]}, "Mark rejected": {"main": [[{"node": "Collect", "type": "main", "index": 1}]]} } ``` ## 7. 표현식 주의 - 표현식은 `=`로 시작한다. 이번 실행 번호는 `$execution.id`, 항목 번호는 `$itemIndex`. - Code 노드의 `throw new Error('...')`는 워크플로우를 멈춘다 (Error Workflow가 받음). 처리할 수 있는 실패는 던지지 말고 결과로. ## 8. 테스트 파일 (`cases/*.json`) ```json { "webhook": "my-intake", "before": [{"url": "http://localhost:8800/tickets-reset", "body": {}}], "cases": [ {"name": "API 오류", "before": [{"url": "http://localhost:8800/admin/fault", "body": {"tickets": "error"}}], "request": "requests/batch-two.json", "expect": {"equals": {"failed": 2}}}, {"name": "DLQ 확인", "url": "http://localhost:8800/dlq/list", "body": {}, "expect": {"equals": {"size": 2}}}, {"name": "알림 확인", "before": [{"sleep": 3}], "url": "http://localhost:8800/alerts/latest", "body": {}, "expect": {"equals": {"severity": "critical"}}}, {"name": "다른 웹훅", "webhook": "my-replay", "body": {}, "expect": {"equals": {"ok": 2}}} ] } ``` - `url` 케이스는 그 주소에 **POST** 합니다. 읽기만 할 때는 `/dlq/list`, `/alerts/list`, `/alerts/latest`, `/tickets-created/list`. - `{"sleep": 3}`: 기다리기 (Error Workflow가 늦게 돌 때). - **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다. ## 9. 하지 말 것 - `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만). `nightly-report`의 버그를 고쳐서 테스트를 통과시키지 않는다 (오늘은 일부러 실패시키는 연습). - 워크플로우 안에서 고장 스위치 · `*-reset` · `*-clear`를 부르지 않는다. - 실패를 숨기지 않는다: 실패한 항목을 결과에서 빼거나 성공으로 바꾸지 않는다. - n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다. - `.env`나 API 키를 읽어서 출력하지 않는다.