Files

15 KiB

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 결과, 그리고 사람이 실행할 명령 두 줄.
    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/<path>, n8n 안에서도 http://localhost:5678/webhook/<path>
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. 워크플로우 파일 규칙

{
  "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)

{"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 } }));"}}
{"name": "Collect", "type": "n8n-nodes-base.merge", "typeVersion": 3, "position": [1760, 300], "parameters": {"numberInputs": 2}}
  • 처리한 갈래 → Collect 입력 0번, 거절한 갈래 → 입력 1번 ("index": 1). 한쪽이 비어도 모은다.

AI Agent (항목마다 분류) — 실패해도 계속

{"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) — 재시도 · 멱등 키 · 실패해도 계속

{"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 (에러 처리기)

{"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)

{"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}}}}}

내 웹훅 다시 부르기 (재처리, 항목마다)

{"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)

{
  "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)

{
  "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 키를 읽어서 출력하지 않는다.