diff --git a/Last3Mon/76/.gitignore b/Last3Mon/76/.gitignore new file mode 100644 index 0000000..be55978 --- /dev/null +++ b/Last3Mon/76/.gitignore @@ -0,0 +1,3 @@ +runs/ +tools/.cache/ +my-work/*.json diff --git a/Last3Mon/76/AGENTS.md b/Last3Mon/76/AGENTS.md new file mode 100644 index 0000000..ef95574 --- /dev/null +++ b/Last3Mon/76/AGENTS.md @@ -0,0 +1,176 @@ +# 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 키를 읽어서 출력하지 않는다. diff --git a/Last3Mon/76/CLAUDE.md b/Last3Mon/76/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/Last3Mon/76/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Last3Mon/76/LAB-01-A-FAILURE-TYPES.md b/Last3Mon/76/LAB-01-A-FAILURE-TYPES.md new file mode 100644 index 0000000..4d1b14e --- /dev/null +++ b/Last3Mon/76/LAB-01-A-FAILURE-TYPES.md @@ -0,0 +1,119 @@ +# Lab 01 · 실패를 겪어 보고, 항목마다 처리하고, 중복 없이 다시 해 보기 + +> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-01-Q-FAILURE-TYPES.md](LAB-01-Q-FAILURE-TYPES.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 2026-10-29에 1회씩 실행한 값입니다 (강사 Mac, 분류 `gpt-4.1-mini`). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md). + +## 예제 1 · 결과 (에이전트 없이, `answers/01`) + +| 한 일 | 사용자가 본 것 | 실제로 생긴 것 | +| --- | --- | --- | +| 정상 3건 | 200 · `processed: 3` (5.2초) | 접수 건 3 (T-2001~2003) | +| 빈 글이 섞인 3건 | **500** · `Error in workflow` | 접수 건 **2** (1번째 · 3번째) | +| `flaky` · 2건 | 500 | 0 (재시도 없음) | +| 정상 3건을 다시 | 200 · 새 번호 3개 | 접수 건 8 · VPN 문의가 T-2001 · T-2006 **둘** (키 `10057-0` → `10060-0`) | + +- 풀이 때: "500을 받은 사용자는 무엇을 하나?" → 다시 보냄 → 2건이 또 생김. 500은 "아무것도 안 됨"이 아닙니다. + +## 예제 2 · 요청문 예 + +```text +my-work/intake.json (baseline 을 복사한 것) 의 웹훅 경로를 my-intake 로, 이름을 "76 Intake (my)" 로 바꿔 줘. +requests 가 목록이 아니면 400 {"error": "requests must be a list"}. +항목마다 id · user · text 를 확인해서, 빠진 것이 있으면 LLM 과 API 로 보내지 말고 status rejected 와 이유("text is required" 등)로. +Create ticket 노드에 timeout 3000ms, 재시도 3 번, 간격 300ms, 그리고 실패해도 다음으로 가게 onError continueRegularOutput . +접수 건의 key 는 요청의 id (실행 번호가 아님). +처리한 갈래와 거절한 갈래를 Merge 로 모아 AGENTS.md 3절 결과 모양으로, HTTP 200 . +기대 결과 cases/lab01.json. +``` + +**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`) + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `3` | 조건 표 "3번" (`maxTries`는 첫 시도를 포함한 수) | +| ② | `continueRegularOutput` | 실패한 항목이 `error`를 가진 채 다음 Code로 (AGENTS.md 5절) | +| ③ | 요청의 `id` | 같은 문의를 다시 보내도 바뀌지 않는 것. `$execution.id`는 실행마다 바뀜 (예제 1의 `10057` → `10060`) | +| ④ | `200` | 부분 성공. 일부가 실패해도 항목마다 상태로 알림 | + +정답: [reference/lab01-example2-intake.json](reference/lab01-example2-intake.json) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/02`, 기준에 같은 테스트) | **0/6** · 결과 모양이 달라 `null`, 접수 건 6 (중복), 빈 글 · 목록 아님 · `flaky` 모두 500 | +| 고친 뒤 (`answers/03`) | **6/6** · 다시 보낸 3건 `duplicates: 3`, `flaky` 2건 재시도로 성공 (2.4초), 빈 글만 `rejected` (1.7초) | + +## 예제 3 · 결과 (에이전트 없이, `answers/04`) + +| 고장 | 시간 | 결과 | 이유 | +| --- | --- | --- | --- | +| `flaky` | 2.4초 | `created: 2` | 첫 시도 500 → 두 번째 성공 | +| `error` | 3.4초 | `failed: 3` | `500 - tickets service is failing (fault switch)` 세 번 | +| `slow` | **12.4초** | `failed: 3` | `timeout of 3000ms exceeded` 세 번 | +| 8초 뒤 접수 건 | | **5** (2 + 3) | 실패라던 R-101~103이 늦게 만들어짐. 시도 9번 · 키가 같아 3건 | +| 다시 보냄 | 2.9초 | `duplicates: 3` | 키가 이미 있음 | + +- 이 예제의 답 두 가지: ① 재시도는 **잠깐의 실패에만** 약이고, 멈춤 · 느림에는 시간만 늘린다 (`tretry --deadline`으로 이어짐). ② 시간 초과는 **"모름"**이다 — `failed`라고 말한 것이 만들어져 있다. 그래서 시간 초과 뒤 재시도 · 재처리에는 멱등 키가 꼭 있어야 한다 (Lab 03 예제 3). + +## 과제 1 · 요청문 예 + +```text +my-work/intake.json 에서 Classify(AI Agent) 노드가 실패해도 워크플로우가 멈추지 않게 onError continueRegularOutput 로 해 줘. +Parse 에서 그 항목에 error 가 있거나 output 이 JSON 이 아니면 category "미분류", priority "보통" 으로. +결과의 unclassified 에 미분류로 접수한 수를 세어 줘. 나머지는 그대로. +기대 결과 cases/lab01-challenge.json, cases/lab01.json 도 그대로 통과. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `continueRegularOutput` | `Create ticket`과 같은 값. 실패한 항목이 다음으로 | +| ② | `error` | 실패한 항목은 `output` 대신 `error`를 가짐 | +| ③ | `미분류` | 팀장의 말 | +| ④ | `unclassified` | AGENTS.md 3절 결과 모양 | + +정답: [answers/76-lab01-challenge-fallback.json](answers/76-lab01-challenge-fallback.json) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/05`) | **1/3** · `llm down`에서 0.8초에 500, 접수 0 | +| 고친 뒤 (`answers/06`) | **3/3** · `created: 3, unclassified: 3` (0.9초), `lab01` 6/6 | + +- 풀이 때: 72일차 "간단 답변"과 같은 생각(덧붙인 일은 줄여서라도 핵심 일을 끝냄)이고, `unclassified` 수가 "얼마나 줄였는지를 숨기지 않기"라는 점을 짚습니다. + +## 과제 2 · 요청문 예 + +```text +my-work/intake.json 에서 requests 가 목록이어도 10 건이 넘으면 HTTP 400 {"error": "too many requests (max 10)"} 로, 하나도 처리하지 말아 줘. +목록이 아닐 때의 400 글은 그대로 "requests must be a list". +Check items 에서 같은 요청 안에 같은 id 가 또 나오면 두 번째부터 rejected, 이유 "duplicate id in this request". +앞에서 본 id 는 Set 에 기억해 둔다. 나머지 규칙(빠진 칸 거절 · 재시도 · 키 · 미분류)은 그대로. +기대 결과 cases/lab01-challenge2.json, cases/lab01.json · lab01-challenge.json 도 통과. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `10` | "10건까지만" — 10은 받고 11부터 400 | +| ② | `두` | 처음 나온 것은 받음 | +| ③ | `duplicate id in this request` | 조건 표 그대로 (테스트가 `duplicate id`를 찾음) | +| ④ | `Set` | 본 id를 빨리 찾는 모음 (배열 `includes`도 되지만 Set이 알맞음) | + +정답: [answers/76-lab01-challenge2-rules.json](answers/76-lab01-challenge2-rules.json) (`Is a list?`의 조건에 `length <= 10`, `Reject body`의 글을 두 경우로) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/07`) | **1/4** · 같은 id: `created: 3, duplicates: 1` (분류 세 번), 11건: **11.0초**에 200 · 접수 건 13 | +| 고친 뒤 (`answers/08`) | **4/4** · 같은 id 두 번째만 `rejected`, 11건 **0.2초**에 400 · 접수 건 2 그대로, `lab01` 6/6 · `lab01-challenge` 3/3 | + +## 2교시에 볼 것 + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| 재시도 설정을 `parameters` 안에 씀 | "`check`가 알려 준 대로 노드 바로 아래로" (`flow.py check`가 오류로 잡음) | +| 거절한 항목이 결과에 없음 | "Merge 두 번째 입력(`index: 1`)" | +| `duplicates`가 0 | "키가 실행 번호인지 요청 id인지" | +| `failed`와 `created`가 엉뚱한 항목에 | "Mark results가 `Parse`의 `.all()`과 같은 순서인지" | +| `slow` 케이스를 오래 기다림 | "정상. 12초가 바로 이 예제의 숫자" | +| 같은 id를 멱등 키로 충분하다고 함 | "결과가 세 건이라고 말하고 분류를 세 번 했다" | diff --git a/Last3Mon/76/LAB-01-Q-FAILURE-TYPES.md b/Last3Mon/76/LAB-01-Q-FAILURE-TYPES.md new file mode 100644 index 0000000..ffb218d --- /dev/null +++ b/Last3Mon/76/LAB-01-Q-FAILURE-TYPES.md @@ -0,0 +1,690 @@ +# Lab 01 · 실패를 겪어 보고, 항목마다 처리하고, 중복 없이 다시 해 보기 + +- 관련 강의: [1단계 · 실패의 종류](../../Lecture/76/01-STAGE1-FAILURE-TYPES.md) +- 소요 시간: 50분 (2교시 한 시간) · 예제 1 10분 · 예제 2 15분 · 예제 3 7분 · 과제 1 8분 · 과제 2 10분 +- 준비: [실습 준비](../../Lecture/76/00-DAY76-PREP.md)의 `doctor` → `ready`, `base-intake` · `nightly-report` 게시 +- 목표: 여러 건을 한 번에 처리할 때 **한 건의 실패가 전체를 망치지 않게** 한다. 잘못된 항목은 그 항목만 거절하고, 잠깐의 실패는 다시 해 보고, 다시 해도 **접수 건이 두 번 생기지 않게** 한다. 고장 종류마다 재시도가 무엇을 하는지 재 보고, LLM이 멈춰도 접수는 하고, 요청 안의 규칙(같은 id · 10건 상한)을 지킨다. + +```mermaid +flowchart LR + E1["예제 1
기준을 망가뜨려 보기"] --> E2["예제 2
항목 확인 · 재시도 ·
멱등 키"] --> E3["예제 3
고장 종류마다
재시도가 하는 일"] --> T1["과제 1
LLM이 멈춰도 접수"] --> T2["과제 2
요청 안의 규칙"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py ops --reset`. + +## 시작하기 · 터미널 두 개 + +| 터미널 | 하는 일 | +| --- | --- | +| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/76` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 | +| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/76` 에서 `call` · `push` · `test` · `ops` · `fault`를 **내가** 실행한다 | + +`cp templates/RUNBOOK-TEMPLATE.md my-work/RUNBOOK.md` 후 표를 채워 갑니다. 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다. + +## 예제 1 · 기준을 망가뜨려 보기 (10분, 에이전트 없이) + +### 이 실습의 의도 + +**한 줄로:** 오늘 고칠 기준 워크플로우(`base-intake`)가 실패할 때 **무엇을 숨기는지** 직접 봅니다. 고치는 것은 예제 2부터이고, 여기서는 **망가뜨려 보고 적기만** 합니다. + +> **생활 속 예 · 마트 계산대.** 계산원이 "결제 실패"라고만 했는데 영수증을 보니 앞의 물건은 이미 결제돼 있다면, 다시 계산하면 두 번 냅니다. 예제 1은 일부러 이런 상황을 만들어, **들은 말**(워크플로우의 답, `call`)과 **실제로 일어난 일**(영수증, `ops`)을 나란히 보는 실습입니다. + +**상황.** `base-intake`는 문의 여러 건(`requests[]`)을 한 번에 받아, 한 건씩 LLM으로 분류하고 데이터 API에 접수 건을 만듭니다. 정상 요청이면 잘 됩니다. 그런데 운영하다 보면 이런 일이 생깁니다. + +- 보낸 문의 중 하나가 잘못됨 (글이 비어 있음) +- 데이터 API가 잠깐 끊김 +- 사용자가 답을 못 받았다며 같은 요청을 다시 보냄 + +이 셋을 하나씩 일부러 일으켜, **사용자가 받은 답**과 **데이터 API에 실제로 생긴 접수 건**을 나란히 봅니다. 둘이 다르면 그것이 오늘 고칠 문제입니다. + +**실험 셋.** + +| 실험 | 일으키는 것 | 보낼 요청 | 볼 것 | +| --- | --- | --- | --- | +| ① 잘못된 항목 | 3건 중 가운데 글이 빔 | `batch-bad-item` (R-201 · 빈 R-202 · R-203) | 답은 성공인가 실패인가? 그런데 `ops`에 접수 건이 생겼나, 몇 개? | +| ② 잠깐의 끊김 | `fault tickets flaky`: 접수 건 만들기가 키마다 첫 시도만 실패 | `batch-two` (R-301 · R-302) | 한 번만 더 했으면 됐을 실패를 기준은 어떻게 끝내나? | +| ③ 다시 보내기 | 고장을 끄고 정상 3건을 한 번 더 | `batch-ok` (R-101~103) 두 번째 | 같은 문의의 접수 건이 몇 개가 되나? `ops`의 `key` 칸이 첫 번째와 같나? | + +`call`은 웹훅을 한 번 불러 **워크플로우의 답**을 보여 주고, `ops`는 데이터 API에 지금 있는 접수 건 · DLQ · 알림 · 고장 스위치를 보여 줍니다. `ops`는 답이 아니라 **실제로 생긴 것**을 보는 창입니다. 두 화면을 번갈아 봅니다. + +**끝나면 알게 되는 것.** 기준은 ① 한 건 때문에 전체를 실패라고 하면서 다른 건은 이미 만들었고, ② 잠깐의 끊김도 바로 실패로 끝내고, ③ 같은 문의를 다시 보내면 또 만듭니다. 예제 2에서 이 셋을 **항목 확인 · 재시도 · 멱등 키**로 하나씩 고칩니다. + +**적을 곳.** `my-work/RUNBOOK.md` 1.1 표 (한 일 · 사용자가 본 것 · 실제로 생긴 것), 실험마다 한 줄. + +```bash +uv run tools/flow.py ops --reset # 접수 건 · DLQ · 알림 비우기, 고장 스위치 모두 ok + +# 0. 정상일 때 (비교 기준) +uv run tools/flow.py call base-intake requests/batch-ok.json # 정상 3건 + +# ① 잘못된 항목 +uv run tools/flow.py call base-intake requests/batch-bad-item.json # 가운데 항목의 text가 비어 있음 +uv run tools/flow.py ops # 답은 실패였는데, 접수 건은 몇 개? + +# ② 잠깐의 끊김 +uv run tools/flow.py fault tickets flaky # 접수 건 만들기가 키마다 첫 시도에 실패 +uv run tools/flow.py call base-intake requests/batch-two.json +uv run tools/flow.py fault tickets ok # 고장 끄기 + +# ③ 다시 보내기 +uv run tools/flow.py call base-intake requests/batch-ok.json # 0번과 같은 요청을 다시 +uv run tools/flow.py ops # VPN 문의(R-101)의 접수 건은 몇 개? key는? +``` + +### 이해하기 + +**여러 건을 한 번에 처리하는 일(일괄 처리, batch)에는 "반쯤 된" 상태가 있습니다.** 한 건만 처리하면 결과는 됐다 · 안 됐다 둘 중 하나입니다. 세 건을 처리하다 두 번째에서 멈추면 첫 번째는 이미 끝났고, 세 번째는 했는지 안 했는지 모릅니다. 그런데 사용자에게는 `500` 하나만 갑니다. + +```mermaid +flowchart LR + Q(["requests 3건"]) --> S["하나씩 나누기"] --> C{{"LLM 분류"}} --> T["접수 건 만들기
key = 실행 번호"] + T -->|1번째 · 3번째| OK["만들어짐"] + T -->|2번째: 빈 글 → 400| X["노드 오류 → 전체 500"] +``` + +**오늘 기준이 가진 문제 셋.** + +| 문제 | 겉으로 | 실제로 | +| --- | --- | --- | +| 한 건의 실패가 전체를 멈춤 | HTTP 500 · `Error in workflow` | 다른 건은 이미 접수됨 (부분 처리) | +| 잠깐의 실패도 바로 실패 | 500 | 1초 뒤에 다시 했으면 됐을 것 | +| 다시 보내면 또 만듦 | 200 · 접수 건 번호 3개 | 같은 문의의 접수 건이 둘 (중복) | + +**왜 중복이 생기나.** 기준은 접수 건을 만들 때 키를 `$execution.id + '-' + $itemIndex`(이번 실행 번호와 순서)로 정합니다. 같은 문의를 다시 보내면 실행 번호가 다르니 데이터 API는 다른 요청으로 봅니다. "같은 문의"를 알아보게 하려면 **실행마다 바뀌지 않는 것**(요청의 `id`)이 키여야 합니다 (예제 2). + +**고장 스위치.** 오늘 데이터 API에는 접수 건 만들기(`tickets`)와 DLQ(`dlq`)에 고장 스위치가 있습니다. `flaky`는 키마다 첫 시도만 실패시킵니다 (잠깐의 끊김), `error`는 늘 500, `slow`는 8초를 기다린 뒤 답합니다. 연습이 끝나면 `ops --reset`이 접수 건 · DLQ · 알림을 비우고 스위치를 모두 `ok`로 돌립니다. + +**기대 결과.** 빈 글 하나가 섞인 3건 → 500인데 접수 건 2개가 생깁니다. `flaky`에서는 500. 같은 정상 3건을 두 번 보내면 VPN 문의의 접수 건이 두 개가 됩니다. `RUNBOOK.md` 1절에 "사용자가 본 것 / 실제로 생긴 것"을 나란히 적습니다. + +
▶ 1단계 · 실패를 나누기 + +| 실패 | 다시 하면 되나 | +| --- | --- | +| 입력이 잘못됨 (빈 글) | 아니오 — 몇 번을 해도 같음 | +| API가 잠깐 실패 | 예 | +| API가 오래 멈춤 | 지금은 아니오, 나중에는 예 | + +이 셋을 똑같이 "500"으로 끝내면 무엇을 잃을까요? 사용자는 무엇을 할까요? +
+ +
▶ 2단계 · 중복은 왜 생기나 + +`ops`의 `key` 칸을 봅니다. 두 번째 `batch-ok`의 키는 첫 번째와 같나요? 같은 요청을 알아보게 하려면 무엇을 키로 써야 할까요? +
+ +### 실행 결과 · 기준을 망가뜨려 보기 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다 (줄임). LLM 분류 · 시간 · 접수 건 번호의 순서는 실행마다 조금씩 다를 수 있고, 500 · 접수 건 수는 같아야 합니다. + +```text +$ call base-intake requests/batch-ok.json +HTTP 200 · 5.2s {"processed": 3, "tickets": ["T-2001", "T-2002", "T-2003"]} + +$ call base-intake requests/batch-bad-item.json +HTTP 500 · 3.2s {"message": "Error in workflow"} + +$ ops +created tickets: 5 + T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요 + T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요 + +$ fault tickets flaky → call base-intake requests/batch-two.json +HTTP 500 · 2.5s {"message": "Error in workflow"} + +$ fault tickets ok → call base-intake requests/batch-ok.json +HTTP 200 · 3.0s {"processed": 3, "tickets": ["T-2006", "T-2008", "T-2007"]} + +$ ops +created tickets: 8 + T-2006 key 10060-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 +``` + +**왜 이렇게 나왔나** + +- 빈 글이 섞인 요청은 **500인데 접수 건이 2개(T-2004 · T-2005) 생겼습니다.** 접수 건 만들기 노드가 1번째 · 3번째는 만들고, 2번째에서 데이터 API가 `title`이 비었다고 400을 주자 노드가 오류로 멈췄기 때문입니다. 사용자는 "실패"로 알고 다시 보낼 것이고, 그러면 두 건이 또 생깁니다. +- `flaky`에서 500: 첫 시도만 실패하는 **잠깐의 끊김**인데도 기준에는 재시도가 없어 바로 멈췄습니다. +- 같은 정상 3건을 다시 보내자 **새 번호 3개(T-2006~2008)** 가 생겼습니다. VPN 문의 접수 건이 T-2001 · T-2006 둘입니다. 키가 `10057-0` → `10060-0`처럼 실행 번호라서, 데이터 API는 같은 문의인 줄 모릅니다. + +## 예제 2 · 항목 확인 · 재시도 · 멱등 키 (15분) + +### 이 실습의 의도 + +**한 줄로:** 예제 1에서 본 세 문제를 내 워크플로우(`my-intake`)에서 고칩니다. Lab 01의 중심 실습입니다. + +> **생활 속 예 · 택배 배송 조회 + 송장 번호.** 좋은 택배 회사는 ① 상자마다 "배달 완료 / 주소 불명 반송"을 따로 보여 주고(부분 성공), ② 벨을 눌러 대답이 없으면 한 번 더 눌러 보고(재시도), ③ 같은 송장 번호의 상자는 두 번 배달하지 않습니다(멱등 키). 예제 2는 `my-intake`를 이런 택배 회사로 만드는 실습입니다. + +| 예제 1의 문제 | 고치는 방법 | 확인하는 테스트 케이스 | +| --- | --- | --- | +| ① 한 건이 잘못되면 전체 500, 다른 건은 이미 접수 | 항목마다 확인해 잘못된 것만 `rejected`, 결과는 늘 200에 항목마다 상태 (부분 성공) | 잘못된 항목 · 목록이 아님 | +| ② 잠깐의 끊김도 바로 실패 | 접수 건 만들기 노드에 재시도 3번, 그래도 실패면 `failed`로 적고 계속 | 가끔 실패 (`flaky`) | +| ③ 다시 보내면 또 만듦 | 접수 건의 `key`를 실행 번호 대신 요청 `id`(R-101)로 | 같은 요청을 다시 · 접수 건 수 | + +**왜 셋을 한 번에.** 셋은 서로 기대고 있습니다. 재시도(②)를 넣으면 n8n은 노드에 들어온 항목을 **모두** 다시 보냅니다. 이미 만든 접수 건도 또 보내므로, 멱등 키(③) 없이는 재시도가 중복을 만듭니다. 그리고 재시도 뒤에도 실패한 항목을 `failed`로 남기려면 항목마다 상태를 담는 결과 모양(①)이 먼저 있어야 합니다. + +**순서.** 기준을 복사 → 같은 테스트를 **기준에 먼저** 돌려 0/6을 봄 (무엇이 틀렸는지 케이스마다 확인) → 요청문을 써서 에이전트에게 고치게 함 → push → 6/6. + +**끝나면 알게 되는 것.** "일부가 실패해도 200 + 항목마다 상태"가 왜 500보다 정직한지, n8n 재시도가 노드의 어디에 붙고 무엇을 다시 보내는지, 멱등 키가 재시도를 어떻게 안전하게 만드는지. + +**만들 것**: 기준을 복사해, 항목마다 확인하고, 접수 건 만들기를 다시 해 보게 하고, 요청 `id`를 멱등 키로. 결과는 늘 200 (부분 성공). + +```bash +cp reference/baseline-intake.json my-work/intake.json +uv run tools/flow.py test cases/lab01.json --webhook base-intake -v # 고치기 전 (기준에 같은 테스트) +``` + +| 조건 | 값 | +| --- | --- | +| 파일 · 웹훅 | `my-work/intake.json` · `my-intake` (경로 · 이름을 바꿈) | +| 거절 | id · user · text가 없는 항목 → `rejected`와 이유, 나머지는 처리 | +| 재시도 | timeout 3초, 3번, 0.3초 간격 · 그래도 실패면 `failed` (멈추지 않음) | +| 결과 · 400 | [AGENTS.md](AGENTS.md) 3절 (`requests`가 목록이 아니면 400) | +| 기대 결과 | `cases/lab01.json` | + +### 이해하기 + +**지금 무엇이 문제인가.** 예제 1의 세 문제(한 건이 전체를 멈춤 · 잠깐의 실패도 실패 · 다시 보내면 중복)를 한 번에 고칩니다. 셋은 따로 보이지만 서로 이어져 있습니다. 재시도를 넣으면 **이미 만든 건을 또 보내게** 되므로, 재시도와 멱등 키는 늘 함께 갑니다. + +```mermaid +flowchart LR + subgraph 지금 + A1(["3건"]) --> B1["하나씩"] --> C1{{"분류"}} --> D1["접수
key = 실행 번호
재시도 없음"] -->|하나라도 실패| X1(["500"]) + end + subgraph 바꾼 뒤 + A2(["3건"]) --> V2{"항목 확인"} + V2 -->|맞음| C2{{"분류"}} --> D2["접수
key = 요청 id
재시도 3 · 계속"] --> M2["모으기"] + V2 -->|빠짐| R2["거절 · 이유"] --> M2 + M2 --> O2(["200 · 항목마다 상태"]) + end +``` + +**부분 성공.** 결과는 늘 HTTP 200이고, 항목마다 `created` · `failed` · `rejected`와 이유를 담습니다. "모두 되거나 500" 대신 "무엇이 되고 무엇이 안 됐는지"를 알려 줍니다. 받는 쪽은 `rejected`는 고쳐서, `failed`는 나중에 다시 보내면 됩니다. + +**n8n의 재시도는 노드 단위입니다.** `retryOnFail` · `maxTries` · `waitBetweenTries`는 노드 **바로 아래**(`parameters` 밖)에 둡니다. 그리고 그 노드에 들어온 **항목을 모두** 다시 보냅니다. 세 건 중 하나만 실패해도 세 건을 다시 보냅니다. 그래서 키가 필요합니다. + +**멱등 키(idempotency key).** 같은 요청을 몇 번 보내도 결과가 한 번 한 것과 같게 하는 표시입니다. 오늘 데이터 API는 `POST /tickets`의 `key`가 같으면 새로 만들지 않고 처음 번호를 돌려줍니다 (`duplicate: true`). + +| 키 | 같은 요청을 다시 보내면 | +| --- | --- | +| 실행 번호 (`$execution.id`) | 매번 새 키 → 중복 | +| **요청 `id`** (`R-101`) | "이미 만든 건" → 같은 번호, `duplicate: true` | + +**실패해도 다음으로.** 재시도 뒤에도 실패한 항목에서 멈추면 안 됩니다. `onError: continueRegularOutput`이면 실패한 항목이 `error`를 가진 채 다음 Code로 갑니다. 다음 Code는 앞 노드(`Parse`)의 항목과 **같은 순서**로 맞춰 `created` / `failed`를 정합니다. + +**테스트 케이스.** + +| 케이스 | 보내는 것 | 기대 | +| --- | --- | --- | +| 정상 3건 | `batch-ok` | `created: 3` | +| 같은 요청을 다시 | `batch-ok` 또 | `created: 3, duplicates: 3` | +| 접수 건 수 | `/tickets-created/list` | `count: 3` (중복 없음) | +| 잘못된 항목 | `batch-bad-item` | `created: 2, rejected: 1`, 이유 `text is required` | +| 목록이 아님 | `not-a-list` | HTTP 400 | +| 가끔 실패 | `flaky` + `batch-two` | `created: 2, failed: 0` (재시도로 성공) | + +**기대 결과.** 고치기 전(기준에 같은 테스트) 0/6, 고친 뒤 6/6. + +
▶ 1단계 · 거절한 항목은 어디로 + +거절한 항목은 LLM · API로 보내지 않습니다. 하지만 결과에는 있어야 합니다. 두 갈래(처리 · 거절)를 다시 하나로 모으는 노드가 [AGENTS.md](AGENTS.md) 5절에 있습니다. 두 번째 입력은 `index: 1`. +
+ +
▶ 2단계 · 재시도와 중복 + +n8n 노드의 재시도는 그 노드에 들어온 **항목을 모두** 다시 보냅니다. 이미 성공한 항목도요. 멱등 키가 있으면 데이터 API가 "이미 만든 건"이라며 같은 번호를 돌려줍니다 (`duplicate: true`). +
+ +
▶ 3단계 · 실패한 항목도 다음으로 + +재시도 뒤에도 실패한 항목에서 워크플로우가 멈추면 안 됩니다. 실패한 항목이 `error`를 가진 채 다음 노드로 가게 하는 설정과, 다음 Code에서 앞 노드 항목과 순서를 맞추는 방법을 요청문에 적습니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 항목 확인과 거절 이유 ③ 재시도 설정과 실패해도 계속 ④ 멱등 키 ⑤ 두 갈래 모으기 ⑥ 결과 모양 · 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다. + +```text +my-work/intake.json (baseline 을 복사한 것) 의 웹훅 경로를 my-intake 로, 이름을 "76 Intake (my)" 로 바꿔 줘. +requests 가 목록이 아니면 400 {"error": "requests must be a list"}. +항목마다 id · user · text 를 확인해서, 빠진 것이 있으면 LLM 과 API 로 보내지 말고 status rejected 와 이유("text is required" 등)로. +Create ticket 노드에 timeout 3000ms, 재시도 ____① 번, 간격 300ms, 그리고 실패해도 다음으로 가게 onError ____② . +접수 건의 key 는 ____③ (실행 번호가 아님). +처리한 갈래와 거절한 갈래를 Merge 로 모아 AGENTS.md 3절 결과 모양으로, HTTP ____④ . +기대 결과 cases/lab01.json. +``` + +빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**): + +- ① 위 조건 표의 "재시도"입니다. 처음 한 번을 포함한 수입니다. +- ② AGENTS.md 5절 접수 건 만들기 노드의 설정입니다. 멈추지 않고 실패한 항목이 다음으로 가는 값. +- ③ 같은 문의를 다시 보내도 **바뀌지 않는** 것입니다. 예제 1의 `ops`에서 키가 왜 바뀌었는지 떠올립니다. +- ④ 부분 성공의 상태 코드입니다. 일부가 실패해도 이것. + +채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다. +
+ +```bash +uv run tools/flow.py push my-work/intake.json +uv run tools/flow.py test cases/lab01.json -v +uv run tools/flow.py ops +``` + +### 실행 결과 · 항목 확인 · 재시도 · 멱등 키 + +수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (`test cases/lab01.json --webhook base-intake`) + +```text +✘ 정상 3건 · 2.7s · created = null (expected 3); failed = null (expected 0); rejected = null (expected 0) +✘ 같은 요청을 다시 보내도 중복 없음 · 3.1s · created = null (expected 3); duplicates = null (expected 3) +✘ 만들어진 접수 건은 3개 · 0.0s · count = 6 (expected 3) +✘ 잘못된 항목은 그 항목만 거절 · 3.4s · status 500 (expected 200); ... +✘ 목록이 아니면 400 · 0.1s · status 500 (expected 400) +✘ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.0s · status 500 (expected 200); ... +Passed 0/6 +``` + +고친 뒤 (`test cases/lab01.json`) + +```text +✔ 정상 3건 · 3.9s +✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s +✔ 만들어진 접수 건은 3개 · 0.0s +✔ 잘못된 항목은 그 항목만 거절 · 1.7s +✔ 목록이 아니면 400 · 0.0s +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s +Passed 6/6 +``` + +**왜 이렇게 나왔나** + +- 고치기 전에 `created = null`인 것은 기준의 결과가 `{processed, tickets}` 모양이라 오늘 계약의 칸이 없기 때문입니다. 접수 건이 **6개**(3이 기대)인 것은 같은 3건을 두 번 보내 중복이 생겼기 때문입니다. 목록이 아닌 본문도 확인 없이 하나씩 나누다 500이 되었습니다. +- 고친 뒤 두 번째 `batch-ok`는 `created: 3, duplicates: 3`입니다. 키가 `R-101`처럼 요청 `id`라서 데이터 API가 처음 번호를 돌려줬습니다. 접수 건은 3개 그대로. +- `flaky`에서 2건 모두 `created`: 첫 시도가 500이었지만 0.3초 뒤 두 번째 시도에서 성공했습니다. 재시도는 두 건을 **모두** 다시 보냈지만, 키 덕분에 중복이 생기지 않았습니다. +- 잘못된 항목 케이스가 1.7초로 빠른 것은 빈 글 하나를 LLM에 보내지 않고 거절했기 때문입니다. + +## 예제 3 · 고장 종류마다 재시도가 하는 일 (7분, 에이전트 없이) + +### 이 실습의 의도 + +**한 줄로:** 예제 2에서 넣은 재시도가 **어떤 고장에는 도움이 되고, 어떤 고장에는 시간만 늘리는지** 재 봅니다. 워크플로우는 고치지 않고(에이전트 없이), 데이터 API의 고장 종류만 바꿔 가며 같은 `my-intake`를 부릅니다. + +> **생활 속 예 · 전화 다시 걸기.** 통화 중이면 다시 걸면 됩니다(`flaky`). 상대 전화국이 고장이면 열 번 걸어도 시간만 갑니다(`error`). 상대가 받았는데 말소리가 늦게 들려 내가 먼저 끊었다면, 상대는 사실 내 말을 다 들었을 수 있습니다(`slow`). 예제 3은 세 경우에 "다시 걸기"가 각각 무엇을 하는지 재 봅니다. + +**왜 하나.** 재시도를 넣으면 "이제 실패는 해결"이라고 생각하기 쉽습니다. 하지만 고장은 종류가 다릅니다. + +| 고장 스위치 | 현실에서는 | 물어볼 것 | +| --- | --- | --- | +| `flaky` | 네트워크가 잠깐 끊김 · 서버가 한 번 버벅임 | 재시도가 구해 주나? | +| `error` | 데이터 API가 죽음 (몇 분 · 몇 시간) | 세 번 다시 하면 달라지나? 무엇이 늘어나나? | +| `slow` | 데이터 API가 느림 (8초) — 우리는 3초만 기다림 | 우리가 "실패"라고 포기한 요청은 정말 실패했나? | + +`cases/lab01-faults.json`이 이 셋을 차례로 켜고, 마지막에 8초를 기다린 뒤 접수 건 목록을 봅니다. **`slow`에서 포기한 3건이 나중에 생겼는지**가 이 예제의 핵심 관찰입니다. + +**끝나면 알게 되는 것.** 재시도는 잠깐의 실패에만 약이고, 오래 멈춘 고장에는 다른 그물(Lab 02의 DLQ)이 필요하며, 시간 초과는 "실패"가 아니라 "모름"이라서 멱등 키가 꼭 있어야 한다는 것. + +**적을 곳.** `my-work/RUNBOOK.md` 1.2 표 (고장 · 걸린 시간 · 결과 · 실제로 생긴 접수 건). + +```bash +uv run tools/flow.py test cases/lab01-faults.json -v +uv run tools/flow.py ops +``` + +### 이해하기 + +**재시도는 잠깐의 실패에만 약입니다.** + +| 고장 | 무엇 | 재시도를 하면 | +| --- | --- | --- | +| `flaky` (잠깐 끊김) | 키마다 첫 시도만 500 | 두 번째에 성공 → **좋아짐** | +| `error` (멈춤) | 늘 500 | 세 번 다 실패 → 결과는 같고 **시간만 더** | +| `slow` (느림) | 8초 뒤에 답 | 3초마다 끊고 다시 → 세 번 × 3초 + 간격, 그래도 실패 | + +```mermaid +sequenceDiagram + participant W as my-intake + participant A as 데이터 API (slow) + W->>A: 1번째 (key R-101) + Note over W: 3초 timeout → 포기 + W->>A: 2번째 (같은 key) + Note over W: 3초 timeout → 포기 + W->>A: 3번째 (같은 key) + Note over W: 3초 timeout → failed + A-->>A: 8초 뒤: 접수 건 R-101 만들기 (늦게) +``` + +**시간 초과(timeout)는 "실패"가 아니라 "모름"입니다.** 3초 안에 답이 오지 않아 포기했지만, 데이터 API는 요청을 받았고 8초 뒤에 **접수 건을 만들었습니다.** 워크플로우는 `failed`라고 말하지만 실제로는 만들어져 있습니다. 이때 같은 키로 다시 보내면 `duplicate: true`로 처음 번호를 받습니다. 멱등 키가 없었다면 두 번째 접수 건이 생깁니다. 이것이 **"시간 초과 뒤 재시도"에 멱등 키가 꼭 필요한** 까닭입니다 (Lab 03 예제 3에서 재처리로 다시 봄). + +**재시도는 사용자를 기다리게 합니다.** `slow`에서 사용자는 3초 × 3번 + 간격 + LLM 분류 = 12초쯤 기다리고 `failed`를 받습니다. "재시도를 늘리면 더 잘 된다"가 아니라, **기다릴 수 있는 시간 안에서** 몇 번 할지 정합니다 (바이브 코딩 `tretry`의 마감 `--deadline`). + +**테스트 케이스.** 이 파일은 내 워크플로우를 바꾸지 않고 **고장만 바꿔 가며** 잽니다. + +| 케이스 | 고장 | 기대 | +| --- | --- | --- | +| flaky | `tickets flaky` · `batch-two` | `created: 2` | +| error | `tickets error` · `batch-ok` | `failed: 3` | +| slow | `tickets slow` · `batch-ok` | `failed: 3` (오래 걸림) | +| 늦게 만들어짐 | 8초 기다린 뒤 `/tickets-created/list` | `count: 5` (flaky 2 + 실패라던 3) | +| 다시 보냄 | `ok` · `batch-ok` | `created: 3, duplicates: 3` | + +**기대 결과.** 5/5. 시간은 flaky < error < slow이고, slow는 10초가 넘습니다. 실패라던 3건이 접수 건 목록에 있습니다. + +
▶ 1단계 · error에서 3번은 좋은가 + +데이터 API가 한 시간 멈췄다면, 요청마다 세 번씩 보내는 것은 무엇을 늘릴까요? 그 사이 다른 워크플로우도 똑같이 세 번씩 보낸다면? (강의 1단계 "재시도 폭풍") +
+ +
▶ 2단계 · failed인데 만들어진 건 + +결과의 `failed` 3건과 `ops`의 접수 건 목록을 나란히 봅니다. 사용자에게 "실패"라고 말한 것이 사실은 됐다면, 사용자는 무엇을 할까요? 그때 무엇이 중복을 막나요? +
+ +### 실행 결과 · 고장 종류마다 + +수업 준비 때(10/29) 강사 노트북에서 예제 2 정답으로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +`test cases/lab01-faults.json` (결과 줄과 이유만) + +```text +✔ flaky · 다시 해 보면 됨 · 2.4s +✔ error · 세 번 해도 실패 · 3.4s + "reason": "500 - \"{\"error\": \"tickets service is failing (fault switch)\"}\"" +✔ slow · 3초씩 세 번 기다리다 실패 · 12.4s + "reason": "timeout of 3000ms exceeded" +… before: wait 8s +✔ 실패라던 3건이 늦게 만들어짐 (2 + 3) · 0.0s +✔ 같은 요청을 다시 · 멱등 키로 중복 없음 · 2.9s +Passed 5/5 + +$ ops +created tickets: 5 + T-2001 key R-301 · T-2002 key R-302 · T-2003 key R-103 · T-2004 key R-102 · T-2005 key R-101 +``` + +**왜 이렇게 나왔나** + +- `flaky` 2.4초: 첫 시도 500 → 0.3초 뒤 두 번째 성공. 재시도가 **도움이 된** 경우입니다. +- `error` 3.4초: 세 번 모두 500이라 `failed: 3`. 재시도가 결과를 바꾸지 못하고 시간만 더했습니다 (분류 약 2.5초 + 시도 셋과 간격 둘). +- `slow` 12.4초: 시도마다 3초에 끊겨(`timeout of 3000ms exceeded`) 세 번 실패했습니다. 사용자는 12초를 기다리고 실패를 받았습니다. +- 그런데 8초 뒤 접수 건이 **5개**입니다. 실패라던 R-101 · R-102 · R-103이 늦게 만들어졌습니다 (데이터 API가 8초 뒤 처리). 시도는 9번 갔지만 키가 같아 3건만 생겼습니다. +- 마지막에 같은 3건을 다시 보내자 `duplicates: 3`: 이미 있는 것을 알아보고 새로 만들지 않았습니다. + +## 과제 1 · LLM이 멈춰도 접수는 (8분) + +### 이 실습의 의도 + +**한 줄로:** LLM(OpenAI)이 멈춰도 **문의 접수는 계속** 되게 합니다. 분류만 "미분류"로 줄여서. + +> **생활 속 예 · 카드 단말기가 고장 난 편의점.** 단말기가 안 된다고 문을 닫지 않고 "오늘은 현금만"으로 판매를 계속합니다. 과제 1은 LLM(단말기)이 멈춰도 접수(판매)는 "미분류"로 계속하게 하는 실습입니다. + +**상황.** 지금 `my-intake`는 분류(LLM) → 접수 순서입니다. 키가 잘못되거나 OpenAI에 장애가 나면 분류 노드에서 멈추고 500이 나며, 접수 건은 하나도 생기지 않습니다. 그 사이 들어온 문의는 모두 사라집니다. 분류는 나중에 사람이 해도 되지만, 접수되지 않은 문의는 아무도 모릅니다. + +**어떻게 시험하나.** 실제 OpenAI 장애를 기다릴 수는 없으니 "LLM이 멈춘 상태"를 만듭니다. `cases/lab01-challenge.json`의 한 케이스가 `llm down`으로 n8n의 OpenAI Credential을 일부러 틀린 키로 바꾸고, 끝나면 `llm up`으로 진짜 키를 되돌립니다. + +| 볼 것 | 고치기 전 | 고친 뒤 기대 | +| --- | --- | --- | +| LLM이 멈췄을 때 답 | 직접 확인 | 200, `created: 3, unclassified: 3` | +| 접수 건의 분류 | 직접 확인 | `미분류` | +| 평소 요청 | 분류됨 | 그대로 분류됨 | + +**예제 2와의 관계.** 예제 2에서 접수 건 만들기 노드에 쓴 "실패해도 계속"을 이번에는 **분류 노드**에 씁니다. 같은 설정, 다른 노드입니다. 그리고 분류 결과를 읽는 Code가 "실패한 분류"를 알아보고 대신 넣을 값을 정합니다. + +**끝나면 알게 되는 것.** 핵심 일(접수)과 덧붙인 일(분류)을 나누고, 덧붙인 일이 실패하면 줄여서라도 핵심 일을 끝내는 법(우아한 성능 저하), 그리고 줄인 양(`unclassified`)을 숨기지 않는 법. + +팀장이 요청했습니다. + +> "AI 분류가 안 된다고 문의 접수까지 막히면 안 돼요. 분류는 나중에 사람이 해도 되니, 일단 '미분류'로 받아 주세요." + +```bash +uv run tools/flow.py test cases/lab01-challenge.json -v # 고치기 전 +``` + +| 조건 | 값 | +| --- | --- | +| LLM이 실패하면 | `category: 미분류`, `priority: 보통`으로 접수 (결과의 `unclassified`에 셈) | +| 기대 결과 | `cases/lab01-challenge.json` (케이스 하나가 `llm down`, 끝나면 `llm up`) | + +### 이해하기 + +**핵심 일과 덧붙인 일.** 접수는 이 워크플로우의 **핵심 일**입니다. 분류는 접수를 돕는 **덧붙인 일**입니다. 덧붙인 일이 실패했다고 핵심 일까지 멈추면, LLM이 멈춘 동안 들어온 문의가 모두 사라집니다. 덧붙인 일은 **줄여서라도** 핵심 일을 끝내게 합니다 (graceful degradation, 우아한 성능 저하). + +```mermaid +flowchart LR + subgraph 지금 + C1{{"분류 (LLM 멈춤)"}} -->|오류| X1(["500 · 접수 0"]) + end + subgraph 바꾼 뒤 + C2{{"분류 (LLM 멈춤)
실패해도 계속"}} -->|error| P2["Parse: 미분류 · 보통"] --> T2["접수"] --> O2(["200 · created 3
unclassified 3"]) + end +``` + +**72일차와 무엇이 다른가.** 72일차에 LLM이 멈췄을 때는 FAQ 원문으로 "간단 답변"을 했습니다. 오늘은 "미분류로 접수"입니다. 둘 다 같은 생각입니다: LLM 없이도 할 수 있는 몫은 한다. 그리고 **얼마나 줄였는지를 숨기지 않습니다** — `unclassified` 수가 있어야 나중에 사람이 몇 건을 분류해야 하는지 압니다. + +**테스트 케이스.** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 평소 | `batch-two` | `created: 2, unclassified: 0` | +| LLM 멈춤 | `llm down` · `batch-ok` · `llm up` | `created: 3, unclassified: 3, failed: 0` | +| 접수 건 확인 | `/tickets-created/list` | 분류에 `미분류`가 있음 | + +**기대 결과.** 고치기 전 1/3 (LLM 멈춤에서 500), 고친 뒤 3/3. `lab01`도 그대로 6/6. + +**먼저 생각해 보기 (2분)**: 어떤 일은 LLM 없이도 해야 하고, 어떤 일은 LLM 없이는 할 수 없나요? 오늘 문의 접수에서 LLM이 없으면 무엇을 잃나요? + +
▶ 1단계 · 실패한 분류를 알아보기 + +분류 노드가 실패한 항목은 답 글자(`output`) 대신 `error`를 가집니다. 노드가 실패한 항목을 다음으로 넘기게 설정하고, 다음 Code가 `error`를 보고 "미분류"로 정하게 합니다. LLM이 JSON이 아닌 글을 돌려준 경우도 같이 생각합니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 고칠 파일 ② 분류 노드가 실패해도 계속 ③ 실패를 알아보는 법과 대신 넣을 값 ④ 결과에 셀 칸 ⑤ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/intake.json 에서 Classify(AI Agent) 노드가 실패해도 워크플로우가 멈추지 않게 onError ____① 로 해 줘. +Parse 에서 그 항목에 ____② 가 있거나 output 이 JSON 이 아니면 category "____③", priority "보통" 으로. +결과의 ____④ 에 미분류로 접수한 수를 세어 줘. 나머지는 그대로. +기대 결과 cases/lab01-challenge.json, cases/lab01.json 도 그대로 통과. +``` + +빈칸 힌트: + +- ① 예제 2의 `Create ticket`에 쓴 것과 같은 값입니다 (AGENTS.md 5절 AI Agent). +- ② 실패한 항목이 답 글자 대신 가지는 칸입니다 (▶ 1단계). +- ③ 팀장의 말에 있습니다. +- ④ AGENTS.md 3절 결과 모양에서 미분류 수를 세는 칸입니다. +
+ +```bash +uv run tools/flow.py push my-work/intake.json +uv run tools/flow.py test cases/lab01-challenge.json -v +uv run tools/flow.py test cases/lab01.json +``` + +### 실행 결과 · LLM이 멈춰도 접수 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다. + +고치기 전 (예제 2 정답) + +```text +✔ 평소에는 분류됨 · 2.1s +✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up +✘ LLM이 멈추면 미분류로 접수 · 0.8s · status 500 (expected 200); created = null (expected 3); unclassified = null (expected 3); failed = null (expected 0) +✔ OpenAI credential: real key from .env restored +✘ 미분류도 접수 건으로 · 0.0s · tickets has none of ['미분류'] +Passed 1/3 +``` + +고친 뒤 + +```text +✔ 평소에는 분류됨 · 3.5s +✔ LLM이 멈추면 미분류로 접수 · 0.9s +✔ 미분류도 접수 건으로 · 0.0s +Passed 3/3 +(lab01 6/6) +``` + +**왜 이렇게 나왔나** + +- 고치기 전 0.8초 만에 500: 키가 틀려 OpenAI가 호출을 거절하자 분류 노드가 오류로 멈췄습니다 (n8n은 `Error in workflow`). 접수 건 만들기까지 가지 못해 3건 모두 사라졌습니다. +- 고친 뒤 0.9초에 `created: 3, unclassified: 3`: 분류는 실패했지만 `Parse`가 `error`를 보고 미분류 · 보통으로 정해 접수했습니다. LLM을 기다리지 않아 오히려 빨랐습니다. +- `llm up`으로 진짜 키를 되돌린 뒤의 `lab01` 6/6은 평소 분류가 그대로라는 뜻입니다. + +## 과제 2 · 요청 안의 규칙 (10분) + +### 이 실습의 의도 + +**한 줄로:** 비싼 일(LLM 분류 · 접수)을 하기 **전에** 요청 전체를 보고, 규칙에 맞지 않는 것을 입구에서 막습니다. + +> **생활 속 예 · 접수 창구 직원.** 같은 서류를 두 번 내밀면 "방금 접수하셨는데요" 하고 그 자리에서 돌려주고, 서류를 수백 장 한꺼번에 내밀면 "한 번에 10장까지만 받아요"라고 합니다. 안쪽 사무실까지 들고 가서 처리하지 않습니다. 과제 2는 워크플로우 입구에 이 창구 직원을 세우는 실습입니다. + +**상황 둘.** + +| 상황 | 지금은 | 바라는 것 | +| --- | --- | --- | +| 엑셀에서 복사하다 같은 줄(같은 `id`)이 두 번 | 둘 다 분류 · 접수 시도. 키 덕분에 접수 건은 하나지만 답은 "둘 다 접수" | 두 번째 줄만 `rejected` (`duplicate id in this request`) | +| 한 번에 수백 건 | 모두 처리, 그동안 n8n이 붙잡힘 | 10건이 넘으면 요청 전체를 400으로 거절, 하나도 처리하지 않음 | + +**예제 2와의 차이.** 예제 2의 항목 확인은 "이 한 줄에 id · user · text가 있나"였습니다. 이번 규칙은 **다른 줄과 비교해야**(같은 id) 또는 **요청 전체를 봐야**(몇 건인가) 알 수 있습니다. + +**왜 멱등 키로 충분하지 않나.** [교안 1단계 8절](../../Lecture/76/01-STAGE1-FAILURE-TYPES.md)의 "멱등 키가 있어도 입구 규칙이 필요한 까닭"을 먼저 읽습니다. 키는 중복 접수 건이라는 **피해**를 막고, 입구 규칙은 쓸데없는 분류와 틀린 숫자라는 **일**을 막습니다. + +**시험하는 것.** `batch-dup-id`(R-401 두 번 · R-402), `batch-eleven`(11건), 11건이 400일 때 접수 건이 정말 하나도 안 생겼는지, 평소 요청은 그대로인지. + +**끝나면 알게 되는 것.** 항목 규칙과 요청 전체 규칙의 차이, 상한을 넘으면 일부가 아니라 전체를 거절하는 까닭, 입구에서 막으면 얼마나 빨라지는지 (11건을 다 처리할 때와 비교). + +> "어떤 팀은 엑셀에서 복사하다 같은 줄을 두 번 넣어 보내요. 그리고 한 번에 수백 건을 보내서 몇 분씩 걸린 적도 있어요. 같은 요청 안에 같은 번호가 또 있으면 두 번째는 받지 말고, 한 번에는 10건까지만 받아 주세요." + +```bash +uv run tools/flow.py test cases/lab01-challenge2.json -v # 고치기 전 +``` + +| 조건 | 값 | +| --- | --- | +| 같은 `id`가 또 | 두 번째부터 `rejected`, 이유 `duplicate id in this request` | +| 10건 넘음 | HTTP 400 `{"error": "too many requests (max 10)"}`, 하나도 처리하지 않음 | +| 기대 결과 | `cases/lab01-challenge2.json`, `lab01` · `lab01-challenge`도 그대로 | + +### 이해하기 + +**입구에서 막기 (validate at the edge).** 잘못된 요청은 비싼 일(LLM · API)을 하기 **전에** 막는 것이 가장 쌉니다. 예제 2의 항목 확인이 "항목 하나"의 규칙이었다면, 이번에는 **요청 전체**의 규칙입니다. + +| 규칙 | 어느 수준 | 어기면 | +| --- | --- | --- | +| id · user · text가 있어야 | 항목 | 그 항목만 거절 (나머지는 처리) | +| 같은 요청 안에 같은 id가 또 | 항목 (두 번째) | 두 번째만 거절 | +| 한 번에 10건까지 | 요청 전체 | 요청 전체 400 (하나도 처리하지 않음) | + +**왜 같은 id는 멱등 키로 충분하지 않나.** 같은 id 두 개는 키가 같아 접수 건은 하나만 생깁니다. 하지만 결과에는 `created` 2건(하나는 `duplicate: true`)으로 나와, 사용자는 두 건이 접수된 줄 압니다. 그리고 LLM 분류를 두 번 합니다. **입구에서 두 번째를 거절**하면 결과가 정직하고 비용도 덜 듭니다. + +**왜 상한인가.** 상한이 없으면 한 요청이 몇 분씩 n8n을 붙잡고, 그동안 다른 요청이 기다립니다. 많은 API가 한 번에 받을 수 있는 수를 정해 둡니다 (예: AWS SQS의 묶음 보내기는 한 번에 10개). 상한을 넘으면 일부만 처리하지 않고 **전체를 거절**합니다. 일부만 처리하면 어디까지 됐는지 사용자가 알기 어렵기 때문입니다. + +```mermaid +flowchart LR + Q(["requests"]) --> L{"목록이고
10건 이하?"} + L -->|아니오| E(["400 · 이유"]) + L -->|예| V{"항목마다:
빠진 것 · 앞에 나온 id?"} + V -->|맞음| P["분류 · 접수"] + V -->|아님| R["rejected · 이유"] +``` + +**테스트 케이스.** + +| 케이스 | 보내는 것 | 기대 | +| --- | --- | --- | +| 같은 id 두 번 | `batch-dup-id` (R-401 둘 · R-402) | `created: 2, rejected: 1, duplicates: 0`, 이유 `duplicate id` | +| 11건 | `batch-eleven` | 400, `error`에 `too many` | +| 400이면 아무것도 | `/tickets-created/list` | `count: 2` | +| 평소 | `batch-ok` | `created: 3, rejected: 0` | + +**기대 결과.** 고치기 전 1/4, 고친 뒤 4/4. 11건은 LLM을 부르지 않아 빠르게 끝납니다. + +
▶ 1단계 · 어디에 넣나 + +"목록인가"를 보는 곳이 이미 있습니다 (예제 2의 400). 상한은 그 자리에, 같은 id는 항목을 확인하는 Code에서 **앞에서 본 id를 기억해** 둡니다. 400의 이유가 둘(목록이 아님 · 너무 많음)이 되므로 응답 글도 나눠야 합니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 요청 전체 규칙과 응답 ② 항목 규칙과 이유 ③ 어느 것이 거절되나 ④ 이미 있는 규칙은 그대로 ⑤ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/intake.json 에서 requests 가 목록이어도 ____① 건이 넘으면 HTTP 400 {"error": "too many requests (max 10)"} 로, 하나도 처리하지 말아 줘. +목록이 아닐 때의 400 글은 그대로 "requests must be a list". +Check items 에서 같은 요청 안에 같은 id 가 또 나오면 ____② 번째부터 rejected, 이유 "____③". +앞에서 본 id 는 ____④ 에 기억해 둔다. 나머지 규칙(빠진 칸 거절 · 재시도 · 키 · 미분류)은 그대로. +기대 결과 cases/lab01-challenge2.json, cases/lab01.json · lab01-challenge.json 도 통과. +``` + +빈칸 힌트: + +- ① 팀장의 말에 있습니다. "넘으면"이므로 그 수 자체는 받습니다. +- ② 처음 나온 것은 받습니다. +- ③ 위 조건 표의 이유 글 그대로입니다 (테스트가 `duplicate id`를 찾음). +- ④ 이미 본 것을 빨리 찾을 수 있는 JavaScript 모음입니다 (목록도 되지만 더 알맞은 것). +
+ +```bash +uv run tools/flow.py push my-work/intake.json +uv run tools/flow.py test cases/lab01-challenge2.json -v +uv run tools/flow.py test cases/lab01.json +uv run tools/flow.py test cases/lab01-challenge.json +uv run tools/flow.py ops --reset +``` + +### 실행 결과 · 요청 안의 규칙 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다. + +고치기 전 (과제 1 정답) + +```text +✘ 같은 id가 두 번이면 두 번째는 거절 · 2.8s · created = 3 (expected 2); rejected = 0 (expected 1); duplicates = 1 (expected 0); results has none of ['duplicate id'] +✘ 11건은 400 (한 번에 10건까지) · 11.0s · status 200 (expected 400); error has none of ['too many'] +✘ 400이면 하나도 만들지 않음 · 0.0s · count = 13 (expected 2) +✔ 평소 요청은 그대로 · 3.2s +Passed 1/4 +``` + +고친 뒤 + +```text +✔ 같은 id가 두 번이면 두 번째는 거절 · 3.4s +✔ 11건은 400 (한 번에 10건까지) · 0.2s +✔ 400이면 하나도 만들지 않음 · 0.0s +✔ 평소 요청은 그대로 · 3.7s +Passed 4/4 +(lab01 6/6 · lab01-challenge 3/3) +``` + +**왜 이렇게 나왔나** + +- 고치기 전 같은 id 두 번은 `created: 3, duplicates: 1`: 멱등 키 덕분에 접수 건은 둘만 생겼지만, 결과는 세 건을 접수했다고 말하고 LLM 분류도 세 번 했습니다. +- 고치기 전 11건은 **11.0초** 걸려 모두 처리됐고, 접수 건이 13개(2 + 11)가 되었습니다. 상한이 없으면 한 요청이 그만큼 n8n을 붙잡습니다. +- 고친 뒤 11건은 **0.2초**에 400: LLM과 API를 부르기 전에 입구에서 막았습니다. 접수 건은 2개 그대로입니다. +- 평소 요청 3건은 그대로 `created: 3`: 새 규칙이 정상 요청을 막지 않았습니다. + +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| 거절한 항목이 결과에 없음 | 거절 갈래가 모으는 노드(Merge)의 두 번째 입력(`index: 1`)에 이어졌는지 | +| `flaky`에서 실패 | 접수 건 만들기 노드에 재시도 설정이 있는지 (`parameters` 밖, 노드 바로 아래). `check`가 안에 쓴 것을 오류로 알려 줌 | +| 다시 보냈더니 `duplicates`가 0 | `key`가 요청의 `id`인지 (`$execution.id`가 아님) | +| 결과의 순서 · 이유가 엉킴 | Code에서 앞 노드의 `.all()`과 같은 순서로 맞추는지 | +| 테스트 뒤에도 LLM이 모두 실패 | `uv run tools/flow.py llm up` | +| `slow` 케이스가 너무 오래 | 정상 (약 12초). 접수 건 만들기의 timeout이 3초인지 | +| 11건이 400인데 이유가 `must be a list` | 400 응답 글을 두 경우로 나눴는지 | +| 계속 안 됨 | `cp reference/lab01-example2-intake.json my-work/intake.json` 후 push (예제 2까지) | + +## 확인 + +- [ ] 예제 1: 기준의 500 · 이미 만들어진 건 · 중복을 `RUNBOOK.md`에 적었다. +- [ ] 예제 2: `cases/lab01.json` 6/6 (고치기 전 0/6). +- [ ] 예제 3: `cases/lab01-faults.json` 5/5, 고장마다 시간 · 늦게 생긴 접수 건을 적었다. +- [ ] 과제 1: `cases/lab01-challenge.json` 3/3, `lab01`도 통과. +- [ ] 과제 2: `cases/lab01-challenge2.json` 4/4, `lab01` · `lab01-challenge`도 통과. +- [ ] `my-work/PROMPTS.md`, 끝나면 `ops --reset`. diff --git a/Last3Mon/76/LAB-02-A-COLLECT-AND-ALERT.md b/Last3Mon/76/LAB-02-A-COLLECT-AND-ALERT.md new file mode 100644 index 0000000..ca37d46 --- /dev/null +++ b/Last3Mon/76/LAB-02-A-COLLECT-AND-ALERT.md @@ -0,0 +1,132 @@ +# Lab 02 · 실패를 모으고(DLQ), 알리기 (Error Workflow) + +> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-02-Q-COLLECT-AND-ALERT.md](LAB-02-Q-COLLECT-AND-ALERT.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 2026-10-29에 정답 워크플로우를 학생 순서대로 1회씩 실행한 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md). + +## 예제 1 · 요청문 예 + +```text +my-work/intake.json 의 Summarize 뒤에 Any failed?(If) 를 넣어 failed 가 1 이상일 때만 Send to DLQ(HTTP POST http://data-api:8800/ dlq ) 로. +본문은 {items: [...]}, 실패한 항목마다 { item: 원래 요청 {id, user, text}, reason, workflow: "intake" }. rejected 는 넣지 않는다 . +DLQ 저장이 실패해도 응답은 나가게 options.response 에 neverError: true , 두 갈래 모두 Public result 로 가서 같은 결과를 응답. +기대 결과 cases/lab02.json 앞 두 케이스. +``` + +**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`) + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `dlq` | `POST /dlq` (AGENTS.md 2절) | +| ② | 원래 요청 | 재처리 때 그대로 `my-intake`로 보냄. 결과의 `request` | +| ③ | 넣지 않는다 | 다시 해도 같은 실패 | +| ④ | `neverError: true` | HTTP 500을 노드 오류로 만들지 않음 (예제 3에서 이것이 구멍이 됨) | + +정답: [reference/lab02-example1-dlq.json](reference/lab02-example1-dlq.json) (Lab 01 과제 1 · 2 포함) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/09`) | **2/4** · `failed: 2`인데 DLQ 0 · 알림 0 | +| 고친 뒤 (`answers/10`) | **3/4** · DLQ D-1 · D-2 (R-301 · R-302, `attempts 1`, 이유 `500 - tickets service is failing`) · 남은 ✘는 예제 2 | + +## 예제 2 · 요청문 예 + +```text +my-work/error-handler.json 을 새로 만들어 줘. 파일 맨 위에 "id": "errHandler76001", 첫 노드는 errorTrigger . +Make alert(Code) 가 workflow.name · workflow.id · execution.lastNodeExecuted · execution.error.message · execution.id 를 +{workflow, workflow_id, node, message, execution_id, severity: "critical", reason: "unexpected"} 로 만들어 POST http://data-api:8800/alerts. +my-work/intake.json 의 settings 에 errorWorkflow: "errHandler76001" 을 넣어 줘. +기대 결과 cases/lab02.json 전체. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `errHandler76001` | `nightly-report`의 `settings.errorWorkflow`가 이미 이 id | +| ② | `errorTrigger` | `n8n-nodes-base.errorTrigger` (AGENTS.md 5절) | +| ③ | `critical` | 처리하지 못한 오류 (강의 2단계 등급 표) | +| ④ | `errorWorkflow` | `settings` 안 이름 (AGENTS.md 4절) | + +정답: [reference/lab02-example2-error-handler.json](reference/lab02-example2-error-handler.json) · [reference/lab02-example2-intake.json](reference/lab02-example2-intake.json) + +| 10/29 결과 | | +| --- | --- | +| 고친 뒤 (`answers/11`) | **4/4** · 알림 `[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]`, `lab01` 6/6 | + +- 수업 준비 때 확인: 처리기를 **게시 해제**하면 n8n이 부르지 않습니다 (알림 0). 학생이 "push했는데 알림이 없다"면 처리기의 게시 여부부터. +- 풀이 때: 오류 문장 `rows is not iterable [line 4]`만 보고 버그(`$json.items` → `tickets`)를 찾게 해 봅니다. 고치지는 않습니다. + +## 예제 3 · 결과 (에이전트 없이, `answers/12`) + +| 실패 | 그물 | 10/29 | +| --- | --- | --- | +| 빈 글 | 결과 `rejected` | ✔ `rejected: 1, created: 2` | +| API 오류 | `failed` → DLQ | ✔ DLQ 2 | +| 버그 | Error Workflow → critical | ✔ 3초 뒤 `unexpected` | +| **API 오류 + DLQ 오류** | **없음** | ✘ 200 · `failed: 3`, DLQ 2 그대로 (R-101~103 사라짐), 결과에 `dlq_saved` 없음, 알림 없음 | + +- **5/7**이 맞는 답입니다. 6 · 7번 ✘는 과제 1에서 고칩니다. 풀이 때: "예제 1에서 둔 `neverError`가 무엇을 숨겼나?" → DLQ 저장 실패. "처리하는 장치가 실패할 때"를 생각하게 합니다. + +## 과제 1 · 요청문 예 + +```text +my-work/intake.json 의 Send to DLQ 뒤에 Make alert(Code) 와 Send alert(HTTP POST http://data-api:8800/alerts) 를 넣어 줘. +DLQ 응답에 stored 가 있으면 {workflow: "intake", severity: "high", reason: "dlq", count: failed 수, message}, +없으면(DLQ 저장 실패) severity "critical", reason " dlq_down ", message 에 잃을 뻔한 요청 id 들. +알림은 요청 하나에 하나 (항목마다가 아님). 실패가 없으면 알림을 보내지 않는다. +결과에 dlq_saved(실패가 있을 때 true/false) 와 alert("sent" / "none") 를 더해 줘. +기대 결과 cases/lab02-challenge.json, cases/lab02-nets.json, cases/lab02.json. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `stored` | DLQ 응답 `{"stored", "size"}`. 고장이면 `{"error"}`뿐 | +| ② | `high` | 처리한 실패 (critical 아래) | +| ③ | `dlq_down` | 조건 표 그대로 | +| ④ | `alert` | 조건 표의 결과 칸 | + +정답: [answers/76-lab02-challenge-alert.json](answers/76-lab02-challenge-alert.json) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/13`) | **0/6** | +| 고친 뒤 (`answers/14`) | **6/6** · high `count 2` 하나 · 실패 없으면 `alert: none` · DLQ 고장 시 `dlq_saved: false` + critical `DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203` · `lab02-nets` **7/7** · `lab02` 4/4 | + +## 과제 2 · 요청문 예 + +```text +my-work/intake.json 의 Make alert 뒤에 Recent alerts(HTTP POST http://data-api:8800/alerts/list) 를 넣어 +본문 {workflow: "intake", reason: 이번 알림의 reason, within: 600 } 로 10분 안의 같은 알림 수를 받아 줘. +Decide alert(Code): severity 가 high 이고 그 수가 1 이상이면 suppressed true. critical 은 늘 보낸다 . +Send it?(If) 로 suppressed 가 아닐 때만 Send alert, 결과의 alert 는 "sent" / "suppressed" / "none". +기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `600` | 10분 = 600초 (`within`은 초) | +| ② | `high` | 줄이라고 한 등급 | +| ③ | 늘 보낸다 | "정말 급한 건 늘" | +| ④ | `suppressed` | 조건 표 그대로 | + +정답: [answers/76-lab02-challenge2-throttle.json](answers/76-lab02-challenge2-throttle.json) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/15`) | **4/6** · 두 번째에도 `sent`, dlq 알림 2 | +| 고친 뒤 (`answers/16`) | **6/6** · 두 번째 `suppressed`, dlq 알림 1, **DLQ 5건 모두**, critical은 `sent` · `lab02-challenge` 6/6 · `lab01-challenge2` 4/4 | + +## 4교시에 볼 것 + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| 처리기 파일에 `id`가 없음 | "`check`의 경고 — 다른 워크플로우가 id로 부름" | +| 처리기를 push하지 않음 | "게시를 내린 처리기는 불리지 않음" | +| 알림 확인을 `POST /alerts`로 함 | "그건 알림을 만든다. 읽기는 `/alerts/list`" | +| `dlq_saved`가 늘 true | "`neverError`라 상태 코드는 늘 성공. 본문의 `stored`" | +| 알림을 항목마다 보냄 | "요청 하나에 하나, `count`" | +| suppressed인데 DLQ에도 안 넣음 | "줄이는 것은 알림이지 기록이 아님" | diff --git a/Last3Mon/76/LAB-02-Q-COLLECT-AND-ALERT.md b/Last3Mon/76/LAB-02-Q-COLLECT-AND-ALERT.md new file mode 100644 index 0000000..fa4fe76 --- /dev/null +++ b/Last3Mon/76/LAB-02-Q-COLLECT-AND-ALERT.md @@ -0,0 +1,721 @@ +# Lab 02 · 실패를 모으고(DLQ), 알리기 (Error Workflow) + +- 관련 강의: [2단계 · 모으고 알리기](../../Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md) +- 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 12분 · 예제 3 6분 · 과제 1 10분 · 과제 2 10분 +- 준비: Lab 01의 `my-work/intake.json` (없으면 `cp reference/lab01-example2-intake.json my-work/intake.json` 후 push. 그 파일은 Lab 01 예제 2까지) +- 목표: 다시 하면 될 실패는 **잃지 않게 모아 두고**(DLQ), 예상하지 못한 오류는 **n8n이 대신 알리게**(Error Workflow) 한다. 어느 실패가 어느 그물에 걸리는지 보고 그물 사이의 구멍을 막고, 처리한 실패도 사람에게 알리되 같은 알림이 쏟아지지 않게 한다. + +```mermaid +flowchart LR + E1["예제 1
실패한 요청은 DLQ"] --> E2["예제 2
Error Workflow"] --> E3["예제 3
어느 실패가
어느 그물에"] --> T1["과제 1
처리한 실패도 알림
DLQ가 멈추면?"] --> T2["과제 2
알림 폭주 막기"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py ops --reset`. + +## 예제 1 · 실패한 요청은 DLQ에 (12분) + +### 이 실습의 의도 + +**한 줄로:** 데이터 API가 멈춘 동안 들어온 문의를 **잃지 않게 따로 모아 두는 곳**(DLQ)을 만듭니다. + +> **생활 속 예 · 택배 보관소.** 집에 아무도 없으면 기사는 상자를 버리지 않고 보관소에 상자 그대로 · 못 전한 까닭과 함께 둡니다. 지금 `my-intake`는 "배달 실패"라고 말만 하고 상자를 길에 두고 가는 기사와 같습니다. 예제 1은 보관소(DLQ)를 만드는 실습입니다. + +**상황.** Lab 01까지 고친 `my-intake`는 API가 멈추면 그 문의를 `failed`로 표시하고 200으로 답합니다. 정직한 답이지만, 그 답을 받은 화면이 닫히면 끝입니다. 문의 내용은 데이터 API에도, n8n에도, 어디에도 남지 않습니다. 원인을 고친 뒤 "아까 실패한 것 다시 해 줘"라고 할 대상이 없습니다. + +**먼저 볼 것 → 고칠 것.** + +| 순서 | 명령 | 볼 것 | +| --- | --- | --- | +| 1 | `fault tickets error` → `call my-intake batch-two` | 답은 `failed: 2`. 여기까지는 Lab 01과 같음 | +| 2 | `fault tickets ok` → `ops` | **DLQ가 0** — 실패한 두 문의가 어디에도 없음 | +| 3 | `test cases/lab02.json` (고치기 전) | DLQ 케이스가 ✘ | +| 4 | 요청문 → push → 다시 test | DLQ에 R-301 · R-302가 원래 요청 그대로 | + +**무엇을 넣고 무엇을 안 넣나.** 다시 하면 될 실패(`failed`)만, **처음 받은 요청 그대로** 넣습니다. 그래야 Lab 03에서 같은 요청을 `my-intake`에 다시 보낼 수 있습니다. 거절(`rejected`)은 다시 해도 같으니 넣지 않습니다. + +**끝나면 알게 되는 것.** 실패를 "알리는 것"과 "잃지 않는 것"은 다르다는 것, DLQ에 무엇을 담아야 나중에 다시 돌릴 수 있는지. 이 DLQ는 Lab 03의 재처리가 읽어 갑니다. + +```bash +uv run tools/flow.py ops --reset +uv run tools/flow.py fault tickets error +uv run tools/flow.py call my-intake requests/batch-two.json # failed 2 — 그다음은? +uv run tools/flow.py fault tickets ok +uv run tools/flow.py ops # DLQ는? +uv run tools/flow.py test cases/lab02.json -v # 고치기 전 +``` + +API가 멈춘 동안 들어온 두 문의는 결과에 `failed`로만 남았습니다. 사용자 화면을 닫으면 사라집니다. + +**만들 것**: 실패한(`failed`) 요청을 **원래 요청 그대로** 데이터 API의 DLQ(`POST /dlq`)에 넣기. + +| 조건 | 값 | +| --- | --- | +| 넣는 것 | 본문 `{items: [...]}`, 실패한 항목마다 `{item: 원래 요청 {id, user, text}, reason, workflow: "intake"}` | +| 거절(`rejected`)은 | 넣지 않는다 (다시 해도 같으니까) | +| 실패가 없으면 | DLQ를 부르지 않는다 | +| 기대 결과 | `cases/lab02.json` 앞 두 케이스 | + +### 이해하기 + +**DLQ(dead letter queue)는 "배달하지 못한 편지 보관함"입니다.** 우체국은 주소가 틀린 편지를 버리지 않고 따로 모아 둡니다. 메시지 시스템(Amazon SQS · RabbitMQ · Kafka)도 처리하지 못한 메시지를 버리지 않고 DLQ로 옮깁니다. 나중에 원인을 고친 사람이 **같은 메시지를 다시** 처리할 수 있게요. + +```mermaid +flowchart LR + subgraph 지금 + S1["Summarize
failed 2"] --> O1(["응답 · 그리고 끝"]) + end + subgraph 바꾼 뒤 + S2["Summarize"] --> F2{"failed > 0?"} + F2 -->|예| D2[("POST /dlq
원래 요청 · 이유")] --> O2(["응답"]) + F2 -->|아니오| O2 + end +``` + +**무엇을 넣나.** + +| 넣는 것 | 까닭 | +| --- | --- | +| **원래 요청 그대로** (`id` · `user` · `text`) | 나중에 같은 요청으로 다시 돌림 (Lab 03). 분류 결과 같은 중간 값이 아니라 처음 받은 것 | +| 이유 (`reason`) | 사람이 원인을 압니다 (`500 - tickets service is failing`, `timeout`) | +| 어느 워크플로우 (`workflow`) | 여러 워크플로우가 같은 DLQ를 쓸 때 | + +**거절은 넣지 않습니다.** 빈 글은 다시 해도 빈 글입니다. DLQ는 "**나중에 다시 하면 될 것**"만 모읍니다. 거절까지 넣으면 재처리할 때마다 같은 실패가 되풀이되고, 정말 살려야 할 것이 묻힙니다. + +**같은 요청이 또 오면.** 오늘 데이터 API는 같은 `item.id`가 다시 들어오면 새 줄을 만들지 않고 `attempts`를 하나 올립니다. 재처리(Lab 03)에서 또 실패할 때 쓰입니다. + +**테스트 케이스 (예제 1 몫).** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| API가 멈추면 | `tickets error` · `batch-two` | 200 · `created: 0, failed: 2` | +| DLQ 확인 | `/dlq/list` | `size: 2`, `R-301` · `R-302` | + +**기대 결과.** 고치기 전 `lab02` 2/4, 예제 1 뒤 3/4 (남은 ✘는 예제 2의 알림). + +
▶ 1단계 · 왜 원래 요청 그대로 + +나중에 다시 돌릴 때(3단계) 같은 요청을 그대로 `my-intake`에 보내야 합니다. Lab 01의 결과에서 실패한 항목은 `request`(원래 요청)를 가지고 있습니다. 그것을 모아 DLQ 본문으로. +
+ +
▶ 2단계 · DLQ에 넣다가 실패하면 + +DLQ 저장마저 실패하면? 최소한 사용자 응답은 나가야 합니다. 어떤 설정을 둘까요? (그다음은 예제 3에서 직접 봅니다) +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 언제 DLQ로 가나 ② DLQ 주소와 본문 모양 ③ 넣지 않는 것 ④ DLQ가 실패해도 응답 ⑤ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/intake.json 의 Summarize 뒤에 Any failed?(If) 를 넣어 failed 가 1 이상일 때만 Send to DLQ(HTTP POST http://data-api:8800/ ____① ) 로. +본문은 {items: [...]}, 실패한 항목마다 { item: ____② {id, user, text}, reason, workflow: "intake" }. rejected 는 ____③ . +DLQ 저장이 실패해도 응답은 나가게 options.response 에 ____④ , 두 갈래 모두 Public result 로 가서 같은 결과를 응답. +기대 결과 cases/lab02.json 앞 두 케이스. +``` + +빈칸 힌트: + +- ① AGENTS.md 2절 데이터 API 표에서 실패한 요청을 넣는 주소입니다. +- ② 나중에 다시 돌릴 때 그대로 보낼 것입니다 (▶ 1단계). +- ③ 다시 해도 같은 실패입니다. +- ④ AGENTS.md 5절 "DLQ · 알림" 노드의 응답 설정입니다. HTTP 오류를 노드 오류로 만들지 않는 값. +
+ +```bash +uv run tools/flow.py push my-work/intake.json +uv run tools/flow.py test cases/lab02.json -v +uv run tools/flow.py ops +``` + +### 실행 결과 · 실패한 요청은 DLQ에 + +수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 (Lab 01 과제 2 정답) + +```text +$ fault tickets error → call my-intake requests/batch-two.json +HTTP 200 · 2.8s ( "failed": 2 ) +$ ops +created tickets: 0 +DLQ: 0 (parked 0) +alerts: 0 + +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.8s +✘ 예제 1 · 실패한 2건은 DLQ에 · 0.0s · size = 0 (expected 2); items has none of ['R-301', 'R-302'] +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s +✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); ... +Passed 2/4 +``` + +고친 뒤 + +```text +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.1s +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s +✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); ... +Passed 3/4 + +$ ops +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +``` + +**왜 이렇게 나왔나** + +- 고치기 전 `failed: 2`는 Lab 01에서 이미 있었습니다. 하지만 `ops`의 DLQ가 0입니다. 결과를 받은 화면이 닫히면 두 문의는 **아무 데도 남지 않습니다.** +- 고친 뒤 DLQ에 R-301 · R-302가 원래 요청 그대로, 실패 이유 · `attempts 1`과 함께 들어갔습니다. 이제 원인을 고친 뒤 다시 돌릴 수 있습니다 (Lab 03). +- 남은 ✘는 `nightly-report`의 오류를 아무도 알리지 않아서입니다 (예제 2). + +## 예제 2 · Error Workflow (12분) + +### 이 실습의 의도 + +**한 줄로:** 내가 **예상하지 못한** 오류로 워크플로우가 멈췄을 때, n8n이 대신 알림을 남기게 합니다. + +> **생활 속 예 · 자동차 경고등.** 운전자는 모든 고장을 미리 알 수 없지만, 무슨 일이 생기면 계기판에 경고등이 켜져 정비소에 가게 됩니다. 경고등이 고장을 고치지는 않습니다. 예제 2는 `nightly-report`의 버그(고장)를 고치지 않고, 버그가 나면 켜지는 경고등(Error Workflow → 알림)을 다는 실습입니다. + +**상황.** 예제 1의 DLQ는 "API가 멈출 수 있다"를 **예상하고** 미리 길을 만들어 둔 것입니다. 하지만 버그, 모르는 응답 모양, 누가 바꾼 설정 같은 오류는 미리 길을 만들 수 없습니다. 그런 오류로 워크플로우가 멈추면 부른 쪽에 500 하나가 갈 뿐, **운영하는 사람은 아무도 모릅니다.** + +**연습 대상.** `nightly-report`는 일부러 버그를 넣어 둔 제공 워크플로우입니다. 버그는 **고치지 않습니다.** 오늘의 목표는 버그를 고치는 것이 아니라 "버그가 나면 누가 아느냐"를 바꾸는 것입니다. + +| 순서 | 명령 | 볼 것 | +| --- | --- | --- | +| 1 | `call nightly-report` | 500 | +| 2 | `ops` | `alerts: 0` — 아무도 모름 | +| 3 | 에러 처리기를 만들어 push → `test cases/lab02.json` | 3초 뒤 critical 알림: 어느 워크플로우 · 어느 노드 · 무슨 오류 | + +**n8n이 해 주는 것과 내가 할 것.** 워크플로우 설정에 `errorWorkflow`(처리기 id)가 있으면, 그 워크플로우가 멈출 때 n8n이 처리기를 부릅니다. 나는 처리기(Error Trigger로 시작 → 알림 남기기)를 만들어 **정해진 id로 게시**하고, 내 `my-intake`에도 같은 설정을 넣습니다. + +**끝나면 알게 되는 것.** "처리한 실패"(DLQ)와 "처리하지 못한 실패"(Error Workflow)는 받는 곳이 다르고 둘 다 있어야 한다는 것, 알림 한 줄에 무엇이 있어야 고칠 곳을 찾을 수 있는지. + +이제 확인합니다. `nightly-report`는 일부러 버그가 있는 워크플로우입니다 (고치지 않습니다). + +```bash +uv run tools/flow.py call nightly-report # HTTP 500 — 누가 알까? +uv run tools/flow.py ops # alerts: 0 +``` + +**만들 것**: 처리하지 못한 오류로 워크플로우가 멈추면 n8n이 부르는 **에러 처리기**. 알림을 데이터 API에 남긴다. 그리고 내 접수 워크플로우도 이 처리기를 쓰게. + +| 조건 | 값 | +| --- | --- | +| 파일 | `my-work/error-handler.json`, 맨 위에 `"id": "errHandler76001"` ([AGENTS.md](AGENTS.md) 3 · 4절) | +| 알림 | `POST /alerts` `{workflow, workflow_id, node, message, execution_id, severity: "critical", reason: "unexpected"}` | +| 내 접수 워크플로우 | `settings.errorWorkflow: "errHandler76001"` | +| 기대 결과 | `cases/lab02.json` 전체, `lab01`도 그대로 | + +### 이해하기 + +**처리한 실패와 처리하지 못한 실패.** 예제 1의 DLQ는 내가 **예상한** 실패(API가 멈춤)를 그 자리에서 처리한 것입니다. 하지만 모든 실패를 예상할 수는 없습니다. 버그, 모르는 응답 모양, 누가 바꾼 설정. 그런 오류로 워크플로우가 멈추면, 응답 500 하나만 남고 아무도 모릅니다. + +| | 처리한 실패 | 처리하지 못한 실패 | +| --- | --- | --- | +| 예 | API가 멈춤 (예상함) | `nightly-report`의 버그 | +| 워크플로우 | 계속, 200 | **멈춤**, 500 | +| 받는 곳 | DLQ + 알림 (내가 만듦, 과제 1) | **Error Workflow** (n8n이 부름) | + +**Error Workflow는 마지막 그물입니다.** n8n은 워크플로우 설정에 `errorWorkflow`가 있으면, 그 워크플로우가 처리하지 못한 오류로 멈출 때 지정한 워크플로우를 부릅니다. 받는 쪽은 **Error Trigger** 노드로 시작하고, 어느 워크플로우 · 어느 실행 · 어느 노드 · 무슨 오류인지를 받습니다. + +```mermaid +flowchart LR + W["nightly-report
settings.errorWorkflow"] -->|처리 못 한 오류로 멈춤| E["On error
(Error Trigger)"] --> C["Make alert"] --> A[("POST /alerts
critical · unexpected")] + W -.->|응답 500| U(["부른 사람"]) +``` + +| Error Trigger가 넘기는 것 | 알림에 | +| --- | --- | +| `workflow.name` · `workflow.id` | 어느 워크플로우 | +| `execution.lastNodeExecuted` | 어느 노드 | +| `execution.error.message` | 무슨 오류 | +| `execution.id` | n8n 화면 Executions에서 그 실행을 연다 | + +**처리기의 id는 고정합니다.** 다른 워크플로우가 처리기를 **id로** 부르므로, 파일 맨 위에 `"id": "errHandler76001"`을 씁니다 (70일차 모듈과 같음). 처리기도 **게시(push)**해야 합니다. 게시를 내린 처리기는 n8n이 부르지 않습니다 (수업 준비 때 확인). + +**알림은 늦게 생깁니다.** Error Workflow는 웹훅 응답(500)이 나간 **뒤에** 돕니다. 테스트는 3초를 기다린 뒤(`{"sleep": 3}`) 알림을 봅니다. + +**테스트 케이스 (예제 2 몫).** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 예상 못 한 오류 | `nightly-report` | 500 | +| 알림 | 3초 뒤 `/alerts/latest` | `severity: critical, reason: unexpected`, 워크플로우 이름, 노드 `Count by category` | + +**기대 결과.** `lab02` 4/4. `ops`에 critical 알림 하나. + +
▶ 1단계 · 둘 다 있어야 하는 이유 + +DLQ만 있으면? 예상 못 한 버그는 아무도 모릅니다. Error Workflow만 있으면? API가 멈출 때마다 워크플로우가 멈춰야 알림이 오고, 멈추면 그 실행의 다른 항목도 함께 잃습니다. +
+ +
▶ 2단계 · 알림에 무엇을 + +받는 사람이 "어느 워크플로우의 어느 노드에서 무슨 오류"를 바로 알아야 합니다. 실행 번호가 있으면 n8n 화면 Executions에서 그 실행을 엽니다. Error Trigger가 넘기는 값은 AGENTS.md 5절. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 파일과 고정 id ② 시작 노드 ③ 알림에 담을 값과 등급 ④ 부르는 쪽 설정 ⑤ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/error-handler.json 을 새로 만들어 줘. 파일 맨 위에 "id": "____①", 첫 노드는 ____② . +Make alert(Code) 가 workflow.name · workflow.id · execution.lastNodeExecuted · execution.error.message · execution.id 를 +{workflow, workflow_id, node, message, execution_id, severity: "____③", reason: "unexpected"} 로 만들어 POST http://data-api:8800/alerts. +my-work/intake.json 의 settings 에 ____④ : "errHandler76001" 을 넣어 줘. +기대 결과 cases/lab02.json 전체. +``` + +빈칸 힌트: + +- ① 위 조건 표의 파일 줄입니다. `nightly-report`의 설정에 이미 적혀 있는 값과 같아야 합니다. +- ② AGENTS.md 5절 "Error Workflow"의 노드 종류(`type`의 끝 부분)입니다. +- ③ 처리하지 못한 오류의 등급입니다. 강의 2단계 알림 등급 표. +- ④ AGENTS.md 4절 워크플로우 파일 규칙의 `settings` 안 이름입니다. +
+ +```bash +uv run tools/flow.py push my-work/error-handler.json my-work/intake.json +uv run tools/flow.py test cases/lab02.json -v +uv run tools/flow.py ops +uv run tools/flow.py test cases/lab01.json +``` + +### 실행 결과 · Error Workflow + +수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. + +고친 뒤 + +```text +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.8s +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s +… before: wait 3s +✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s +Passed 4/4 + +$ ops +alerts: 1 + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] +(lab01 6/6) +``` + +**왜 이렇게 나왔나** + +- `nightly-report`는 여전히 500입니다 (버그를 고치지 않음). 달라진 것은 **누가 아느냐**입니다. 3초 뒤 critical 알림이 생겼고, 어느 워크플로우 · 어느 노드(`Count by category`) · 무슨 오류(`rows is not iterable [line 4]`)인지 한 줄로 보입니다. +- 오류 문장은 버그를 가리킵니다: 응답의 `tickets`를 `items`로 잘못 읽어 `rows`가 비어(`undefined`) 있었습니다. 알림만 보고 고칠 곳을 찾을 수 있습니다. +- `nightly-report`의 설정에 이미 `errorWorkflow: errHandler76001`이 있었기 때문에, 처리기를 그 id로 **게시하는 것만으로** 알림이 시작되었습니다. +- 내 `my-intake`도 같은 처리기를 씁니다. 하지만 오늘의 실패는 모두 처리하므로(200) 처리기가 불리지 않습니다. `lab01` 6/6. + +## 예제 3 · 어느 실패가 어느 그물에 (6분, 에이전트 없이) + +### 이 실습의 의도 + +**한 줄로:** 지금까지 만든 그물 셋이 각각 어떤 실패를 잡는지 확인하고, **그물 사이로 빠지는 구멍**을 찾습니다. 고치지 않고(에이전트 없이) 재기만 합니다. + +> **생활 속 예 · 건전지가 다 된 화재경보기.** 경보기가 있어도 건전지가 다 되면 불이 나도 조용합니다. 예제 3은 그물(거절 · 보관소 · 경고등)을 하나씩 시험하다가, 보관소(DLQ) 문까지 잠겼을 때 아무 소리도 나지 않는 것을 직접 확인하는 실습입니다. + +**지금 있는 그물 셋.** + +| 그물 | 언제 만들었나 | 잡는 것 | +| --- | --- | --- | +| 결과의 `rejected` | Lab 01 예제 2 · 과제 2 | 잘못된 항목 | +| DLQ | 예제 1 | API가 멈춰 처리 못 한 항목 | +| Error Workflow | 예제 2 | 워크플로우를 멈추게 한 예상 못 한 오류 | + +**실패 넷을 일으킵니다.** 빈 글 · API 오류 · 버그, 그리고 마지막으로 **API와 DLQ가 함께 멈춘** 경우. 앞의 셋은 각자 그물에 걸립니다. 네 번째는 어디에 걸릴까요? DLQ에 넣지 못했고, 워크플로우는 멈추지 않았으니(`neverError`) Error Workflow도 불리지 않습니다. + +**`lab02-nets.json`의 6 · 7번은 지금 ✘가 맞습니다.** 이 예제는 구멍을 **보여 주는** 것이고, 막는 것은 과제 1입니다. 5/7이 기대 결과입니다. + +**끝나면 알게 되는 것.** 실패를 처리하는 장치(DLQ)도 실패할 수 있고, 그때 요청이 소리 없이 사라진다는 것 ("누가 감시자를 감시하나"). 그물 지도를 표로 그려 두면 구멍이 보인다는 것. + +**적을 곳.** `my-work/RUNBOOK.md` 1.3 그물 지도 (실패 · 어떻게 알아채나 · 자동으로 하는 일 · 사람이 할 일). + + +```bash +uv run tools/flow.py test cases/lab02-nets.json -v +uv run tools/flow.py ops +``` + +### 이해하기 + +**그물 지도.** + +| 실패 | 걸리는 그물 | 사람이 아는 방법 | +| --- | --- | --- | +| 잘못된 항목 (빈 글) | 결과의 `rejected` + 이유 | 부른 쪽이 결과를 보고 고쳐 보냄 | +| API 오류 | `failed` → **DLQ** | (아직 없음 → 과제 1) | +| 버그 (`nightly-report`) | **Error Workflow** → critical 알림 | 알림 | +| **DLQ도 멈춤** | ? | ? | + +```mermaid +flowchart LR + F1["빈 글"] --> R["rejected"] + F2["API 오류"] --> D[("DLQ")] + F3["버그"] --> E["Error Workflow"] --> A[("알림")] + F4["API 오류 +
DLQ 오류"] -.->|neverError로
조용히 지나감| X(["200 · failed 3
어디에도 없음"]) +``` + +**구멍은 그물 사이에 있습니다.** 예제 1에서 DLQ 저장이 실패해도 응답은 나가게 `neverError`를 두었습니다. 그래서 DLQ가 멈추면 워크플로우는 **멈추지 않고(→ Error Workflow도 안 불림)**, DLQ에도 없고, 결과는 `failed`만 말합니다. 실패를 처리하는 장치 자체가 실패할 때를 생각해야 합니다. 이것을 "누가 감시자를 감시하나"라고 부르기도 합니다. + +**테스트 케이스.** 지금 워크플로우를 바꾸지 않고 재기만 합니다. 6 · 7번은 **지금은 ✘가 맞습니다** (과제 1에서 고침). + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 1 | `batch-bad-item` | `rejected: 1, created: 2` | +| 2 · 3 | `tickets error` · `batch-two`, `/dlq/list` | `failed: 2`, DLQ `size: 2` | +| 4 · 5 | `nightly-report`, 3초 뒤 알림 | 500, critical · unexpected | +| 6 | `tickets error` + `dlq error` · `batch-ok` | `failed: 3, dlq_saved: false` | +| 7 | `/alerts/latest` | critical · `dlq_down` | + +**기대 결과.** 5/7. 6번은 결과에 `dlq_saved`가 없어서, 7번은 마지막 알림이 5번의 `unexpected`라서 ✘. `ops`의 DLQ는 2건 그대로 (3건이 사라짐). + +
▶ 1단계 · 사라진 3건을 찾을 수 있나 + +6번 뒤 `ops`를 봅니다. DLQ는 몇 건인가요? n8n 화면 Executions에서 그 실행을 열면 `Send to DLQ` 노드의 응답에 무엇이 있나요? 사람이 매번 Executions를 열어 볼 수 있을까요? +
+ +### 실행 결과 · 어느 실패가 어느 그물에 + +수업 준비 때(10/29) 강사 노트북에서 예제 2 정답으로 실제로 나온 출력입니다. + +```text +✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 1.7s +✔ 2 · API 오류 → failed · 2.8s +✔ 3 · failed는 DLQ에 · 0.0s +✔ 4 · 버그 → 500 · 0.0s +… before: wait 3s +✔ 5 · Error Workflow → critical 알림 · 0.0s +✘ 6 · DLQ마저 멈추면 결과가 알려야 · 4.1s · dlq_saved = null (expected false) +✘ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s · reason = unexpected (expected dlq_down) +Passed 5/7 + +$ ops +DLQ: 2 (parked 0) + D-1 R-301 … D-2 R-302 … +alerts: 1 + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] +``` + +**왜 이렇게 나왔나** + +- 1~5: 그물 셋이 각자 일했습니다. 빈 글은 결과에, API 오류는 DLQ에, 버그는 알림으로. +- 6: 응답은 200 · `failed: 3`. 그런데 DLQ는 **2건 그대로**입니다. `batch-ok`의 세 문의(R-101~103)는 DLQ 저장이 500으로 실패했고, `neverError` 때문에 워크플로우는 아무 일 없다는 듯 끝났습니다. 결과에도 "DLQ에 못 넣었다"는 말이 없습니다. +- 7: 마지막 알림은 여전히 5번의 버그 알림입니다. **세 문의를 잃었다는 것을 아무도 모릅니다.** 과제 1에서 이 구멍을 막습니다. + +## 과제 1 · 처리한 실패도 알리기, DLQ가 멈추면 (10분) + +### 이 실습의 의도 + +**한 줄로:** DLQ에 쌓인 것을 사람이 **바로 알게** 하고, 예제 3에서 찾은 구멍(DLQ도 멈춤)을 **더 큰 알림**으로 막습니다. + +> **생활 속 예 · 응급실과 "건전지 부족" 삑 소리.** 응급실은 급한 정도에 따라 환자를 나누고, 화재경보기는 건전지가 떨어지면 따로 삑 소리를 냅니다. 과제 1은 보관소에 넣었으면 "확인해 주세요"(high), 보관소에도 못 넣었으면 "지금 바로"(critical, 잃을 뻔한 송장 번호와 함께)로 등급을 나눠 알리는 실습입니다. + +**문제 둘.** + +| 문제 | 지금 | 바라는 것 | +| --- | --- | --- | +| DLQ에 쌓여도 아무도 모름 | 다음 날 아침에 `ops`를 쳐야 앎 | DLQ에 넣을 때 `high` 알림 하나 (몇 건인지) | +| DLQ도 멈추면 요청이 사라짐 | 결과는 `failed`만, 알림 없음 | `critical` 알림, 메시지에 **잃을 뻔한 요청 id** | + +**왜 등급을 나누나.** DLQ에 넣었으면 요청은 안전합니다. 원인을 고친 뒤 재처리하면 됩니다(`high`). DLQ에도 못 넣었으면 요청은 지금 **어디에도 없습니다.** 알림에 적힌 id가 유일한 단서라 바로 봐야 합니다(`critical`). + +**지킬 것.** 실패가 없으면 알림을 보내지 않습니다. 실패한 항목마다가 아니라 **요청 하나에 알림 하나**(개수를 담아)입니다. 그리고 결과에 `dlq_saved` · `alert`를 더해, 부른 쪽도 "DLQ에 들어갔나"를 알게 합니다. + +**끝나면 알게 되는 것.** 알림 등급을 무엇으로 정하나(요청을 잃을 수 있나), DLQ에 저장됐는지를 상태 코드가 아니라 응답 내용으로 판단하는 법. 고치면 예제 3의 `lab02-nets`가 7/7이 됩니다. + +> "DLQ에 쌓이는 걸 아무도 몰라서 다음 날 아침에야 봤어요. DLQ에 넣었으면 바로 알려 주세요. 그리고 아까처럼 DLQ에도 못 넣었으면 그건 정말 급한 거니 더 크게 알려 주세요. 대신 문제가 없을 때는 알림을 보내지 마세요." + +```bash +uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전 +``` + +| 조건 | 값 | +| --- | --- | +| DLQ에 넣었으면 | `POST /alerts` `{workflow: "intake", severity: "high", reason: "dlq", count, message}` | +| DLQ에 못 넣었으면 | `{severity: "critical", reason: "dlq_down", count, message}` — message에 잃을 뻔한 요청 id들 | +| 실패가 없으면 | 알림 없음 | +| 결과에 | `dlq_saved` (실패가 있을 때 true/false), `alert` (`sent` · `none`) | +| 기대 결과 | `cases/lab02-challenge.json`, `lab02-nets` 7/7, `lab02` 그대로 | + +### 이해하기 + +**알림 등급.** 모든 알림이 같은 무게면 받는 사람은 무엇부터 볼지 모릅니다. + +| 등급 | 오늘 | 받으면 | +| --- | --- | --- | +| `critical` | 예상 못 한 오류(버그), **DLQ가 멈춤 (요청을 잃을 수 있음)** | 바로 본다 | +| `high` | DLQ에 쌓임, parked (Lab 03) | 원인(데이터 API 등)을 확인하고 재처리 | +| (알림 없음) | 거절, 미분류 | 결과 · 보고서로 셈 | + +**요청 하나에 알림 하나.** 실패한 항목마다 알림을 보내면 50건 실패에 알림 50개입니다. 요청 하나에 알림 하나, 개수(`count`)를 담습니다. + +**구멍 막기.** DLQ의 응답에 `stored`가 있으면 저장된 것, 없으면(500의 `error`) 저장되지 않은 것입니다. 저장되지 않았다면 **잃을 뻔한 요청의 id를 알림에 직접 적습니다.** 그러면 DLQ가 멈춰도 사람이 알림만 보고 그 요청들을 살릴 수 있습니다. 결과의 `dlq_saved: false`는 부른 쪽에게도 "이건 아직 아무 데도 없다"고 알려 줍니다. + +```mermaid +flowchart LR + F{"failed > 0?"} -->|아니오| N(["alert: none"]) + F -->|예| D[("POST /dlq")] --> S{"stored?"} + S -->|예| H["high · dlq · count"] --> A[("POST /alerts")] + S -->|아니오| C["critical · dlq_down
잃을 뻔한 id들"] --> A + A --> R(["dlq_saved · alert: sent"]) +``` + +**테스트 케이스.** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| API가 멈춤 | `tickets error` · `batch-two` | `failed: 2, dlq_saved: true, alert: sent` | +| high 알림 | `/alerts/latest` | `high · dlq · count 2 · intake` | +| 실패 없음 | `batch-ok` | `alert: none` | +| 알림은 그대로 | `/alerts/list` | `count: 1` | +| DLQ도 멈춤 | `tickets error` + `dlq error` · `batch-bad-item` | `failed: 2, dlq_saved: false` | +| critical 알림 | `/alerts/latest` | `critical · dlq_down · count 2`, message에 `R-201` | + +**기대 결과.** 고치기 전 0/6, 고친 뒤 6/6. 예제 3의 `lab02-nets`도 7/7. + +**먼저 생각해 보기 (2분)**: 알림이 너무 많으면 어떻게 될까요? `critical`과 `high`는 받는 사람에게 무엇이 달라야 할까요? (런북 1절) + +
▶ 1단계 · 한 번에 하나 + +DLQ 노드는 요청 하나에 한 번 돕니다 (`items` 목록 하나). 그 뒤에 알림을 하나 만들면 저절로 "요청 하나에 하나"가 됩니다. +
+ +
▶ 2단계 · 저장됐는지 어떻게 아나 + +`curl -s -XPOST localhost:8800/dlq -d '{"items": [{"item": {"id": "X-1"}, "reason": "test"}]}'`과, `fault dlq error` 뒤 같은 명령을 해 봅니다. 응답에 무엇이 있고 무엇이 없나요? (끝나면 `ops --reset`) +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 어디에 붙이나 ② 저장됐을 때 알림 ③ 저장 못 했을 때 알림 ④ 알림 수 ⑤ 결과에 더할 칸 ⑥ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/intake.json 의 Send to DLQ 뒤에 Make alert(Code) 와 Send alert(HTTP POST http://data-api:8800/alerts) 를 넣어 줘. +DLQ 응답에 ____① 가 있으면 {workflow: "intake", severity: "____②", reason: "dlq", count: failed 수, message}, +없으면(DLQ 저장 실패) severity "critical", reason " ____③ ", message 에 잃을 뻔한 요청 id 들. +알림은 요청 하나에 하나 (항목마다가 아님). 실패가 없으면 알림을 보내지 않는다. +결과에 dlq_saved(실패가 있을 때 true/false) 와 ____④ ("sent" / "none") 를 더해 줘. +기대 결과 cases/lab02-challenge.json, cases/lab02-nets.json, cases/lab02.json. +``` + +빈칸 힌트: + +- ① ▶ 2단계에서 본 DLQ 응답의 칸입니다. 저장된 수. +- ② 팀장 말의 "바로 알려 주세요"는 critical보다 한 단계 아래입니다 (위 등급 표). +- ③ 위 조건 표의 reason 그대로입니다 (테스트가 찾음). +- ④ 위 조건 표의 결과 칸 이름입니다. +
+ +```bash +uv run tools/flow.py push my-work/intake.json +uv run tools/flow.py test cases/lab02-challenge.json -v +uv run tools/flow.py test cases/lab02-nets.json +uv run tools/flow.py test cases/lab02.json +uv run tools/flow.py ops +``` + +### 실행 결과 · 처리한 실패도 알리기 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다. + +고치기 전 (예제 2 정답) + +```text +✘ API가 멈춤 → DLQ · 2.7s · dlq_saved = null (expected true); alert = null (expected sent) +✘ 처리한 실패도 알림 (high · 개수) · 0.0s · severity = null (expected high); reason = null (expected dlq); ... +✘ 실패가 없으면 알림 없음 · 2.8s · alert = null (expected none) +✘ 알림은 그대로 1개 · 0.0s · count = 0 (expected 1) +✘ DLQ도 멈추면 critical · 2.4s · dlq_saved = null (expected false) +✘ 잃어버린 id를 알림에 · 0.0s · severity = null (expected critical); ... message has none of ['R-201'] +Passed 0/6 +``` + +고친 뒤 + +```text +✔ API가 멈춤 → DLQ · 4.3s +✔ 처리한 실패도 알림 (high · 개수) · 0.0s +✔ 실패가 없으면 알림 없음 · 2.9s +✔ 알림은 그대로 1개 · 0.0s +✔ DLQ도 멈추면 critical · 2.4s +✔ 잃어버린 id를 알림에 · 0.0s +Passed 6/6 +(lab02-nets 7/7 · lab02 4/4) + +알림 두 개 (응답에서): + [high] intake · dlq · count 2 · "2 request(s) could not be created and wait in the DLQ" + [critical] intake · dlq_down · count 2 · "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203" +``` + +**왜 이렇게 나왔나** + +- 고치기 전에는 DLQ에는 넣지만 알림이 0개라 모두 ✘입니다. 결과에 `dlq_saved` · `alert` 칸도 없습니다. +- 고친 뒤 실패 2건 → high 알림 **하나**(`count 2`). 실패가 없는 `batch-ok`에는 `alert: none`, 알림은 1개 그대로. +- DLQ가 멈추면 `dlq_saved: false` + critical `dlq_down` 알림, 메시지에 `lost ids R-201, R-203`. DLQ에 없어도 사람이 알림만 보고 두 문의를 살릴 수 있습니다. 예제 3의 구멍이 막혀 `lab02-nets` 7/7. + +## 과제 2 · 알림 폭주 막기 (10분) + +### 이 실습의 의도 + +**한 줄로:** 같은 원인의 `high` 알림은 **10분에 한 번만** 보내, 사람이 알림을 꺼 버리지 않게 합니다. 급한 것(`critical`)과 실패의 기록(DLQ)은 줄이지 않습니다. + +> **생활 속 예 · 알림을 꺼 버린 단톡방.** 같은 공지가 백 번 오면 사람들은 단톡방 알림을 끄고, 정말 중요한 공지도 못 봅니다. 그래도 대화 기록은 남아 있습니다. 과제 2는 같은 원인의 알림은 10분에 한 번만 보내되(알림 줄이기), DLQ(대화 기록)는 그대로 두고, 정말 급한 것(critical)은 늘 보내는 실습입니다. + +**상황.** 과제 1로 "DLQ에 넣으면 알림"이 생겼습니다. 그런데 API가 30분 멈추면, 그동안 들어온 요청마다 알림이 하나씩 갑니다. 같은 원인을 백 번 알리는 셈이고, 받는 사람은 결국 알림을 끕니다. 그러면 정말 급한 알림도 못 봅니다 (알림 피로). + +| 줄이는 것 | 줄이지 않는 것 | +| --- | --- | +| 10분 안에 같은 `reason`의 `high` 알림이 이미 있으면 보내지 않음 (`alert: suppressed`) | DLQ에는 모두 남음 | +| | `critical`은 늘 보냄 (요청을 잃을 수 있으니) | + +**어떻게 아나.** 알림을 보내기 **바로 전에** 데이터 API에 "10분 안에 같은 알림이 있었나"를 묻습니다 (`/alerts/list`의 `within`). + +**시험하는 것.** 첫 실패 → 보냄, 10분 안에 또 실패 → 보내지 않음, 그래도 DLQ에는 모두, DLQ가 멈춘 critical은 그래도 보냄. + +**끝나면 알게 되는 것.** 알림이 많은 것이 안전한 것은 아니라는 것, 운영 도구의 묶기 · 반복 간격 · 중복 없애기가 무엇을 하는지 (그 가장 작은 판), 줄이는 것은 알림이지 기록이 아니라는 것. + +> "어제 API가 30분 멈췄는데, 그동안 요청마다 알림이 와서 휴대폰이 백 번 울렸어요. 그다음부터는 다들 알림을 꺼 버렸고요. 같은 문제면 10분에 한 번만 알려 주세요. 단, 정말 급한 건(critical)은 늘 보내 주세요." + +```bash +uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전 +``` + +| 조건 | 값 | +| --- | --- | +| 같은 알림 | `workflow: intake`이고 같은 `reason`의 알림이 10분(600초) 안에 이미 있으면 | +| 그때 high는 | 보내지 않고 결과에 `alert: "suppressed"` | +| critical은 | 늘 보냄 | +| 10분 안의 알림 수 | `POST /alerts/list` 본문 `{workflow, reason, within: 600}` (AGENTS.md 2절) | +| 기대 결과 | `cases/lab02-challenge2.json`, `lab02-challenge` 그대로 | + +### 이해하기 + +**알림 피로 (alert fatigue).** 알림이 너무 많으면 사람은 알림을 보지 않게 됩니다. 그러면 정말 급한 알림도 묻힙니다. 알림이 많다는 것은 안전하다는 뜻이 아니라, **아무도 보지 않는다**는 뜻이 되기 쉽습니다. + +**같은 문제는 한 번, 대신 숨기지 않기.** + +| 하는 것 | 하지 않는 것 | +| --- | --- | +| 같은 문제의 high 알림은 10분에 한 번 | 실패를 DLQ에서 빼기 (DLQ에는 모두 남음) | +| 결과에 `alert: suppressed` (보내지 않은 것도 기록) | critical 줄이기 (급한 것은 늘 보냄) | + +운영 도구들(Prometheus Alertmanager · PagerDuty 등)은 이것을 묶기(grouping) · 반복 간격(repeat interval) · 중복 없애기(deduplication)라고 부릅니다. 오늘은 그 가장 작은 판입니다: "**같은 이유의 알림이 10분 안에 있었나?**" + +```mermaid +flowchart LR + M["Make alert
high · dlq"] --> L[("POST /alerts/list
intake · dlq · within 600")] --> D{"high이고
이미 있음?"} + D -->|예| S(["alert: suppressed"]) + D -->|아니오| A[("POST /alerts")] --> O(["alert: sent"]) +``` + +**테스트 케이스.** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 첫 실패 | `tickets error` · `batch-two` | `alert: sent` | +| 10분 안에 또 | `batch-ok` | `failed: 3, alert: suppressed` | +| dlq 알림 수 | `/alerts/list {reason: dlq}` | `count: 1` | +| DLQ에는 모두 | `/dlq/list` | `size: 5` | +| critical은 늘 | `dlq error` · `batch-bad-item` | `dlq_saved: false, alert: sent` | +| 고친 뒤 | `batch-dup-id` | `failed: 0, alert: none` | + +**기대 결과.** 고치기 전 4/6 (두 번째에도 보내서), 고친 뒤 6/6. + +
▶ 1단계 · 어디서 세나 + +알림을 보내기 **바로 전에** 데이터 API에 "10분 안에 같은 알림이 있나"를 묻습니다. `curl -s -XPOST localhost:8800/alerts/list -d '{"workflow": "intake", "reason": "dlq", "within": 600}'`로 모양을 봅니다. +
+ +
▶ 2단계 · 왜 critical은 예외인가 + +DLQ가 멈춘 알림을 10분 동안 한 번만 보내면, 그 10분 동안 들어온 다른 요청의 id는 어디에 남나요? (critical 메시지에 id를 적은 까닭) +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 묻는 곳과 본문 ② 보내지 않는 조건 ③ 예외 ④ 결과 값 ⑤ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/intake.json 의 Make alert 뒤에 Recent alerts(HTTP POST http://data-api:8800/alerts/list) 를 넣어 +본문 {workflow: "intake", reason: 이번 알림의 reason, within: ____① } 로 10분 안의 같은 알림 수를 받아 줘. +Decide alert(Code): severity 가 ____② 이고 그 수가 1 이상이면 suppressed true. critical 은 ____③ . +Send it?(If) 로 suppressed 가 아닐 때만 Send alert, 결과의 alert 는 "sent" / "____④" / "none". +기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json. +``` + +빈칸 힌트: + +- ① 10분을 초로. +- ② 팀장이 줄이라고 한 등급입니다. +- ③ 팀장 말의 "단," 뒤입니다. +- ④ 위 조건 표의 값 그대로입니다. +
+ +```bash +uv run tools/flow.py push my-work/intake.json +uv run tools/flow.py test cases/lab02-challenge2.json -v +uv run tools/flow.py test cases/lab02-challenge.json +uv run tools/flow.py test cases/lab01-challenge2.json +uv run tools/flow.py ops --reset +``` + +### 실행 결과 · 알림 폭주 막기 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다. + +고치기 전 (과제 1 정답) + +```text +✔ 첫 실패 → 알림 보냄 · 2.9s +✘ 10분 안에 또 실패 → 보내지 않음 · 3.7s · alert = sent (expected suppressed) +✘ dlq 알림은 1개 · 0.0s · count = 2 (expected 1) +✔ 그래도 DLQ에는 5건 모두 · 0.0s +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.5s +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.0s +Passed 4/6 +``` + +고친 뒤 + +```text +✔ 첫 실패 → 알림 보냄 · 4.5s +✔ 10분 안에 또 실패 → 보내지 않음 · 3.7s +✔ dlq 알림은 1개 · 0.0s +✔ 그래도 DLQ에는 5건 모두 · 0.0s +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.4s +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.8s +Passed 6/6 +(lab02-challenge 6/6 · lab01-challenge2 4/4) +``` + +**왜 이렇게 나왔나** + +- 고치기 전에는 두 번째 실패에도 알림을 또 보내 dlq 알림이 2개입니다. 장애가 30분 이어지면 요청마다 하나씩 쌓입니다. +- 고친 뒤 두 번째는 `suppressed`, dlq 알림 1개. 그래도 **DLQ에는 5건 모두** 있습니다. 줄인 것은 알림이지 실패의 기록이 아닙니다. +- DLQ가 멈춘 critical은 10분 안이라도 `sent`. 급한 것은 줄이지 않았습니다. +- 마지막 `batch-dup-id`는 `failed: 0` · `alert: none`이고, Lab 01 과제 2의 같은 id 거절도 그대로입니다. + +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| DLQ가 비어 있음 | 실패가 있을 때만 가는 If의 true 쪽에 DLQ 노드가 있는지, 본문이 `{items: [...]}`인지 | +| 알림이 안 생김 | 에러 처리기를 push(게시)했는지, 파일의 `id`가 `errHandler76001`인지, 부르는 쪽 `settings.errorWorkflow` | +| 알림이 늦게 생김 | 정상. 응답이 나간 뒤에 돈다 (테스트는 `{"sleep": 3}`) | +| 알림이 두 개 | 테스트의 `url` 케이스가 `/alerts`에 POST하면 알림이 생김 — 읽기는 `/alerts/list` · `/alerts/latest` | +| `dlq_saved`가 늘 true | DLQ 응답의 `stored`를 보는지 (상태 코드는 `neverError`라 늘 성공처럼 보임) | +| 늘 `suppressed` | 앞 테스트의 알림이 남았는지 (`ops --reset`), `within`이 초 단위인지 | +| `fault dlq error` 뒤 다른 테스트가 이상함 | `uv run tools/flow.py fault dlq ok` 또는 `ops --reset` | +| 계속 안 됨 | `reference/lab02-example1-dlq.json`, `reference/lab02-example2-*.json` (예제까지) | + +## 확인 + +- [ ] 예제 1: `cases/lab02.json` 앞 두 케이스, `ops`로 DLQ 확인. +- [ ] 예제 2: `cases/lab02.json` 4/4, `nightly-report`의 알림을 봤다. +- [ ] 예제 3: `cases/lab02-nets.json` 5/7, 그물 지도와 구멍을 `RUNBOOK.md`에 적었다. +- [ ] 과제 1: `cases/lab02-challenge.json` 6/6, `lab02-nets` 7/7, `lab02`도 통과. +- [ ] 과제 2: `cases/lab02-challenge2.json` 6/6, `lab02-challenge`도 통과. +- [ ] `RUNBOOK.md` 1절 (알아채는 법 · 자동으로 하는 일 · 알림 등급). diff --git a/Last3Mon/76/LAB-03-A-REPLAY-AND-RUNBOOK.md b/Last3Mon/76/LAB-03-A-REPLAY-AND-RUNBOOK.md new file mode 100644 index 0000000..8785f5a --- /dev/null +++ b/Last3Mon/76/LAB-03-A-REPLAY-AND-RUNBOOK.md @@ -0,0 +1,131 @@ +# Lab 03 · 다시 돌리기, 런북과 장애 연습, 끝없이 돌리지 않기 + +> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-03-Q-REPLAY-AND-RUNBOOK.md](LAB-03-Q-REPLAY-AND-RUNBOOK.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 2026-10-29에 정답 워크플로우를 학생 순서대로 1회씩 실행한 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md). + +## 예제 1 · 요청문 예 + +```text +my-work/replay.json 을 새로 만들어 줘. 웹훅 POST my-replay . +Get DLQ(HTTP GET http://data-api:8800/dlq) → Pick items(Code): parked 가 아닌 것만, 비어도 다음으로 가게 alwaysOutputData true. +Has items? 가 없으면 바로 {replayed: 0, ok: 0, still_failed: 0, parked: 0}. +있으면 항목마다 Run intake again: POST http://localhost:5678/webhook/my-intake 본문 {requests: [ item ]} (DLQ 의 원래 요청). +my-intake 결과의 created 가 1 이면 성공 → POST /dlq/update {dlq_id, remove: true}. 실패면 지우지 않는다. +끝에 AGENTS.md 3절 {replayed, ok, still_failed, parked, duplicates}. 기대 결과 cases/lab03.json. +``` + +**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`) + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `my-replay` | AGENTS.md 3절 재처리 | +| ② | `alwaysOutputData` | 빈 결과에서도 다음 노드로 (AGENTS.md 5절 그 밖) | +| ③ | `item` | DLQ 항목의 원래 요청 칸 | +| ④ | `created` | `my-intake`는 늘 200이라 결과 칸으로 판단 | + +정답: [reference/lab03-example1-replay.json](reference/lab03-example1-replay.json) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/17`) | **1/5** · `my-replay` 404, DLQ 2 · 접수 건 0 | +| 고친 뒤 (`answers/18`) | **5/5** · `replayed: 2, ok: 2` (1.1초), DLQ 0, 접수 건 2, 한 번 더 `replayed: 0` | + +## 예제 2 · 결과 (에이전트 없이, `answers/19`) + +| 단계 | 본 것 | +| --- | --- | +| 장애 시작 | `my-intake` 200 · `failed: 2` (2.8초), `nightly-report` 500 | +| 알아챔 (`ops`) | DLQ 2 (R-301 · R-302) · 알림 2: `[high] intake · dlq · count 2`, `[critical] … unexpected · rows is not iterable` | +| 고침 · 재처리 | `fault tickets ok` → `call my-replay` 200 (1.4초) | +| 확인 | DLQ 0 · 접수 건 2. **critical 알림은 남음** (버그는 재처리로 안 고쳐짐) | +| `lab03-drill` | **7/7** · 접수 건 5 (평소 3 + 재처리 2) | + +런북 2절 예 (짝 연습 뒤 비교용): + +```markdown +1. `uv run tools/flow.py ops` — 고장 스위치 · DLQ · 알림을 본다. (또는 `curl -s -XPOST localhost:5678/webhook/my-ops-status -d '{}'`) +2. 알림을 나눈다: `high · dlq`는 데이터 API 쪽 → 3번. `critical · unexpected`는 코드 버그 → 알림의 workflow · node · message와 + execution_id로 n8n 화면 Executions에서 그 실행을 열고 코드 담당에게. `critical · dlq_down`은 알림의 lost ids를 먼저 적어 둔다. +3. 데이터 API가 되살아났는지: 데이터 API 담당에게 확인 (연습에서는 `uv run tools/flow.py fault tickets ok` 뒤 `ops`의 `fault switch: all ok`). `/health`는 고장 스위치와 관계없이 ok라 이것만으로는 모름. +4. 재처리: `curl -s -XPOST localhost:5678/webhook/my-replay -d '{}'` → `ok`가 DLQ 수와 같은지. +5. 확인: `ops`의 DLQ 0, 접수 건 수가 재처리한 만큼 늘었고 같은 key가 둘이 아닌지. +하지 말 것: DLQ를 비우지 말 것 (`/dlq-clear`, `ops --reset` — 재처리할 요청이 사라짐). parked를 자동으로 돌리지 말 것. nightly-report를 급히 고쳐 배포하지 말 것 (74일차 배포 절차). +``` + +- 짝 연습에서 흔히 빠지는 단계: ① 알림을 **원인별로 나누기** (critical이 재처리 뒤에도 남아 당황함) ② 재처리 **전에** 데이터 API가 살아났는지 확인 (아니면 재처리가 또 실패해 `attempts`만 오름) ③ 확인에서 **중복** 보기. + +## 예제 3 · 결과 (에이전트 없이, `answers/20`) + +| 케이스 | 10/29 | +| --- | --- | +| `slow` · `batch-two` | 11.6초 · `failed: 2`, DLQ 2 | +| 8초 뒤 접수 건 | **2** (이미 있음) | +| 재처리 | `replayed: 2, ok: 2, duplicates: 2` (1.1초) | +| 그 뒤 | 접수 건 2 그대로, DLQ 0 · **6/6** | + +- 이 예제의 답: DLQ의 요청은 "반쯤 처리됐을" 수 있다. 재처리가 안전한 것은 멱등 키 덕분 (최소 한 번 + 멱등 = 결과는 한 번). + +## 과제 1 · 요청문 예 + +```text +my-work/replay.json 의 Pick items 에서 body.max_attempts(없으면 3 ) 를 읽어, +attempts 가 그 수 이상 인 항목은 action "park", 나머지는 "replay" 로 나눠 줘. +Replay it?(If): replay 는 지금처럼 다시 보내고, park 는 POST /dlq/update {dlq_id, parked: true , reason} 뒤 +POST /alerts {workflow: "replay", severity: "high", reason: "parked", dlq_id, message}. +두 갈래를 Merge 로 모아 요약의 parked 에 수를. parked 항목은 지우지 않는다. +기대 결과 cases/lab03-challenge.json, cases/lab03.json. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `3` | 조건 표 기본값 | +| ② | 이상 (`>=`) | 재처리 3에서 `attempts`가 3. 초과(`>`)면 4번째에야 멈춤 | +| ③ | `true` | DLQ 항목의 `parked` | +| ④ | `parked` | 조건 표 그대로 | + +정답: [answers/76-lab03-challenge-park.json](answers/76-lab03-challenge-park.json) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/21`) | **3/7** · 재처리 3도 `replayed: 2, parked: 0`, 고친 뒤 자동으로 다시 보냄 | +| 고친 뒤 (`answers/22`) | **7/7** · 재처리 3 **0.1초** `parked: 2`, DLQ `size 2 · parked 2`, `[high] parked`, 고친 뒤 `replayed: 0` · `lab03` 5/5 | + +## 과제 2 · 요청문 예 + +```text +my-work/ops-status.json 을 새로 만들어 줘. 웹훅 POST my-ops-status . +Get DLQ(GET /dlq) → Recent alerts(POST /alerts/list {within: 600}) → Get created(GET /tickets-created) → Decide status(Code). +parked 가 1 이상이거나 10분 안 critical 알림이 있으면 "critical", parked 가 아닌 DLQ 가 있거나 high 알림이 있으면 " warn ", 아니면 "ok". +결과 {status, dlq(parked 가 아닌 수), parked, created, alerts: {critical, high}, reasons: [한국어로 까닭]}. +읽기만 한다: 이 워크플로우에서 DLQ · 알림을 바꾸지 않는다 . 기대 결과 cases/lab03-challenge2.json. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `my-ops-status` | 조건 표 | +| ② | `600` | 10분 = 600초 | +| ③ | `warn` | 가운데 신호 | +| ④ | 바꾸지 않는다 | 확인이 상태를 바꾸면 안 됨 (`/alerts` POST 사고) | + +정답: [answers/76-lab03-challenge2-ops-status.json](answers/76-lab03-challenge2-ops-status.json) + +| 10/29 결과 | | +| --- | --- | +| 고치기 전 (`answers/23`) | **3/7** · 상태 케이스 넷 404 | +| 고친 뒤 (`answers/24`) | **7/7** · `ok` → `warn`(DLQ 2) → `critical`(critical 1) → `ok`, `lab03-challenge` 7/7 · `lab02-challenge2` 6/6 | +| `call my-ops-status` (비우기 전) | `critical` · DLQ 5 · critical 1 · high 1 · 까닭 셋 (`critical 알림 1건 (10분)`, `DLQ 5건: 원인을 고친 뒤 재처리`, `high 알림 1건 (10분)`) | + +## 6교시에 볼 것 + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| 재처리가 접수 건을 직접 만듦 | "`my-intake`를 다시 불러 같은 규칙으로" | +| DLQ가 비면 응답이 안 옴 | "`alwaysOutputData` · 할 일 없음 갈래" | +| HTTP 상태로 성공 판단 | "`my-intake`는 늘 200 — `created`" | +| parked를 지움 | "사람이 볼 것. 지우지 않기" | +| 런북에 "확인한다"만 씀 | "무엇을 보면 되는지까지 (DLQ 0 · 같은 key 없음)" | +| 상태 웹훅이 알림을 만듦 | "읽기만" | diff --git a/Last3Mon/76/LAB-03-Q-REPLAY-AND-RUNBOOK.md b/Last3Mon/76/LAB-03-Q-REPLAY-AND-RUNBOOK.md new file mode 100644 index 0000000..276d24d --- /dev/null +++ b/Last3Mon/76/LAB-03-Q-REPLAY-AND-RUNBOOK.md @@ -0,0 +1,675 @@ +# Lab 03 · 다시 돌리기, 런북과 장애 연습, 끝없이 돌리지 않기 + +- 관련 강의: [3단계 · 재처리와 운영 체계](../../Lecture/76/03-STAGE3-REPLAY-AND-RUNBOOK.md) +- 소요 시간: 50분 (6교시 한 시간) · 예제 1 12분 · 예제 2 12분 · 예제 3 6분 · 과제 1 10분 · 과제 2 10분 +- 준비: Lab 02의 `my-work/intake.json` · `my-work/error-handler.json` (없으면 `reference/lab02-example2-*.json`을 복사 후 push) +- 목표: 원인을 고친 뒤 DLQ의 요청을 **중복 없이** 다시 돌리고, 장애가 났을 때 할 일을 **런북**으로 써서 연습하고, 반쯤 처리된 요청도 안전하게 다시 돌리는 것을 보고, 계속 실패하는 요청은 끝없이 돌리지 않고 멈추고, 지금 괜찮은지를 한 번에 알려 주는 상태 웹훅을 만든다. + +```mermaid +flowchart LR + E1["예제 1
재처리 my-replay"] --> E2["예제 2
런북 · 장애 연습"] --> E3["예제 3
반쯤 처리된 요청"] --> T1["과제 1
세 번 실패하면 멈춤"] --> T2["과제 2
운영 상태 한눈에"] +``` + +> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. + +## 예제 1 · 재처리 (12분) + +### 이 실습의 의도 + +**한 줄로:** 원인을 고친 뒤 DLQ에 모아 둔 요청을 **다시 돌려 일을 끝내는** 재처리 워크플로우(`my-replay`)를 만듭니다. + +> **생활 속 예 · 택배 재배송.** 보관소에 둔 택배는 다음 날 다시 배송하고, 전하면 목록에서 지워야 일이 끝납니다. 재배송도 따로 지름길로 하지 않고 **평소와 같은 배송 절차**를 거칩니다. 예제 1은 DLQ(보관소)의 요청을 같은 입구(`my-intake`)로 다시 보내는 재배송 실습입니다. + +**상황.** Lab 02에서 API가 멈춘 동안의 문의를 DLQ에 모았습니다. 하지만 모아 두기만 하면 일은 끝나지 않습니다. 문의한 사람에게 접수 건은 아직 없습니다. 장애가 끝나면 누군가 DLQ의 요청을 다시 처리해야 합니다. + +**핵심 결정: 새로 만들지 말고, 이미 있는 입구로 다시 보내기.** 재처리가 접수 건을 직접 만들면 항목 확인 · 분류 · 키 · DLQ · 알림 규칙을 한 벌 더 만들어야 하고, 언젠가 두 곳이 달라집니다. 그래서 DLQ의 원래 요청을 **`my-intake`에 그대로 다시 보냅니다.** 같은 규칙, 같은 멱등 키를 거칩니다. + +| 재처리가 하는 일 | 까닭 | +| --- | --- | +| DLQ에서 `parked`가 아닌 것을 읽음 | 다시 돌릴 대상 | +| 하나씩 `my-intake`로 | 어느 것이 실패했는지 가리려고 | +| 성공하면 DLQ에서 지움, 실패하면 그대로 | 다음 재처리가 또 시도 (`attempts`는 `my-intake`가 올림) | +| 비었으면 바로 "할 일 없음" | 몇 번을 불러도 안전하게 | + +**시험하는 것.** 장애 동안 2건 → DLQ, 고친 뒤 재처리 → DLQ 0 · 접수 건 2 (중복 없음), 한 번 더 돌려도 할 일 없음. + +**끝나면 알게 되는 것.** DLQ는 끝이 아니라 중간이라는 것, 재처리를 원래 입구로 보내면 규칙이 하나로 유지된다는 것. + +```bash +uv run tools/flow.py test cases/lab03.json -v # 고치기 전 (my-replay가 아직 없음) +``` + +**만들 것**: `my-work/replay.json` (`my-replay`). DLQ의 요청을 `my-intake`에 하나씩 다시 보내고, 성공하면 DLQ에서 지우기. + +| 조건 | 값 | +| --- | --- | +| 계약 | [AGENTS.md](AGENTS.md) 3절 재처리 `{replayed, ok, still_failed, parked, duplicates}` | +| 다시 보내는 곳 | `POST http://localhost:5678/webhook/my-intake` 본문 `{requests: [DLQ의 원래 요청]}` (AGENTS.md 5절) | +| 성공이면 | `POST /dlq/update {dlq_id, remove: true}`. 실패면 지우지 않음 | +| DLQ가 비었으면 | 할 일 없음 `{replayed: 0, ...}` | +| 기대 결과 | `cases/lab03.json` (재처리 뒤 DLQ 0, 접수 건 2개 — 중복 없음) | + +### 이해하기 + +**DLQ는 모으는 곳이지 끝이 아닙니다.** 원인(데이터 API 장애)을 고친 뒤 모아 둔 요청을 다시 돌려야 일이 끝납니다. 이것을 재처리(replay, redrive)라고 부릅니다. Amazon SQS에는 DLQ의 메시지를 원래 큐로 되돌리는 "redrive" 기능이 있습니다. + +```mermaid +flowchart LR + subgraph 지금 + D1[("DLQ 2건")] --> X1(["그대로 쌓임"]) + end + subgraph 바꾼 뒤 + W2["my-replay"] --> G2[("GET /dlq")] --> P2{"parked 아닌 것"} + P2 -->|하나씩| I2["POST my-intake
{requests: [원래 요청]}"] --> O2{"created 1?"} + O2 -->|예| R2[("dlq/update remove")] + O2 -->|아니오| K2["그대로 둠
(my-intake가 attempts +1)"] + end +``` + +**새로 만들지 말고 다시 보내기.** 재처리 워크플로우가 접수 건을 직접 만들면, 항목 확인 · 분류 · 키 · DLQ · 알림을 또 만들어야 하고, 두 곳의 규칙이 언젠가 달라집니다. 이미 있는 `my-intake`를 다시 부르면 **같은 규칙**으로 처리됩니다 (70일차 모듈과 같은 생각). + +**다시 실패하면.** `my-intake`가 또 실패하면 그 요청을 다시 DLQ에 넣습니다. 데이터 API는 같은 `item.id`면 새 줄을 만들지 않고 `attempts`를 올립니다. 그래서 재처리는 실패한 것을 지우지 않고 그대로 두면 됩니다 (과제 1에서 `attempts`를 씀). + +**테스트 케이스.** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 장애 동안 2건 | `tickets error` · `batch-two` | `failed: 2` | +| 고친 뒤 재처리 | `ok` · `my-replay` | `replayed: 2, ok: 2, still_failed: 0` | +| DLQ | `/dlq/list` | `size: 0` | +| 접수 건 | `/tickets-created/list` | `count: 2` (중복 없음) | +| 한 번 더 | `my-replay` | `replayed: 0` | + +**기대 결과.** 고치기 전 1/5 (404), 고친 뒤 5/5. + +
▶ 1단계 · 새로 만들지 말고 다시 보내기 + +DLQ 항목의 `item`이 원래 요청입니다. `my-intake`가 받는 모양(`{requests: [...]}`)으로 감싸 하나씩 보냅니다. 한 번에 다 보내면 하나가 실패할 때 어느 것인지 가리기 어렵습니다. +
+ +
▶ 2단계 · 성공은 무엇으로 아나 + +`my-intake`의 결과에서 `created`가 1이면 성공입니다 (이미 있던 건이면 `duplicates`도 1). HTTP 상태는 늘 200이라(부분 성공) 상태 코드로는 알 수 없습니다. +
+ +
▶ 3단계 · DLQ가 비었을 때 + +DLQ가 비면 `Pick items`가 항목 0개를 내놓고, n8n은 그 뒤 노드를 돌리지 않습니다. 응답이 나가지 않으면? 비어도 다음으로 가게 하는 설정과 "할 일 없음" 갈래가 필요합니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 웹훅 ② DLQ에서 고르기와 비었을 때 ③ 다시 보내는 곳과 본문 ④ 성공 판단과 지우기 ⑤ 요약 · 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/replay.json 을 새로 만들어 줘. 웹훅 POST ____① . +Get DLQ(HTTP GET http://data-api:8800/dlq) → Pick items(Code): parked 가 아닌 것만, 비어도 다음으로 가게 ____② true. +Has items? 가 없으면 바로 {replayed: 0, ok: 0, still_failed: 0, parked: 0}. +있으면 항목마다 Run intake again: POST http://localhost:5678/webhook/my-intake 본문 {requests: [ ____③ ]} (DLQ 의 원래 요청). +my-intake 결과의 ____④ 가 1 이면 성공 → POST /dlq/update {dlq_id, remove: true}. 실패면 지우지 않는다. +끝에 AGENTS.md 3절 {replayed, ok, still_failed, parked, duplicates}. 기대 결과 cases/lab03.json. +``` + +빈칸 힌트: + +- ① AGENTS.md 3절 재처리 줄의 웹훅 이름입니다. +- ② ▶ 3단계. AGENTS.md 5절 "그 밖"에 있는 노드 설정입니다. +- ③ DLQ 항목에서 원래 요청이 든 칸입니다 (`curl -s localhost:8800/dlq`로 확인). +- ④ ▶ 2단계. +
+ +```bash +uv run tools/flow.py push my-work/replay.json +uv run tools/flow.py test cases/lab03.json -v +uv run tools/flow.py ops +``` + +### 실행 결과 · 재처리 + +수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다. + +고치기 전 + +```text +✔ API가 멈춘 동안 2건 → DLQ · 2.9s +✘ 고친 뒤 다시 돌리기 · 0.0s · status 404 (expected 200); replayed = null (expected 2); ok = null (expected 2); still_failed = null (expected 0) +✘ DLQ가 비었음 · 0.0s · size = 2 (expected 0) +✘ 접수 건은 2개 (중복 없음) · 0.0s · count = 0 (expected 2) +✘ 한 번 더 돌려도 할 일 없음 · 0.0s · status 404 (expected 200); replayed = null (expected 0) +Passed 1/5 +``` + +고친 뒤 + +```text +✔ API가 멈춘 동안 2건 → DLQ · 4.4s +✔ 고친 뒤 다시 돌리기 · 1.1s +✔ DLQ가 비었음 · 0.0s +✔ 접수 건은 2개 (중복 없음) · 0.0s +✔ 한 번 더 돌려도 할 일 없음 · 0.0s +Passed 5/5 +``` + +**왜 이렇게 나왔나** + +- 고치기 전에는 `my-replay` 웹훅이 없어 404. DLQ의 2건은 쌓인 채 접수 건 0개입니다. +- 고친 뒤 1.1초에 `ok: 2`: 두 요청을 `my-intake`로 다시 보내 접수 건을 만들고, DLQ에서 지웠습니다. 다시 보낸 요청도 `my-intake`의 확인 · 분류 · 키 규칙을 똑같이 거쳤습니다. +- 한 번 더 돌리면 DLQ가 비어 `replayed: 0` (0.0초). 비었을 때 갈래가 바로 답했습니다. + +## 예제 2 · 런북과 장애 연습 (12분, 에이전트 없이) + +### 이 실습의 의도 + +**한 줄로:** 장애가 났을 때 할 일을 **런북**으로 써 두고, 짝과 함께 **일부러 장애를 내서** 그 런북이 정말 쓸모 있는지 시험합니다. 워크플로우는 고치지 않습니다(에이전트 없이). + +> **생활 속 예 · 소방 훈련.** 대피 안내문은 붙여 두기만 하면 맞는지 모릅니다. 일부러 경보를 울리고 모두 대피해 봐야 "비상구 앞에 상자가 있다" 같은 문제가 드러납니다. 예제 2는 런북(대피 안내문)을 쓰고, 짝이 일부러 장애를 낸 상태에서 런북만 보고 대응해 보는 훈련입니다. + +**왜 하나.** 지금까지 실패를 모으고(DLQ) 알리고(알림) 다시 돌리는(재처리) 장치를 만들었습니다. 하지만 알림을 받은 **사람**이 무엇을 할지 모르면 장치는 소용없습니다. 장애는 대개 밤에, 만든 사람이 없을 때 납니다. 그때 처음 보는 사람이 따라 할 수 있는 순서가 런북입니다. + +**진행 셋.** + +| 순서 | 누가 | 하는 일 | +| --- | --- | --- | +| 1 · 쓰기 | 각자 | `RUNBOOK.md` 2절 "알림을 받으면": 확인 → 원인 → 고침 → 재처리 → 확인을 **명령 그대로** | +| 2 · 짝 연습 | 한 사람은 장애를 냄, 다른 사람은 **런북만 보고** 대응 | 막힌 곳 · 빠진 단계 · 걸린 시간을 적음 | +| 3 · 자동 연습 | 테스트 | `lab03-drill.json`이 같은 연습을 한 번에 (7/7) | + +**볼 것.** 장애를 내면 알림이 **둘**(high · dlq, critical · unexpected) 생깁니다. 원인이 다른 알림입니다. 고장 스위치를 되돌리고 재처리하면 DLQ는 비지만, 버그 알림은 남습니다. 런북이 이 둘을 나눠 다루는지 확인합니다. + +**끝나면 알게 되는 것.** 런북은 써 두기만 하면 틀린 줄 모르고, 다른 사람이 따라 해 봐야 빈 곳이 보인다는 것 (장애 연습, game day). + +**적을 곳.** `RUNBOOK.md` 2절 (순서), 3절 (연습 기록: 걸린 시간 · 빠진 단계). + +이제 런북 2절 "알림을 받으면"을 순서대로 씁니다. **새벽 3시에 처음 보는 사람**이 따라 할 수 있어야 합니다. 그다음 옆 사람과 짝을 지어, 한 사람이 장애를 내고 다른 사람이 런북만 보고 대응합니다. + +```bash +# 장애를 내는 사람 +uv run tools/flow.py ops --reset +uv run tools/flow.py fault tickets error +uv run tools/flow.py call my-intake requests/batch-two.json +uv run tools/flow.py call nightly-report +# 대응하는 사람: 런북 순서대로 (ops → 원인 → 고침 → 재처리 → 확인) +uv run tools/flow.py ops +uv run tools/flow.py fault tickets ok # "고침" (오늘은 고장 스위치를 되돌리는 것) +uv run tools/flow.py call my-replay +uv run tools/flow.py ops +``` + +마지막으로 같은 연습을 테스트로 돌려 봅니다. + +```bash +uv run tools/flow.py test cases/lab03-drill.json -v +``` + +`RUNBOOK.md` 3절에 걸린 시간을 적습니다. 런북에서 빠진 단계는 무엇이었나요? + +### 이해하기 + +**런북(runbook)은 "그때 할 일을 미리 써 둔 것"입니다.** 장애는 대개 바쁠 때, 밤에, 그 시스템을 만든 사람이 없을 때 납니다. 그때 머릿속으로 떠올리면 단계를 빠뜨립니다. 런북은 알림 하나에 대해 **확인 → 원인 → 고침 → 되돌리기 → 확인**을 명령 그대로 적습니다. 구글 SRE 책은 런북(플레이북)이 있으면 장애에서 회복하는 시간이 크게 줄어든다고 말합니다. + +**좋은 런북의 네 가지.** + +| 갖출 것 | 예 | +| --- | --- | +| 명령을 **그대로 복사**해 쓸 수 있게 | `uv run tools/flow.py ops` (설명 말고 명령) | +| "확인"은 **무엇을 보면 되는지**까지 | "`ops`의 DLQ가 0이고 접수 건 수가 늘었는지" | +| **하지 말 것** | "DLQ를 비우지 말 것 — 재처리할 요청이 사라짐", "parked를 자동으로 돌리지 말 것" | +| **누구 · 언제까지** | parked 건은 누가 몇 시간 안에 보나 (4절) | + +**장애 연습(game day).** 런북은 써 두기만 하면 틀린 줄을 모릅니다. 일부러 장애를 내고 **다른 사람이 런북만 보고** 대응해 봐야 빠진 단계가 드러납니다. Netflix의 Chaos Monkey처럼 운영 중에 일부러 고장을 내는 곳도 있습니다 (카오스 엔지니어링). 오늘은 고장 스위치로 작게 합니다. + +```mermaid +sequenceDiagram + participant A as 장애를 내는 사람 + participant S as 시스템 + participant B as 대응하는 사람 (런북) + A->>S: fault tickets error · my-intake · nightly-report + S-->>B: 알림 high (dlq) · critical (unexpected) + B->>S: ops (확인) + B->>S: fault tickets ok (고침) + B->>S: my-replay (재처리) + B->>S: ops (DLQ 0 · 접수 건 · 중복 없음) +``` + +**테스트 케이스 (`lab03-drill`, 같은 연습을 자동으로).** 평소 3건 → 장애 2건 → DLQ 2 → 보고서 500 → critical 알림 → 고친 뒤 재처리 `ok: 2` → 접수 건 5 (중복 없음). + +**기대 결과.** 7/7. 짝 연습에서 런북에 빠진 단계를 하나 이상 찾아 3절에 적습니다. + +
▶ 1단계 · 알림 둘, 무엇부터 + +`ops`에 알림이 둘(high dlq · critical unexpected) 있습니다. 둘은 같은 원인인가요? `nightly-report`의 critical은 고장 스위치를 되돌려도 남습니다. 런북에서 둘을 어떻게 나눌까요? +
+ +### 실행 결과 · 장애 연습 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다 (줄임). + +```text +$ fault tickets error → call my-intake requests/batch-two.json HTTP 200 · 2.8s ("failed": 2) +$ call nightly-report HTTP 500 · 0.1s +$ ops +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 … D-2 R-302 attempts 1 … +alerts: 2 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] +$ fault tickets ok → call my-replay HTTP 200 · 1.4s +$ ops +created tickets: 2 +DLQ: 0 (parked 0) + +$ test cases/lab03-drill.json +✔ 1 · 평소 · 2.6s +✔ 2 · 장애 시작 · API 오류 · 3.0s +✔ 3 · DLQ 확인 · 0.0s +✔ 4 · 보고서 작업도 실패 · 0.0s +✔ 5 · 알림 확인 · 0.0s +✔ 6 · 고친 뒤 재처리 · 1.4s +✔ 7 · 결과 확인 · 중복 없이 5건 · 0.0s +Passed 7/7 +``` + +**왜 이렇게 나왔나** + +- 장애를 낸 뒤 `ops` 한 번으로 DLQ 2건과 알림 둘이 보였습니다. **원인이 다른 알림 둘**입니다: high는 데이터 API 장애(고장 스위치), critical은 `nightly-report`의 버그입니다. +- 고장 스위치를 되돌리고 재처리하자 DLQ 0 · 접수 건 2. 하지만 critical 알림은 남아 있습니다. 버그는 재처리로 고쳐지지 않습니다 — 런북에 "critical unexpected는 코드 담당에게"가 따로 있어야 합니다. +- 테스트의 접수 건 5건 = 평소 3 + 재처리 2. 같은 연습을 사람이 해도, 테스트가 해도 결과가 같습니다. + +## 예제 3 · 반쯤 처리된 요청 (6분, 에이전트 없이) + +### 이 실습의 의도 + +**한 줄로:** "실패"로 DLQ에 들어갔지만 **사실은 처리된** 요청을 재처리해도, 접수 건이 두 번 생기지 않는지 확인합니다. 고치지 않고(에이전트 없이) 재기만 합니다. + +> **생활 속 예 · 엘리베이터 버튼.** 급한 사람이 버튼을 여러 번 눌러도 엘리베이터는 한 대만 옵니다. 버튼이 "이미 불렀음"을 기억하기 때문입니다. 예제 3은 사실은 처리된 요청을 재처리로 "한 번 더 눌러도" 접수 건이 한 번만 생기는지 확인하는 실습입니다. + +**이어지는 이야기.** Lab 01 예제 3에서 `slow`일 때 `my-intake`는 3초씩 세 번 기다리다 `failed`라고 답했지만, 데이터 API는 8초 뒤 접수 건을 **만들었습니다.** Lab 02부터는 `failed`가 DLQ로 갑니다. 그러면 이 순간 같은 문의가 **두 곳에** 있습니다. + +| 곳 | 상태 | +| --- | --- | +| 데이터 API | 접수 건이 이미 있음 (늦게 만들어짐) | +| DLQ | "실패한 요청"으로 기다림 | + +여기서 예제 1의 재처리를 돌리면 같은 문의를 **또** 보냅니다. 접수 건이 둘이 될까요? + +**시험하는 것.** `slow`로 2건 실패 → DLQ 2 → 8초 뒤 접수 건이 이미 2 → 재처리 → 접수 건은? DLQ는? + +**끝나면 알게 되는 것.** 재시도 · 재처리가 있는 시스템에서는 같은 요청이 **최소 한 번 이상** 갈 수 있고, 그래도 결과가 한 번이 되게 하는 것은 받는 쪽의 멱등 키라는 것 (at-least-once + 멱등). Lab 01 예제 2의 키가 재처리까지 지켜 줍니다. + + +```bash +uv run tools/flow.py test cases/lab03-half.json -v +uv run tools/flow.py ops +``` + +### 이해하기 + +**DLQ의 요청이 꼭 "안 된 것"은 아닙니다.** 시간 초과로 포기한 요청은 사실 **처리됐을 수도** 있습니다. 상대가 늦게 끝냈을 뿐입니다. 그런 요청이 DLQ에 들어가고, 나중에 재처리되면 같은 일을 **두 번** 하게 됩니다. + +```mermaid +sequenceDiagram + participant I as my-intake + participant A as 데이터 API (slow) + participant D as DLQ + participant R as my-replay + I->>A: R-301 (key R-301) + Note over I: 3초 × 3번 timeout → failed + I->>D: R-301 넣기 + A-->>A: 8초 뒤 접수 건 R-301 만들기 + R->>I: R-301 다시 + I->>A: key R-301 + A-->>I: 200 · 처음 번호 · duplicate: true + R->>D: 지우기 +``` + +**최소 한 번 + 멱등 = 결과는 한 번.** 재시도 · 재처리가 있는 시스템은 같은 요청이 **최소 한 번(at-least-once)** 이상 갈 수 있습니다. "정확히 한 번(exactly-once)"을 네트워크 너머에서 보장하기는 매우 어렵습니다. 그래서 실무에서는 **받는 쪽을 멱등하게** 만들어 "여러 번 가도 결과는 한 번"을 만듭니다. 오늘 Lab 01 예제 2의 멱등 키가 재처리까지 지켜 줍니다. + +**테스트 케이스.** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 1 | `tickets slow` · `batch-two` | `failed: 2` (약 12초) | +| 2 | `/dlq/list` | `size: 2` | +| 3 | 8초 기다린 뒤 `ok` · `/tickets-created/list` | `count: 2` (이미 있음) | +| 4 | `my-replay` | `replayed: 2, ok: 2, duplicates: 2` | +| 5 | `/tickets-created/list` | `count: 2` (그대로) | +| 6 | `/dlq/list` | `size: 0` | + +**기대 결과.** 6/6. 재처리가 두 건 모두 "이미 있던 건"(`duplicates: 2`)으로 끝나고 접수 건은 2개 그대로입니다. + +
▶ 1단계 · 키가 없었다면 + +Lab 01 예제 1의 기준처럼 키가 실행 번호였다면, 4번의 재처리 뒤 접수 건은 몇 개일까요? 그 사이 담당자가 첫 번째 접수 건에 답을 달았다면? +
+ +### 실행 결과 · 반쯤 처리된 요청 + +수업 준비 때(10/29) 강사 노트북에서 예제 1 정답으로 실제로 나온 출력입니다. + +```text +✔ 1 · API가 느림 → 기다리다 실패 · 11.6s +✔ 2 · DLQ에 2건 · 0.0s +… before: wait 8s +✔ 3 · 그런데 접수 건은 이미 2개 · 0.0s +✔ 4 · 재처리 · 이미 있는 건이라 duplicate · 1.1s + ← response { "replayed": 2, "ok": 2, "still_failed": 0, "parked": 0, "duplicates": 2 } +✔ 5 · 그래도 2개 (중복 없음) · 0.0s +✔ 6 · DLQ가 비었음 · 0.0s +Passed 6/6 +``` + +**왜 이렇게 나왔나** + +- 1 · 2: `slow`라 3초마다 끊겨 11.6초 뒤 `failed: 2`, 두 요청이 DLQ로. +- 3: 8초 뒤 접수 건이 **이미 2개**입니다. 데이터 API가 늦게라도 처리했습니다. 이 순간 DLQ에도 2건, 접수 건도 2건 — 같은 문의가 두 곳에 있습니다. +- 4 · 5: 재처리가 두 건을 다시 보냈고, 키(`R-301` · `R-302`)가 같아 데이터 API가 처음 번호를 돌려줬습니다 (`duplicates: 2`). 접수 건은 2개 그대로, DLQ는 0. **반쯤 처리된 요청도 재처리가 안전한 것은 키 덕분입니다.** + +## 과제 1 · 세 번 실패하면 멈추기 (10분) + +### 이 실습의 의도 + +**한 줄로:** 재처리해도 계속 실패하는 요청은 **세 번에서 멈추고**(`parked`), 사람에게 넘깁니다. + +> **생활 속 예 · 비밀번호 잠금.** 은행 앱은 비밀번호를 5번 틀리면 더 시도하게 두지 않고 잠근 뒤, 창구에서 사람이 확인하게 합니다. 과제 1은 세 번 재처리해도 실패한 요청을 잠그고(`parked`) 사람에게 넘기는 실습입니다. + +**상황.** 예제 1의 재처리는 실패하면 DLQ에 그대로 두고, 다음 재처리에서 또 시도합니다. 원인이 금방 고쳐지면 좋지만, 하루 종일 고쳐지지 않거나 요청 자체가 영영 처리되지 않는 것이라면? 재처리를 돌릴 때마다 같은 요청이 또 실패하고, API를 두드리고, 알림이 쌓이고, 새로 들어온 실패가 묻힙니다. + +| | 지금 | 바라는 것 | +| --- | --- | --- | +| 세 번째 재처리 | 또 보냄 → 또 실패 (끝없이) | 다시 보내지 않고 `parked: true` + `high · parked` 알림 | +| parked 항목 | — | DLQ에 남기고(지우지 않음), 다음 재처리도 건너뜀 | +| 원인을 고친 뒤 | — | parked는 **자동으로 돌리지 않음** (사람이 보고 결정) | + +**`attempts`는 어디서 오나.** DLQ는 같은 요청이 다시 들어오면 새 줄 대신 `attempts`를 올립니다 (Lab 02 예제 1). 그 수를 보고 멈춥니다. + +**끝나면 알게 되는 것.** 자동 재처리에도 끝이 있어야 한다는 것 (SQS의 최대 받기 횟수), parked는 포기가 아니라 "자동은 여기까지, 이제 사람이"라는 것, 간격 × 횟수 = 포기하기까지의 시간. + +> "API가 하루 종일 멈춘 날, 재처리를 돌릴 때마다 같은 요청이 또 실패하고 또 실패했어요. 계속 실패하는 건 따로 빼서 사람이 보게 해 주세요." + +```bash +uv run tools/flow.py test cases/lab03-challenge.json -v # 고치기 전 +``` + +| 조건 | 값 | +| --- | --- | +| 기준 | `attempts`가 `max_attempts`(본문, 기본 3) 이상이면 다시 보내지 않고 `parked: true` (`POST /dlq/update`) | +| 알림 | parked 할 때 `{workflow: "replay", severity: "high", reason: "parked", dlq_id, message}` | +| parked는 | 다음 재처리에서도 건너뜀 (사람이 보고 결정), DLQ에서 지우지 않음 | +| 기대 결과 | `cases/lab03-challenge.json` (재처리 1 · 2는 또 실패, 3에서 parked 2), `lab03` 그대로 | + +### 이해하기 + +**끝없이 다시 하면 독이 됩니다.** 원인이 고쳐지지 않은 요청을 계속 다시 보내면 매번 API를 두드리고, 알림이 쌓이고, 정말 새로 들어온 실패가 묻힙니다. 메시지 시스템들은 이것을 "최대 받기 횟수"(Amazon SQS의 `maxReceiveCount`)로 막습니다: 그 수를 넘으면 더 시도하지 않고 따로 옮깁니다. 오늘은 DLQ 안에서 `parked`로 표시합니다. + +```mermaid +flowchart LR + G[("DLQ")] --> P{"parked?"} + P -->|예| S["건너뜀"] + P -->|아니오| A{"attempts ≥ 3?"} + A -->|아니오| R["my-intake로 다시"] + A -->|예| K[("parked: true")] --> L[("알림 high · parked")] + R & K --> M["모아서 요약
replayed · parked"] +``` + +**사람에게 넘기기 (human in the loop).** parked는 "포기"가 아니라 "**자동으로는 여기까지, 이제 사람이**"입니다. 요청 자체가 이상한지(데이터 API가 영영 받지 않는 글), 데이터 API의 다른 문제인지, 사람이 보고 고친 뒤 직접 다시 돌립니다. 그래서 parked 항목은 지우지 않고, 런북 4절에 "누가 · 언제까지"를 적습니다. + +**몇 번이 좋은가.** 3은 오늘 정한 숫자입니다. 재처리를 1분마다 자동으로 돌린다면 3번은 3분, 하루에 한 번 돌린다면 3일입니다. **간격 × 횟수 = 포기하기까지 걸리는 시간**으로 생각합니다 (바이브 코딩 `tretry`의 backoff · 마감과 같은 생각). + +**테스트 케이스.** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 처음 실패 | `tickets error` · `batch-two` | `failed: 2` (attempts 1) | +| 재처리 1 · 2 | `my-replay` 두 번 (여전히 error) | `replayed: 2, still_failed: 2, parked: 0` (attempts 2 · 3) | +| 재처리 3 | `my-replay` | `replayed: 0, parked: 2` | +| DLQ | `/dlq/list` | `size: 2, parked: 2` | +| 알림 | `/alerts/latest` | `high · parked` | +| 고친 뒤 | `ok` · `my-replay` | `replayed: 0` (parked는 자동으로 돌리지 않음) | + +**기대 결과.** 고치기 전 3/7, 고친 뒤 7/7. + +**먼저 생각해 보기 (2분)**: 재처리를 1분마다 자동으로 돌린다면 몇 번이 좋을까요? 데이터 API 담당자가 출근하기까지 얼마나 걸리나요? + +
▶ 1단계 · 두 갈래 + +DLQ 항목을 "다시 보낼 것"과 "멈출 것"으로 나눈 뒤, 두 갈래를 끝에서 모아 요약합니다 (Lab 01의 Merge). +
+ +
▶ 2단계 · 사람에게 넘기기 + +parked 항목은 지우지 않습니다. 런북에 "parked 건은 누가, 언제까지, 무엇을 보고 결정하나"를 적습니다 (4절). +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 기준 수와 어디서 읽나 ② 나누기 ③ 멈출 때 하는 두 가지 ④ 모으기와 요약 ⑤ 지우지 않기 ⑥ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/replay.json 의 Pick items 에서 body.max_attempts(없으면 ____① ) 를 읽어, +attempts 가 그 수 ____② 인 항목은 action "park", 나머지는 "replay" 로 나눠 줘. +Replay it?(If): replay 는 지금처럼 다시 보내고, park 는 POST /dlq/update {dlq_id, parked: ____③ , reason} 뒤 +POST /alerts {workflow: "replay", severity: "high", reason: "____④", dlq_id, message}. +두 갈래를 Merge 로 모아 요약의 parked 에 수를. parked 항목은 지우지 않는다. +기대 결과 cases/lab03-challenge.json, cases/lab03.json. +``` + +빈칸 힌트: + +- ① 위 조건 표의 기본값입니다. +- ② "3번 실패한 것"이 멈추려면 같음을 포함하는지 생각합니다 (재처리 3에서 attempts는 3). +- ③ DLQ 항목의 칸 값입니다 (참/거짓). +- ④ 위 조건 표의 알림 reason 그대로입니다. +
+ +```bash +uv run tools/flow.py push my-work/replay.json +uv run tools/flow.py test cases/lab03-challenge.json -v +uv run tools/flow.py test cases/lab03.json +uv run tools/flow.py ops +``` + +### 실행 결과 · 세 번 실패하면 멈추기 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다. + +고치기 전 (예제 1 정답) + +```text +✔ API가 오래 멈춤 · 처음 실패 · 3.4s +✔ 재처리 1 · 또 실패 · 2.2s +✔ 재처리 2 · 또 실패 · 2.4s +✘ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 2.0s · replayed = 2 (expected 0); parked = 0 (expected 2) +✘ parked로 남음 · 0.0s · parked = 0 (expected 2) +✘ 멈춘 것은 알림 · 0.0s · reason = dlq (expected parked) +✘ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 1.6s · replayed = 2 (expected 0) +Passed 3/7 +``` + +고친 뒤 + +```text +✔ API가 오래 멈춤 · 처음 실패 · 4.3s +✔ 재처리 1 · 또 실패 · 1.9s +✔ 재처리 2 · 또 실패 · 2.1s +✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s +✔ parked로 남음 · 0.0s +✔ 멈춘 것은 알림 · 0.0s +✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s +Passed 7/7 +(lab03 5/5) +``` + +**왜 이렇게 나왔나** + +- 고치기 전 재처리 3도 두 건을 또 보냈습니다 (2.0초, `replayed: 2`). 몇 번을 돌려도 같습니다. 마지막 알림은 `my-intake`의 `dlq`라 parked 알림이 없고, 고친 뒤 재처리는 사람이 보기 전에 자동으로 보내 버렸습니다. +- 고친 뒤 재처리 3은 **0.1초**: `attempts`가 3이 된 두 건을 다시 보내지 않고 parked로 표시하고 high 알림을 남겼습니다. DLQ에는 `parked: 2`로 남아 사람을 기다립니다. +- 고친 뒤 재처리도 `replayed: 0`: parked는 자동으로 돌리지 않습니다. `lab03` 5/5로 평소 재처리는 그대로입니다. + +## 과제 2 · 운영 상태 한눈에 (10분) + +### 이 실습의 의도 + +**한 줄로:** 주소 하나를 부르면 지금 상태를 **신호등 셋**(ok · warn · critical)과 **까닭**으로 알려 주는 웹훅을 만듭니다. + +> **생활 속 예 · 미세먼지 앱.** 측정값 여럿을 늘어놓지 않고 "나쁨 · 마스크를 쓰세요"를 먼저 보여 줍니다. 과제 2는 DLQ · parked · 알림 숫자를 신호등(`ok` · `warn` · `critical`)과 할 일(까닭)로 줄여 주는 웹훅을 만드는 실습입니다. + +**상황.** 지금 상태를 알려면 `ops`를 치고 DLQ · parked · 알림을 하나하나 읽어 판단해야 합니다. 만든 사람은 할 수 있지만, 처음 보는 사람은 "그래서 괜찮은 거야?"를 모릅니다. 오늘 하루 만든 것(DLQ · parked · 알림)을 **판단 하나로 줄여** 줍니다. + +| 신호 | 언제 | 사람이 할 일 | +| --- | --- | --- | +| `ok` | 아무 일 없음 | 없음 | +| `warn` | 기다리는 실패(DLQ) · 10분 안 high 알림 | 원인을 고친 뒤 재처리 | +| `critical` | parked · 10분 안 critical 알림 | 지금 봄, 담당에게 | + +**지킬 것.** 상태를 묻는 것이 상태를 바꾸면 안 됩니다 (**읽기만**). 그리고 신호만이 아니라 까닭(`reasons`)을 함께 줘서, 런북의 어느 줄로 갈지 바로 알게 합니다. + +**시험하는 것.** 처음 `ok` → 접수 실패 뒤 `warn` → 버그 알림 뒤 `critical` → 재처리하고 알림을 비운 뒤 다시 `ok`. 신호가 실제 상태를 따라가는지. + +**끝나면 알게 되는 것.** "살아 있나"(`/health`)와 "일이 잘 되나"는 다르다는 것, 여러 숫자를 사람이 읽을 판단으로 줄이는 법. 런북 2절 1번을 "`my-ops-status`부터"로 바꿀 수 있습니다. + +> "알림이 오면 `ops` 명령을 치고 알림 · DLQ를 하나하나 읽어야 해서, 처음 보는 사람은 지금 괜찮은 건지 아닌지를 몰라요. 주소 하나를 부르면 '괜찮다 · 주의 · 위험'과 그 까닭을 바로 알려 주세요. 나중에 대시보드에도 붙이려고요." + +```bash +uv run tools/flow.py test cases/lab03-challenge2.json -v # 고치기 전 +``` + +| 조건 | 값 | +| --- | --- | +| 파일 · 웹훅 | `my-work/ops-status.json` · `POST my-ops-status` | +| `critical` | parked가 1 이상이거나, 10분 안 critical 알림이 있음 | +| `warn` | parked가 아닌 DLQ가 있거나, 10분 안 high 알림이 있음 | +| `ok` | 둘 다 아님 | +| 결과 | AGENTS.md 3절 `{status, dlq, parked, created, alerts: {critical, high}, reasons: [...]}` | +| 지킬 것 | **읽기만** (DLQ · 알림을 바꾸지 않음) | +| 기대 결과 | `cases/lab03-challenge2.json` | + +### 이해하기 + +**상태 확인 (health check · status page).** 운영 중인 서비스는 대개 "지금 괜찮은가"를 알려 주는 주소를 둡니다. 쿠버네티스는 그 주소로 컨테이너가 살아 있는지 보고, 많은 회사가 고객용 상태 페이지(status page)를 둡니다. 오늘은 사람이 보는 판입니다: 여러 숫자를 **세 단계 신호등**과 **까닭**으로 줄입니다. + +| 신호 | 뜻 | 런북 | +| --- | --- | --- | +| `ok` | 할 일 없음 | — | +| `warn` | 기다리는 실패가 있음 (DLQ) | 원인을 고친 뒤 재처리 | +| `critical` | 사람이 지금 봐야 함 (parked · critical 알림) | 1차 대응 · 담당에게 | + +```mermaid +flowchart LR + W["my-ops-status"] --> D[("GET /dlq")] --> A[("POST /alerts/list
within 600")] --> T[("GET /tickets-created")] --> S["Decide status"] --> O(["status · reasons"]) +``` + +**읽기만 하는 확인.** 상태를 묻는 것이 상태를 바꾸면 안 됩니다. Lab 02에서 테스트가 `/alerts`에 POST해서 빈 알림을 만든 일이 있었습니다 (`/alerts/list`를 쓰는 까닭). 상태 웹훅은 몇 번을 불러도 DLQ · 알림이 그대로여야 합니다. + +**까닭을 함께.** "`critical`"만 있으면 사람은 다시 `ops`를 칩니다. "`parked 2건: 사람이 보고 결정`"처럼 **무엇을 해야 하는지**까지 알려 주면, 런북의 어느 줄로 갈지 바로 압니다. + +**테스트 케이스.** + +| 케이스 | 하는 것 | 기대 | +| --- | --- | --- | +| 1 | (처음) | `ok`, `dlq: 0` | +| 2 · 3 | `tickets error` · `my-intake` 2건 실패 → 상태 | `warn`, `dlq: 2`, 까닭에 `재처리` | +| 4 · 5 | `nightly-report` 500 → 3초 뒤 상태 | `critical`, `alerts.critical: 1` | +| 6 · 7 | 재처리 · 알림 확인 뒤 비움 → 상태 | `ok`, `dlq: 0, created: 2` | + +**기대 결과.** 고치기 전 3/7 (상태 케이스 넷이 404), 고친 뒤 7/7. + +
▶ 1단계 · 순서가 있는 규칙 + +`critical` 조건과 `warn` 조건이 둘 다 맞으면? 더 나쁜 쪽을 먼저 봅니다. 까닭(`reasons`)은 맞는 것을 **모두** 적습니다. +
+ +
▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대 + +요청문에 꼭 들어갈 것: **① 웹훅 ② 읽을 곳 셋 ③ 규칙과 순서 ④ 결과 모양 ⑤ 읽기만 ⑥ 기대 결과**. 빈칸 4개를 채웁니다. + +```text +my-work/ops-status.json 을 새로 만들어 줘. 웹훅 POST ____① . +Get DLQ(GET /dlq) → Recent alerts(POST /alerts/list {within: ____② }) → Get created(GET /tickets-created) → Decide status(Code). +parked 가 1 이상이거나 10분 안 critical 알림이 있으면 "critical", parked 가 아닌 DLQ 가 있거나 high 알림이 있으면 " ____③ ", 아니면 "ok". +결과 {status, dlq(parked 가 아닌 수), parked, created, alerts: {critical, high}, reasons: [한국어로 까닭]}. +읽기만 한다: 이 워크플로우에서 DLQ · 알림을 ____④ . 기대 결과 cases/lab03-challenge2.json. +``` + +빈칸 힌트: + +- ① 위 조건 표의 웹훅입니다. +- ② 10분을 초로 (Lab 02 과제 2와 같음). +- ③ 위 조건 표의 가운데 신호입니다. +- ④ "읽기만"을 다르게 말한 것입니다. +
+ +```bash +uv run tools/flow.py push my-work/ops-status.json +uv run tools/flow.py test cases/lab03-challenge2.json -v +uv run tools/flow.py test cases/lab03-challenge.json +uv run tools/flow.py call my-ops-status +uv run tools/flow.py ops --reset +``` + +마지막으로 `RUNBOOK.md` 전체, [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웁니다. 런북 2절 1번에 "`my-ops-status`부터"를 넣어도 됩니다. + +### 실행 결과 · 운영 상태 한눈에 + +수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다. + +고치기 전 + +```text +✘ 1 · 아무 일 없음 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0) +✔ 2 · (접수 실패 2건) · 2.9s +✘ 3 · DLQ가 있으면 warn · 0.0s · status 404 (expected 200); ... +✔ 4 · (보고서 버그) · 0.0s +✘ 5 · critical 알림이 있으면 critical · 0.0s · status 404 (expected 200); ... +✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.1s +✘ 7 · 고친 뒤 → ok · 0.0s · status 404 (expected 200); ... +Passed 3/7 +``` + +고친 뒤 + +```text +✔ 1 · 아무 일 없음 → ok · 0.9s +✔ 2 · (접수 실패 2건) · 3.7s +✔ 3 · DLQ가 있으면 warn · 0.1s +✔ 4 · (보고서 버그) · 0.1s +… before: wait 3s +✔ 5 · critical 알림이 있으면 critical · 0.1s +✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.2s +✔ 7 · 고친 뒤 → ok · 0.0s +Passed 7/7 +(lab03-challenge 7/7) + +$ call my-ops-status (lab02-challenge2 바로 뒤, 비우기 전) +HTTP 200 · 0.1s +{ "status": "critical", "dlq": 5, "parked": 0, "created": 2, "alerts": { "critical": 1, "high": 1 }, + "reasons": [ "critical 알림 1건 (10분)", "DLQ 5건: 원인을 고친 뒤 재처리", "high 알림 1건 (10분)" ] } +``` + +**왜 이렇게 나왔나** + +- 고치기 전에는 웹훅이 없어 상태 케이스 넷이 404. 상태를 바꾸는 케이스 셋(실패 · 버그 · 재처리)만 ✔. +- 고친 뒤 `ok` → 실패 2건 뒤 `warn`(DLQ 2, "원인을 고친 뒤 재처리") → 버그 뒤 `critical` → 재처리하고 알림을 확인(비움)한 뒤 다시 `ok`. 신호가 실제 상태를 따라갑니다. +- 마지막 `call`은 Lab 02 과제 2 테스트가 남긴 상태를 그대로 읽었습니다: DLQ 5건에 `dlq_down` critical 알림 → `critical`, 까닭 셋. 숫자 여럿을 읽지 않아도 무엇부터 할지 보입니다. +- 상태 웹훅은 0.1초 안팎: 데이터 API를 세 번 읽기만 하고 LLM을 부르지 않습니다. + +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| 재처리했는데 DLQ가 그대로 | 성공을 무엇으로 판단하나 (`my-intake` 결과의 `created`), 지울 때 `dlq_id` | +| 접수 건이 늘어남 (중복) | `my-intake`의 `key`가 요청 `id`인지 | +| DLQ가 비었을 때 응답이 없음 | 빈 결과에서도 다음으로 (`alwaysOutputData`), "할 일 없음" 갈래 | +| `attempts`가 안 오름 | DLQ에 넣을 때 `item.id`가 같은지 | +| 재처리 3에서도 다시 보냄 | `attempts >= max`인지 (`>`면 4번째에야 멈춤) | +| 상태가 늘 `critical` | 앞 테스트의 알림 · DLQ가 남았는지 (`ops --reset`), `within`이 초 단위인지 | +| `lab03-half`의 3번이 `count: 0` | 테스트 중에 데이터 API를 다시 켜면(재시작) 늦게 만들 요청이 사라짐 → 처음부터 다시 | +| 계속 안 됨 | `cp reference/lab03-example1-replay.json my-work/replay.json` 후 push (예제 1까지) | + +## 확인 + +- [ ] 예제 1: `cases/lab03.json` 5/5 (중복 없음). +- [ ] 예제 2: 런북으로 짝 연습, `cases/lab03-drill.json` 7/7, 걸린 시간 · 빠진 단계. +- [ ] 예제 3: `cases/lab03-half.json` 6/6, `duplicates: 2`를 봤다. +- [ ] 과제 1: `cases/lab03-challenge.json` 7/7, `lab03`도 통과. +- [ ] 과제 2: `cases/lab03-challenge2.json` 7/7. +- [ ] `RUNBOOK.md`, `TEST-REPORT.md`, 끝나면 `ops --reset`. diff --git a/Last3Mon/76/LECTURE-76.md b/Last3Mon/76/LECTURE-76.md new file mode 100644 index 0000000..fab4b97 --- /dev/null +++ b/Last3Mon/76/LECTURE-76.md @@ -0,0 +1,180 @@ +# 76일차 수강 안내 · 수업 준비부터 마칠 때까지 + +- 일자: 2026-10-30 (금) · 76일차 +- 주제: 에러 처리와 예외 대응, 안정적 운영 체계 (실패의 종류 · 모으고 알리기 · 재처리와 런북) +- 실습 폴더: [Last3Mon/76](README.md) · 강의 자료: [Lecture/76](../../Lecture/76/README.md) +- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`, 분류) + **76일차 판 데이터 API**(75일차 + 접수 건 만들기 · DLQ · 알림 · 고장 스위치) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.** +- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요. + +```mermaid +flowchart LR + P["전날
준비 · base-intake ·
nightly-report 게시"] --> M["아침
켜기 · doctor"] --> L1["1교시 강의 · 2교시
Lab 01 실습 5개
나누기 · 재시도 · 키"] --> L2["3교시 강의 · 4교시
Lab 02 실습 5개
DLQ · Error Workflow · 알림"] --> L3["5교시 강의 · 6교시
Lab 03 실습 5개
재처리 · 런북 · parked · 상태"] --> V["7~8교시
tretry"] --> E["마친 뒤
ops --reset · 77 준비"] +``` + +## 0. 하루 한눈에 + +| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 | +| --- | --- | --- | --- | --- | +| 전날 | 약 10분 | 자료 받기 · n8n · 76일차 데이터 API · `base-intake` · `nightly-report` 게시 · doctor | [PREP](../../Lecture/76/00-DAY76-PREP.md) | `ready` | +| 1 | 09:00~09:50 | 개요 · 1단계 강의 (실패의 종류) | [개요](../../Lecture/76/00-DAY76-OVERVIEW.md) 1~14장, [1단계](../../Lecture/76/01-STAGE1-FAILURE-TYPES.md) | | +| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-FAILURE-TYPES.md) | `lab01-challenge` 3/3 · `lab01-challenge2` 4/4 | +| 3 | 11:00~11:50 | 2단계 강의 (모으고 알리기) | 개요 15~21장, [2단계](../../Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md) | | +| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-COLLECT-AND-ALERT.md) | `lab02-challenge` 6/6 · `lab02-challenge2` 6/6 | +| - | 13:00~14:00 | 점심 | | | +| 5 | 14:00~14:50 | 3단계 강의 (재처리와 운영 체계) | 개요 22~28장, [3단계](../../Lecture/76/03-STAGE3-REPLAY-AND-RUNBOOK.md) | | +| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-REPLAY-AND-RUNBOOK.md) | `lab03-challenge` 7/7 · `lab03-challenge2` 7/7, `RUNBOOK.md` | +| 7 | 16:00~16:50 | 바이브 코딩 `tretry`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TRETRY](VIBE-REQ-TRETRY.md) → [VIBE-Q-TRETRY](VIBE-Q-TRETRY.md) | `Doc/*.md` · R1~R6 · R8 · R11~R14 · `pytest` | +| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 77일차 예고 | [4단계 정리](../../Lecture/76/04-STAGE4-WRAPUP.md), [진행](../../Lecture/76/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 | + +## 1. 전날 준비 (약 10분) + +### 1.1 자료 받기 + +```bash +cd ~/aio-01d4-stu && git pull +``` + +### 1.2 n8n과 **76일차** 데이터 API 켜기 (Ollama는 끔) + +```bash +sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔 +cd ~/aio-01d4-stu/Last3Mon/65/n8n +docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감) +docker compose start +cd ~/aio-01d4-stu/Last3Mon/76/data-api +docker compose up -d --force-recreate # 75일차 판을 76일차 판(+ 접수 건 만들기 · DLQ · 알림)으로 바꿈 +sleep 3 # 데이터 API가 뜰 때까지 +curl -s localhost:8800/dlq # {"size": 0, "parked": 0, "items": []} +``` + +- 75일차 연습을 되돌리지 않았어도 괜찮습니다: 데이터 API를 새로 띄우면 예산 · 사용량 기록 · 고장 스위치가 처음으로 돌아갑니다. + +### 1.3 기준 게시 · 준비 확인 + +```bash +cd ~/aio-01d4-stu/Last3Mon/76 +uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json +uv run tools/flow.py call base-intake requests/batch-ok.json # processed 3 이면 됨 +uv run tools/flow.py ops --reset +uv run tools/flow.py doctor # 마지막 줄 ready +``` + +### 1.4 미리 읽기 (선택, 5분) + +[PREP 3절](../../Lecture/76/00-DAY76-PREP.md): 500을 받은 사용자는 무엇을 할까? 그때 서버에는 무엇이 남아 있을까? 그리고 7교시 [요청서](VIBE-REQ-TRETRY.md). + +### 준비물 + +노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저 (n8n 화면 Executions를 열어 봄), 짝 연습을 할 옆 사람. + +## 2. 수업 당일 아침 (09:00 전) + +1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (접수 건 · DLQ · 알림은 처음으로 돌아갑니다 — 괜찮음). +2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/76`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`call`·`push`·`test`·`ops`·`fault`). +3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`, `uv run tools/flow.py ops`. +4. `cp templates/RUNBOOK-TEMPLATE.md my-work/RUNBOOK.md`, `my-work/PROMPTS.md`. + +## 3. 실습할 때마다 같은 순서 (오늘은 "망가뜨려 보기 → 고치기 → 다시 망가뜨려 확인") + +```mermaid +flowchart LR + A["① 망가뜨려 보기
fault · llm down · test(전)"] --> B["② 요청문을
메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가
my-work · check"] --> E["⑤ 내가
push · test -v · ops"] + E -->|✘| F["⑥ 실패 줄 · 보고서 경로
그대로 붙여 주기"] --> C + E -->|✔| G["⑦ RUNBOOK.md 한 줄"] +``` + +- 고장 스위치는 **내가** `flow.py fault`로 켜고 끕니다. 테스트 파일은 자기가 켠 고장을 끝에서 되돌립니다. 손으로 켰으면 끝나면 `uv run tools/flow.py fault tickets ok` (또는 `ops --reset`). +- `ops` 한 번으로 고장 스위치 · 접수 건 · DLQ · 알림이 보입니다. 테스트 사이마다 봅니다. +- `llm down`(Lab 01 과제 1)은 테스트가 끝에서 `llm up`으로 되돌립니다. 중간에 멈췄다면 **반드시** `uv run tools/flow.py llm up`. + +## 4. 1~2교시 · 1단계 · 실패의 종류 + +1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-FAILURE-TYPES.md) (50분, 실습 5개). + +| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) | +| --- | --- | --- | --- | +| 예제 1 (10분, 에이전트 없이) | 기준을 망가뜨려 보기: 빈 글 · `flaky` · 다시 보내기 | `base-intake` | `call base-intake requests/batch-*.json`, `ops`, `fault tickets flaky` · `ok` → `RUNBOOK.md` 1.1 | +| 예제 2 (15분) | 항목 확인 · 재시도 3 · 키 = 요청 id · 부분 성공 200 | `cp reference/baseline-intake.json my-work/intake.json` → 웹훅 `my-intake`로 | `test cases/lab01.json --webhook base-intake -v` (전), `push my-work/intake.json` → `test cases/lab01.json -v` | +| 예제 3 (7분, 에이전트 없이) | 고장 종류마다 재시도: `flaky` · `error` · `slow`, 늦게 생긴 접수 건 | (그대로) | `test cases/lab01-faults.json -v`, `ops` → `RUNBOOK.md` 1.2 | +| 과제 1 (8분) | LLM이 멈춰도 미분류 · 보통으로 접수 | `my-work/intake.json` 고치기 | `test cases/lab01-challenge.json -v` (자동으로 `llm down` · `up`), `lab01` | +| 과제 2 (10분) | 같은 id의 두 번째는 거절, 10건 넘으면 400 | `my-work/intake.json` 고치기 | `test cases/lab01-challenge2.json -v`, `lab01` · `lab01-challenge` | + +(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.) + +## 5. 3~4교시 · 2단계 · 모으고 알리기 + +3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-COLLECT-AND-ALERT.md) (50분, 실습 5개). + +| 순서 | 할 일 | 확인 | +| --- | --- | --- | +| 예제 1 (12분) | 먼저 겪기: `fault tickets error` → `call my-intake` → `ok` → `ops` (DLQ 0). 그다음 실패한 요청은 원래 그대로 DLQ에 | `test cases/lab02.json -v` (전 · 후), `ops` | +| 예제 2 (12분) | 에러 처리기 `errHandler76001` (Error Trigger → critical 알림), 내 접수에 `errorWorkflow` | `push my-work/error-handler.json my-work/intake.json`, `test cases/lab02.json -v`, `ops`, `lab01` | +| 예제 3 (6분, 에이전트 없이) | 실패 넷이 어느 그물에: 빈 글 · API 오류 · 버그 · **DLQ도 멈춤** | `test cases/lab02-nets.json -v` (5/7이 맞음), `ops` → `RUNBOOK.md` 1.3 | +| 과제 1 (10분) | DLQ에 넣었으면 high 알림, 못 넣었으면 critical `dlq_down` + 잃을 뻔한 id, 결과에 `dlq_saved` · `alert` | `test cases/lab02-challenge.json -v`, `lab02-nets` 7/7, `lab02` | +| 과제 2 (10분) | 같은 이유의 high 알림은 10분에 한 번 (`suppressed`), critical은 늘 | `test cases/lab02-challenge2.json -v`, `lab02-challenge`, `lab01-challenge2`, `ops --reset` | + +## 6. 5~6교시 · 3단계 · 재처리와 운영 체계 + +5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-REPLAY-AND-RUNBOOK.md) (50분, 실습 5개). + +| 순서 | 할 일 | 확인 | +| --- | --- | --- | +| 예제 1 (12분) | 재처리 `my-replay`: DLQ의 요청을 `my-intake`로 하나씩, 성공하면 지우기 | `test cases/lab03.json -v` (전 · 후), `ops` | +| 예제 2 (12분, 에이전트 없이) | `RUNBOOK.md` 2절을 쓰고 짝 연습: 한 사람이 장애를 내고 다른 사람이 런북만 보고 대응 | `fault tickets error` · `call my-intake` · `call nightly-report` → `ops` → `fault tickets ok` → `call my-replay` → `ops`, `test cases/lab03-drill.json -v` → `RUNBOOK.md` 3절 | +| 예제 3 (6분, 에이전트 없이) | 반쯤 처리된 요청: `slow`로 실패 → 늦게 생긴 접수 건 → 재처리는 `duplicates` | `test cases/lab03-half.json -v`, `ops` | +| 과제 1 (10분) | `attempts` 3 이상이면 다시 보내지 않고 parked + high 알림 | `test cases/lab03-challenge.json -v`, `lab03` | +| 과제 2 (10분) | 운영 상태 `my-ops-status`: ok · warn · critical과 까닭 (읽기만) | `test cases/lab03-challenge2.json -v`, `lab03-challenge`, `call my-ops-status`, `ops --reset` | + +- 기록: `RUNBOOK.md` 전체 (4절 누가 · 언제까지), `TEST-REPORT.md`. + +## 7. 7~8교시 · 바이브 코딩 `tretry` + +[VIBE-REQ-TRETRY](VIBE-REQ-TRETRY.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TRETRY](VIBE-Q-TRETRY.md)의 순서대로 갑니다: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용. 요구는 14개(R1~R14)이고 50분 안에는 엔진 1 · 2단계 · TUI 1단계 · 설치까지, 나머지(R7 마감 · R9 순위 · R10 회로 차단)는 `Doc/IMP-PLAN.md`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 아무 명령이나 HTTP 호출을 감싸 다시 하기(backoff · 상한 · 흔들기) · 시간 제한 · 대체를 붙이고, 고칠 수 없는 실패는 다시 하지 않고, 같은 멱등 키를 붙이고, 기록 · 순위를 TUI로 보는 도구. 정답본 `VIBE-A-TRETRY.md`는 수업이 끝나면 `git pull`로 받습니다. + +```bash +mkdir -p ~/vibe/tretry/Doc && cd ~/vibe/tretry +git init +# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy) +``` + +| 시간 | 단계 | 확인 | +| --- | --- | --- | +| 16:00~16:09 | 0 준비 · 1 요청서 → `Doc/TODO.md` (에이전트 없이) | 할 일 ①~⑫ · 꼭 / 나중 | +| 16:09~16:15 | 2 `Doc/SPEC.md` → VIBE-Q 요구 표(R1~R14)와 비교 · commit | 요구마다 확인 방법 (시도 수 · 기다린 시간 · 종료 코드) | +| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~3단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 | +| 16:20~16:44 | 4 구현: 엔진 1단계(R1~R4 · R12 · R13) → 엔진 2단계(R5 · R6 · R8) → TUI 1단계(R11), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit | +| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tretry` | 데이터 API `flaky`에서 `tretry http POST localhost:8800/tickets ... --idem-key x --body-key key` | + +- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/76/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 77일차 예고. + +## 8. 마친 뒤 + +| 할 일 | 이유 | +| --- | --- | +| `uv run tools/flow.py ops --reset`, `llm up`(혹시 모르니), `doctor` → `ready` | 내일 처음 상태로 | +| `PROMPTS.md`, `RUNBOOK.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (장애 대응 절차) | +| `my-work/*.json`, `~/vibe/tretry` 지우지 않기 | 77일차 로그 분석 · 79일차 `tops` | +| 77일차 준비: [00-DAY77-PREP](../../Lecture/77/00-DAY77-PREP.md) | 실행 기록 · 로그 분석 | + +## 9. 막혔을 때 (오늘 공통) + +| 증상 | 할 일 | +| --- | --- | +| doctor의 `dead letter queue /dlq · alerts · fault switch dlq` ✘ | 데이터 API가 예전 판. 1.2의 76일차 `--force-recreate` · `sleep 3` | +| doctor에 `fault switch is on` · `DLQ is not empty` | `uv run tools/flow.py ops --reset` | +| `check`가 `retryOnFail ... must be at the node level` | 재시도 설정을 `parameters` 밖, 노드 바로 아래로 | +| 테스트 뒤에도 분류가 모두 실패 | `uv run tools/flow.py llm up` | +| 알림이 하나도 안 생김 | 에러 처리기를 push(게시)했는지, 파일의 `"id": "errHandler76001"` | +| 늘 `suppressed` · 늘 `critical` | 앞 테스트의 알림이 남음 → `ops --reset` | +| `call my-intake`가 404 | 복사한 파일의 웹훅 경로를 바꿨는지, push했는지 | +| `slow` 테스트가 오래 걸림 | 정상 (한 케이스 10~12초) | +| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 | + +## 확인 + +- [ ] 전날 76일차 데이터 API, `base-intake` · `nightly-report` 게시, `doctor` → `ready` +- [ ] Lab 01: `RUNBOOK.md` 1.1 · 1.2, `lab01` 6/6, `lab01-faults` 5/5, `lab01-challenge` 3/3, `lab01-challenge2` 4/4 +- [ ] Lab 02: `lab02` 4/4, `lab02-nets` 5/7 → 7/7, `lab02-challenge` 6/6, `lab02-challenge2` 6/6, `RUNBOOK.md` 1.3 · 1.4 +- [ ] Lab 03: `lab03` 5/5, 짝 연습 · `lab03-drill` 7/7, `lab03-half` 6/6, `lab03-challenge` 7/7, `lab03-challenge2` 7/7, `RUNBOOK.md` 2~4절 +- [ ] `tretry`: `Doc/TODO.md` · `SPEC.md` · `IMP-PLAN.md` 먼저, 엔진 1 · 2단계 · TUI 1단계, `pytest`, 단계마다 commit, `uv tool install` +- [ ] `ops --reset` · `llm up`, 77일차 준비 diff --git a/Last3Mon/76/README.md b/Last3Mon/76/README.md new file mode 100644 index 0000000..9143d8e --- /dev/null +++ b/Last3Mon/76/README.md @@ -0,0 +1,95 @@ +# 76일차 실습 · 에러 처리와 예외 대응, 안정적 운영 체계 (바이브 코딩) + +- 일자: 2026-10-30 (76일차) +- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영 +- 단원: AI 워크플로우 운영 및 관리 역량 강화 +- 강의 자료: [Lecture/76](../../Lecture/76/README.md) +- 하루 수강 안내: [LECTURE-76.md](LECTURE-76.md) (수업 준비부터 마칠 때까지 순서대로) +- 67~75일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 실패를 **나누고 · 모으고 · 알리고 · 다시 돌리는** 체계를 만듭니다. + +## 오늘 고치는 것: 실패하면 잃고, 다시 보내면 겹치는 문의 일괄 접수 + +`reference/baseline-intake.json`(웹훅 `base-intake`)은 여러 문의(`requests[]`)를 받아 하나씩 LLM으로 분류하고 접수 건을 만듭니다. 수업 준비 때 빈 글이 섞인 3건을 보내자 **HTTP 500인데 접수 건 2개가 이미 생겼고**, 같은 3건을 다시 보내자 **같은 문의의 접수 건이 둘**이 되었습니다. 이것을 실패해도 잃지 않고, 다시 해도 겹치지 않고, 사람이 바로 알게 바꿉니다. + +```mermaid +flowchart LR + Q(["requests[]
10건까지"]) --> V{"항목 확인
빠진 칸 · 같은 id"} + V -->|잘못됨| RJ["rejected
(이유)"] + V --> C{{"LLM 분류
실패면 미분류"}} --> T["접수 건 만들기
key = 요청 id · 재시도 3"] + T -->|성공| OK["created"] + T -->|실패| DLQ[("DLQ")] --> AL["알림 high
(10분에 한 번)"] + DLQ -.->|DLQ도 멈춤| CR["알림 critical
잃을 뻔한 id"] + RJ & OK & DLQ --> R(["200 부분 성공"]) + X["처리 못 한 오류"] -.-> EH["Error Workflow
알림 critical"] + RP["재처리 my-replay
3번 실패면 parked"] -.->|고친 뒤| DLQ + ST["my-ops-status
ok · warn · critical"] -.-> DLQ +``` + +## 일하는 방식 (67~75일차와 같음) + +| 누가 | 무엇을 | +| --- | --- | +| 나 | 실패를 겪어 보고 나누기(거절 · 재시도 · DLQ · 알림), 요청문, 고장 연습, 대응 절차(런북) 쓰기 · 짝 연습 | +| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 | +| `tools/flow.py` | check(재시도 설정 자리 · `errorWorkflow` id) · push · call · test(`{"sleep": 3}`) · **ops**(접수 건 · DLQ · 알림 · 고장 스위치, `--reset`) · **fault tickets\|dlq flaky\|slow\|error\|ok** · llm · doctor (모두 `-v`) | + +> 실패를 **숨기지** 않습니다. 실패한 항목은 결과에 그대로, 그리고 다시 돌릴 수 있는 곳(DLQ)에. 알림을 줄여도 실패의 기록은 줄이지 않습니다. + +## 실습 15개 (Lab마다 예제 3 · 과제 2) + +| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 | +| --- | --- | --- | --- | --- | --- | +| [Lab 01](LAB-01-Q-FAILURE-TYPES.md) | 기준을 망가뜨려 보기 (에이전트 없이) | 항목 확인 · 재시도 · 멱등 키 | 고장 종류마다 재시도가 하는 일 (에이전트 없이) | LLM이 멈춰도 미분류로 접수 | 요청 안의 규칙 (같은 id · 10건) | +| [Lab 02](LAB-02-Q-COLLECT-AND-ALERT.md) | 실패한 요청은 DLQ | Error Workflow | 어느 실패가 어느 그물에 (에이전트 없이) | 처리한 실패도 알림 · DLQ가 멈추면 | 알림 폭주 막기 | +| [Lab 03](LAB-03-Q-REPLAY-AND-RUNBOOK.md) | 재처리 | 런북 · 장애 연습 (에이전트 없이) | 반쯤 처리된 요청 (에이전트 없이) | 세 번 실패하면 멈춤 (parked) | 운영 상태 한눈에 | + +## 실습 문서 읽는 법 + +- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TRETRY.md`)에는 **복사해 붙일 요청문이 없습니다.** 실습마다 `### 이해하기`(왜 · 지금 → 바뀐 뒤 · 테스트 케이스 · 기대 결과)를 읽고, 막히면 `▶` 를 하나씩 펼칩니다. 요청문은 `▶ 빈칸 채우기`의 빈칸 4개(`____①`~`____④`)를 채워서 씁니다. 실습마다 끝에 `### 실행 결과`(수업 준비 때 고치기 전 · 후 출력과 그렇게 나온 까닭)가 있습니다. +- 쓴 요청문은 `my-work/PROMPTS.md`에, 실패 지도와 대응 절차는 `my-work/RUNBOOK.md`에, 테스트 결과는 `my-work/TEST-REPORT.md`에 모아 둡니다. + +## 구성 + +```text +Last3Mon/76/ +├── README.md +├── LECTURE-76.md # 하루 수강 안내 +├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 항목 실패 · 재시도 · 멱등 키 · DLQ · Error Workflow 노드 5절) +├── LAB-01-Q-FAILURE-TYPES.md # 1단계 · 망가뜨려 보기 · 항목 확인 · 재시도 · 멱등 키 · 고장 종류 · LLM 멈춤 · 요청 규칙 +├── LAB-02-Q-COLLECT-AND-ALERT.md # 2단계 · DLQ · Error Workflow · 그물 지도 · 처리한 실패 알림 · 알림 폭주 +├── LAB-03-Q-REPLAY-AND-RUNBOOK.md # 3단계 · 재처리 · 런북 · 장애 연습 · 반쯤 처리된 요청 · parked · 운영 상태 +├── VIBE-REQ-TRETRY.md # 7교시 바이브 코딩 요청서 (가장 먼저 읽기) +├── VIBE-Q-TRETRY.md # 7교시 바이브 코딩 · tretry (명령 · HTTP 재시도 · 대체 · 마감 · 회로 차단 TUI) +├── tools/flow.py +├── data-api/ # 76일차 판 데이터 API (75일차 + 접수 건 만들기 · DLQ · 알림 · 고장 스위치 dlq) +├── cases/ # lab01 · lab01-faults · lab01-challenge(2) · lab02 · lab02-nets · lab02-challenge(2) · lab03 · lab03-drill · lab03-half · lab03-challenge(2) +├── requests/ # batch-ok · batch-bad-item · batch-two · batch-dup-id · batch-eleven · not-a-list +├── reference/ # baseline-intake · nightly-report(버그) + 막혔을 때 이어 가는 워크플로우 +├── templates/ # 런북 · 테스트 보고서 +├── my-work/ +└── runs/ +``` + +## 준비 + +[실습 준비](../../Lecture/76/00-DAY76-PREP.md)를 끝내 주세요. 요약: + +```bash +cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기 +cd ~/aio-01d4-stu/Last3Mon/76/data-api && docker compose up -d --force-recreate && sleep 3 # 76일차 판 +cd ~/aio-01d4-stu/Last3Mon/76 && uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json +uv run tools/flow.py doctor # 마지막 줄 ready +``` + +## `flow.py` 명령 (오늘 더해진 것) + +| 명령 | 하는 일 | 수업 준비 때 | +| --- | --- | --- | +| `uv run tools/flow.py call base-intake requests/batch-bad-item.json` | 기준이 잘못된 항목 하나에 어떻게 되나 | HTTP 500, 그런데 나머지 2건은 이미 접수됨 | +| `uv run tools/flow.py ops` · `ops --reset` | 만든 접수 건 · DLQ(parked) · 알림 · 고장 스위치 보기 · 모두 비우고 스위치 `ok` | | +| `uv run tools/flow.py fault tickets flaky` · `slow` · `error` · `ok` | 접수 건 만들기를 키마다 첫 시도만 실패 · 8초 늦게(그래도 만듦) · 늘 실패 · 되돌리기 | `slow`에서 내 접수 12.4초 · 늦게 생긴 3건 | +| `uv run tools/flow.py fault dlq error` · `ok` | DLQ 저장을 실패로 (Lab 02 예제 3 · 과제 1) | | +| `uv run tools/flow.py llm down` · `llm up` | LLM 멈춤 연습 (72일차와 같음) | | +| `uv run tools/flow.py check my-work/intake.json` | 재시도 설정을 `parameters` 안에 쓰면 · `errorWorkflow` id가 이상하면 오류 | | + +> 오늘 LLM은 분류에만 씁니다 (`gpt-4.1-mini`, 문의 하나에 한 번). `slow` 케이스는 한 번에 10초 넘게 걸리니 같은 테스트를 여러 번 반복하지 않습니다. diff --git a/Last3Mon/76/VIBE-A-TRETRY.md b/Last3Mon/76/VIBE-A-TRETRY.md new file mode 100644 index 0000000..3ece9bc --- /dev/null +++ b/Last3Mon/76/VIBE-A-TRETRY.md @@ -0,0 +1,84 @@ +# 7교시 바이브 코딩 · `tretry` 정답본 + +> **강사용 정답본** · 학생 저장소에는 수업 뒤에 올립니다. 학생용: [VIBE-REQ-TRETRY.md](VIBE-REQ-TRETRY.md) → [VIBE-Q-TRETRY.md](VIBE-Q-TRETRY.md). +> 강사가 직접 만든 본: [tretry/](tretry/README.md) (테스트 17개, `uv tool install --editable .`). 아래 숫자는 2026-10-29 강사 Mac에서 76일차 데이터 API에 실제로 돌린 값입니다. 68일차부터 바이브 코딩은 CLI 에이전트로 다시 만들어 재지 않습니다 (요청문은 예시). + +## 1. TODO 예 (요청의 말 → 할 일) + +| # | 요청의 말 | 할 일 (내 말) | 꼭 · 나중 | +| --- | --- | --- | --- | +| ① | 종료 코드 ≠ 0이면 다시 | `--tries N`까지 다시, 결과에 시도 수 | 꼭 | +| ② | 점점 늘려 · 상한 · 한꺼번에 X | 지수 backoff · `--max-wait` · `--jitter`(줄이기만) | 꼭 | +| ③ | 멈추면 끊고 다시 | 시도마다 `--timeout`, 끊긴 것도 다시 | 꼭 | +| ④ | 대신 할 명령 · 알려 주기 | `--fallback`, "by the fallback" · 기록에 `fallback` | 꼭 | +| ⑤ | 고칠 수 없는 실패는 바로 멈춤 · 잠깐 뒤에 | 4xx(408 · 425 · 429 빼고) 1번 · 종료 코드 3 · 대체 안 함, `Retry-After` | 꼭 | +| ⑥ | 두 번 만들어지면 안 됨 | 실행에 한 번 만든 키를 모든 시도에 (헤더 · 본문) | 꼭 | +| ⑦ | 몇 초 안에 | `--deadline`, 끝낼 수 없는 시도는 시작 안 함 | 나중 | +| ⑧ | 시도마다 파일에 · 키 · 토큰 X | JSON Lines, `***` | 꼭 | +| ⑨ | 어느 대상이 자주 실패 | `stats`: 성공률 · 실행당 시도 · p95 | 나중 | +| ⑩ | 계속 실패한 대상은 바로 대체 | 회로 차단 (최근 60초 5번) | 나중 | +| ⑪ | 화면 | `tretry log` 두 표 · 실패만 | 꼭 | +| ⑫ | 종료 코드 · 다른 프로그램 | 0 · 1 · 2 · 3, `--json` | 꼭 | +| 끝 | 시험에서 기다리지 않기 | sleep · clock · 무작위를 인자로, 가짜 서버 | 꼭 | + +## 2. 요청문 (처음 한 번 + 다음 단계 한 줄) + +단계마다 긴 요청문을 쓰지 않습니다. 할 일은 이미 `Doc/SPEC.md` · `Doc/IMP-PLAN.md`에 있으니, 에이전트가 그 파일을 읽게 하면 됩니다. + +### 처음 한 번 + +```text +Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 체크 안 된 첫 단계만 만들어 줘. uv 프로젝트, 명령 이름 tretry, 코드와 화면 글자는 영어. +끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마. +``` + +### 그다음 단계마다 (같은 한 줄) + +```text +확인했고 commit 했어. 다음 단계. +``` + +- 그냥 "계속"만 치면 단계 두세 개를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 이 한 줄은 처음 요청의 약속(한 단계만 · 체크 · 확인 명령 · 멈춤)을 이어 가게 합니다. +- 대화가 길어지면 새 세션에서 **처음 요청**을 다시 보냅니다. "체크 안 된 첫 단계"부터 이어집니다. +- 에이전트가 단계 내용을 잘못 알았다면, 요청문을 길게 쓰기보다 **SPEC · IMP-PLAN을 고치고** 다시 시킵니다. + +### 틀렸을 때만 · 고쳐 달라는 요청문 예 + +무엇이 틀렸나(보인 것) · SPEC의 어느 요구인가 · 어디만 고치나 · 무엇으로 확인하나, 넷을 적습니다. + +```text +tretry http POST 가 400 을 받아도 3번 다시 하고 대체 명령까지 돌려. SPEC R5 대로 408 · 425 · 429 를 뺀 4xx 는 1번에 멈추고 종료 코드 3, 대체도 안 해야 해. +runners.http 의 retryable 판단만 고치고, 가짜 서버 400 → 시도 1 · 종료 코드 3 · 대체 출력 없음 테스트를 넣어 줘. 다른 파일은 건드리지 마. +``` + +## 3. 확인 기준과 강사 실측 (10/29, 76일차 데이터 API) + +| 요구 | 기준 | 실측 | +| --- | --- | --- | +| R1 · R2 | `--tries 3 --backoff 0.2 -- false` | 시도 3 · 기다림 0.20 → 0.40 · 0.65초 · 종료 코드 1 | +| R3 | `--tries 1 --timeout 0.3 -- sleep 5` | `timeout 0.3s` · 0.31초 · 1 | +| R4 | 대체 | (회로 차단 예에서) `queued-locally` · `ok by the fallback` · 0 | +| R5 | `flaky` · 400 | `flaky`: #1 HTTP 500 → #2 0.33초 성공 (`T-2001`). 본문 `{}`: HTTP 400 `key and title are required` · 1번 · `(not-retryable)` · **종료 코드 3**, `--fallback`을 줬어도 대체 안 함 | +| R6 | 같은 키로 다시 | `{"ticket_id": "T-2001", "duplicate": true}` (1번, 0.01초) | +| R7 | `slow` · `--timeout 3 --deadline 5` | #1 `timeout 3.0s` → 0.5초 기다림 → #2 `timeout 1.5s` (남은 시간으로 줄임) · **5.00초**에 `(deadline)` · 1. Lab 01 예제 3의 12.4초와 비교 | +| R8 | 기록 | 14줄, 키 없음 | +| R9 | `tretry stats` | `POST http://localhost:8800/tickets` 6 runs · 50% · 1.67 tries/run · failed 7 · fallback 1 · p95 5.00 | +| R10 | `error`에서 실패가 이어진 뒤 | 앞선 실패(400 · 마감 2 · error 3)로 최근 5개가 모두 실패 → 다음 실행부터 `0 tries · breaker open`, 대체가 있으면 바로 대체 | +| R12 | `tretry` (인자 없음) | `put the command after --` · 종료 코드 2 | +| R13 | `uv run pytest -q` | 17 passed in 3.3s | +| R14 | `/tmp`에서 `tretry --help` | `usage: tretry [options] -- ` | + +- R10에서 볼 것: 차단은 **대상**(`METHOD URL`) 단위라 본문이 다른 요청(400을 받은 것 포함)도 같은 대상으로 셉니다. 실무에서는 대상을 더 잘게(경로 · 키) 나누거나 4xx를 세지 않기도 합니다. 8교시에 "무엇을 같은 대상으로 볼까"를 질문으로 씁니다. + +## 4. 7교시에 볼 것 + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| 400도 다시 함 | "고칠 수 없는 실패는 1번, 종료 코드 3, 대체도 하지 않기 (오늘 Lab 01)" | +| 테스트가 몇 초씩 | "엔진이 sleep · clock을 인자로" | +| 간격이 늘 같음 | "지수 backoff와 상한" | +| 다시 보낸 POST로 접수 건이 둘 | "키는 실행에 한 번, 본문 key에도" | +| `--timeout`이 HTTP에 안 먹음 | "urlopen(timeout=...)" | +| `--` 뒤 옵션을 tretry가 먹음 | "`--` 앞만 tretry 옵션" | +| 기록에 토큰 | "헤더 값은 남기지 않기, URL · 오류도 지우기" | +| 대체가 늘 성공해 차단이 안 풀림 | "대체의 성공은 대상의 성공이 아님" | diff --git a/Last3Mon/76/VIBE-Q-TRETRY.md b/Last3Mon/76/VIBE-Q-TRETRY.md new file mode 100644 index 0000000..cf95440 --- /dev/null +++ b/Last3Mon/76/VIBE-Q-TRETRY.md @@ -0,0 +1,317 @@ +# 7교시 바이브 코딩 · `tretry` 만들기 + +> **먼저 [VIBE-REQ-TRETRY.md](VIBE-REQ-TRETRY.md)(요청서)를 읽고 `~/vibe/tretry/Doc/TODO.md`를 직접 쓴 뒤** 이 문서를 엽니다. 아래 1~8을 차례로 갑니다. 이 문서의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다. + +```mermaid +flowchart LR + T["1. Doc/TODO.md
할 일 정리"] --> S["2. Doc/SPEC.md
확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md
엔진 → TUI 단계"] --> I["4. 구현
단계마다 요청 · 확인"] + I --> H["5. 막히면
문제 해결 팁"] --> I + I --> C["6. 결과 확인
요구 표 체크"] --> U["7. uv tool install"] --> O["8. 다른 곳에서 사용"] +``` + +- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시 · 50분 안에 못 한 단계는 집에서 `Doc/IMP-PLAN.md`의 다음 단계로 이어 갑니다 +- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), 표준 라이브러리 `subprocess` · `urllib.request` +- 결과물: 아무 **명령이나 HTTP 호출을 감싸** 실패하면 **간격을 늘려 가며 다시** 하고(상한 · 흔들기), 한 시도는 **시간 제한**으로 끊고, 전체 **마감 시간** 안에 끝내고, 다 실패하면 **대신 할 명령**을 돌리는 도구. 고칠 수 없는 실패(HTTP 4xx)는 다시 하지 않고, 다시 보낼 때는 **같은 멱등 키**를 붙입니다. 시도마다 기록을 남기고(키 · 토큰은 지움), 기록으로 순위와 **바로 대체(회로 차단)**를 정하고, TUI로 봅니다. `uv tool install`로 설치해 **어느 폴더에서나** `tretry`. 오늘 n8n 노드 설정(`retryOnFail` · `onError` · 대체 경로)으로 한 일을 **n8n 밖 어떤 명령에든.** + +```text +$ tretry --tries 4 --backoff 0.5 --timeout 2 --fallback "cat ~/cache/faq.json" -- curl -sf localhost:8800/faq?q=vpn +#1 0.00s ✘ exit 22 · 0.02s +#2 0.52s ✘ timeout 2.0s · 2.00s (waited 0.50s) +#3 3.53s ✔ ok · 0.03s (waited 1.00s) +result: ok · 3 tries · 3.56s + +$ tretry log +┌ tretry · ~/.tretry/log.jsonl ──────────────────────────────────────────────────┐ +│ target runs ok% tries/run failed │ run try at +s result error │ +│ POST http://localhost:8800/tickets 4 50% 2.75 7 │ 3 1 0.00 ✘ HTTP 500 │ +│ curl -sf localhost:8800/faq?q=vpn 2 100% 2.00 2 │ 3 2 0.31 ✔ │ +│ 6 run(s) · 15 tr(ies) · [enter] show tries · [f] failed only · [r] reload │ +└────────────────────────────────────────────────────────────────────────────────┘ +``` + +> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다. +> +> **테스트에서 진짜로 기다리지 않습니다.** 기다리는 일(`sleep`)과 시계(`clock`)를 바꿔 끼울 수 있게 만들고, HTTP는 테스트 안의 작은 가짜 서버로 시험합니다. + +## 시간 (50분) + +| 시간 | 단계 | 결과 | +| --- | --- | --- | +| 16:00~16:09 | 0 준비 · 1 `Doc/TODO.md` (요청서 읽고 직접) | 할 일 ①~⑫ · 꼭 / 나중 | +| 16:09~16:15 | 2 `Doc/SPEC.md` → 아래 요구 표와 비교 · 고치기 | 요구마다 확인 방법 | +| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 단계 · TUI 단계) | 단계마다 체크리스트 | +| 16:20~16:44 | 4 구현: **엔진 1단계 → 엔진 2단계 → TUI 1단계** (막히면 5) | 단계마다 `pytest` · 직접 실행 · commit | +| 16:44~16:50 | 6 확인 · 7 `uv tool install` · 8 다른 폴더에서 | `tretry http POST localhost:8800/tickets ...` (데이터 API `flaky`) | + +50분 안 목표: **R1 · R2 · R3 · R4 · R12 · R13(엔진 1단계) + R5 · R6 · R8(엔진 2단계) + R11(TUI 1단계) + R14(설치)**. R7 · R9 · R10은 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다. + +## 0. 준비 (1분) + +```bash +mkdir -p ~/vibe/tretry/Doc && cd ~/vibe/tretry +git init +# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy) +``` + +## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이) + +[VIBE-REQ-TRETRY.md](VIBE-REQ-TRETRY.md)의 "할 일" 틀대로 씁니다. 요청의 말 ①~⑫를 **내 말로** 옮기고, 꼭 · 나중을 표시하고, 걱정되는 것과 50분 안에 못 할 것을 적습니다. "어떻게"는 아직 쓰지 않습니다. + +
▶ TODO와 SPEC은 무엇이 다른가 + +TODO는 **내가 이해한 것**(무엇을 · 왜)입니다. SPEC은 그것을 **확인할 수 있게** 바꾼 것(됐다 · 안 됐다를 말하는 방법)입니다. TODO에서 빠진 것은 SPEC에서도 빠지므로, TODO는 빠짐없이, SPEC은 정확하게. +
+ +`git add Doc/TODO.md && git commit -m "TODO"` + +## 2. `Doc/SPEC.md` · 확인할 수 있는 요구로 (6분) + +TODO의 할 일마다 **확인 방법**(시도 수 · 기다린 시간 · 종료 코드 · 기록에 남은 것)을 붙여 SPEC을 만듭니다. 에이전트에게 "TODO.md를 읽고 SPEC.md 초안을 써 줘. 요구마다 확인 방법을 붙여"라고 시켜도 됩니다. 그러면 **반드시 내가 읽고 고칩니다.** + +```markdown +# tretry SPEC + +## 1. 요구 (확인할 수 있게) +| 번호 | 요구 | 요청의 어느 말 | 확인 방법 | 꼭 · 나중 | +| --- | --- | --- | --- | --- | +| R1 | | ① | | 꼭 | + +## 2. 숨은 요구 +## 3. 범위 밖 (하지 않을 것) +## 4. 정해 둘 것 (기본 횟수 · 간격 · 상한, 다시 할 HTTP 상태, 종료 코드 번호, 기록 파일 위치 · 모양, 회로 차단 기준) +``` + +
▶ 확인 방법은 손으로 셀 수 있는 숫자로 + +"간격을 늘린다"는 확인 방법이 아닙니다. "두 번 실패 후 성공하는 가짜 시도, `--tries 4 --backoff 0.5` → 시도 3번, 기다린 시간 [0.5, 1.0]"이 확인 방법입니다. 오늘 Lab 01 예제 3처럼 고장 종류마다 **나와야 할 것과 나오면 안 되는 것**을 둘 다 적습니다 (400 → 시도 1번 · 대체 명령 **안 함**). +
+ +쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다. + +### 요구 사항 (요청서에서 뽑은 것, 14개) + +| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 | +| --- | --- | --- | --- | --- | +| R1 | `tretry [--tries N] -- <명령>`: 종료 코드 ≠ 0이면 N번까지 다시 | ① | `--tries 3 --backoff 0 -- false` → 시도 3번 · 종료 코드 1 | 꼭 | +| R2 | 기다림 = `backoff × 2^(i-1)`, `--max-wait` 상한, `--jitter`는 기다림을 **줄이기만** (무작위를 바꿔 끼울 수 있게) | ② | 가짜 sleep: 0.5 → 1.0, `backoff 1 · max 4` → 1 · 2 · 4 · 4, jitter 0.5 · 무작위 1.0 → 절반 | 꼭 | +| R3 | `--timeout S`: 한 시도를 S초에 끊고, 끊긴 것도 다시 할 실패로 | ③ | `--tries 1 --timeout 0.3 -- sleep 5` → `timeout 0.3s` · 종료 코드 1 · 0.3초쯤에 끝 | 꼭 | +| R4 | `--fallback "<명령>"`: 다 실패하면 한 번 실행해 그 출력을 쓰고, 대체로 됐다고 알림 | ④ | `-- false` + `--fallback "echo cached"` → 출력 `cached` · 종료 코드 0 · "by the fallback" | 꼭 | +| R5 | `tretry http [--body] [--header]`: 2xx 성공 · 5xx · 408 · 425 · 429 · timeout · 연결 실패는 다시 · 그 밖의 4xx는 **1번에 멈추고 대체도 안 함**, `Retry-After`를 따름 (상한까지) | ⑤ | 가짜 서버 500 → 201: 시도 2번, 400: 시도 1번 · 종료 코드 3 · 대체 출력 없음, 429 + `Retry-After: 3` → 기다림 3 | 꼭 | +| R6 | `--idem-key auto\|<값>`: 모든 시도에 **같은** 키를 헤더 `Idempotency-Key`로, `--body-key key`면 본문 필드에도 | ⑥ | 가짜 서버가 본 키가 시도 3번 모두 하나 · 본문 `key`와 같음. 데이터 API `flaky`에서 201 → 같은 키로 다시 200 `duplicate: true` | 꼭 | +| R7 | `--deadline S`: 전체 S초. 끝낼 수 없는 기다림 · 시도는 시작하지 않고, 마지막 시도의 시간 제한은 남은 시간으로 줄임 | ⑦ | 가짜: 시도마다 3초 · `backoff 1 · deadline 7` → 시도 2번 · 멈춘 까닭 `deadline` | 나중 | +| R8 | 시도마다 JSON Lines 한 줄 `{at, kind, target, attempt, started, seconds, ok, error, status, waited, fallback, final, result}`, 키 · 토큰(`sk-…`, `Bearer …`, `api_key=…`)은 `***` | ⑧ | URL `?api_key=secret123`, 헤더 `Authorization: Bearer sk-…` → 기록에 `secret123` · `sk-` 없음 | 꼭 | +| R9 | `tretry stats [--json]`: 대상마다 실행 수 · 성공률 · 실행당 시도 · 실패한 시도 · 대체 · p95 시간, 실패 많은 순 | ⑨ | 실패 실행 3번(시도 3 · 2 · 대체) → runs 3 · failed 5 · fallbacks 1 · 33% | 나중 | +| R10 | 회로 차단: 같은 대상의 최근 60초 시도 N개(기본 5)가 모두 실패면 시도 없이 바로 대체 (`--breaker 0`은 끔), 대체가 없으면 멈춤 | ⑩ | 실패 시도 5번 뒤 → 시도 0번 · 대체 출력 · "breaker open" | 나중 | +| R11 | `tretry log`: TUI. 왼쪽 대상 순위(실패 많은 순), 고르면 오른쪽에 시도들, `[f]` 실패만, `[r]` 다시 읽기 | ⑪ | `run_test`: 대상 2개 · 첫 줄이 실패 많은 대상, `f` → 실패만 | 꼭 | +| R12 | 종료 코드 0 됨(대체 포함) · 1 다 실패 · 2 쓰는 법이 틀림 · 3 고칠 수 없는 실패, `--json` | ⑫ | `--` 없음 → 2, 본문이 JSON이 아님 → 2, `--json` → `exit_code` · `stopped` · `attempts` | 꼭 | +| R13 | 테스트에서 기다리지 않음: `sleep` · `clock` · 무작위를 바꿔 끼움, HTTP는 테스트 안의 가짜 서버 | 끝 | `uv run pytest -q`가 10초 안 | 꼭 | +| R14 | `uv tool install`로 설치해 **어느 폴더에서나** `tretry` | 첫 문단 | `cd /tmp && tretry --help`, `uv tool list`에 `tretry` | 꼭 | + +숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것): + +| 숨은 요구 | 어디서 나왔나 | +| --- | --- | +| 다시 하기 **엔진**은 명령도 HTTP도 모른다. "한 번 해 보기"를 받아 결과(성공 · 다시 할 수 있나 · 오류)만 본다 | ① · ⑤ — 그래야 명령 · HTTP · 다음에 올 것(DB 등)에 같은 엔진 | +| 명령이 **없으면**(오타) 다시 하지 않는다 | ⑤의 "몇 번을 해도 똑같은 것" | +| 대체 명령이 다 실패를 **가리지 않게**: 결과 · 기록에 "대체로 됨"이 남는다 | ④ "대신 한 거라면 그렇다고" — 오늘 Lab 01 과제 1의 `unclassified` 수와 같은 생각 | +| 한 실행의 시도 줄들을 다시 묶을 수 있어야 한다 (`final`) | ⑨ · ⑪ — 실행당 시도 수 | +| 회로 차단은 **대체의 성공**을 대상의 성공으로 세지 않는다 | ⑩ — 대체가 늘 성공하면 차단이 영영 안 풀림 | +| 기록 파일 위치를 바꿀 수 있게 (`--log`, `$TRETRY_LOG`) | 테스트가 내 진짜 기록을 더럽히지 않게 | + +범위 밖 (오늘 하지 않음): 여러 대상 동시에, 차단 상태를 여러 컴퓨터가 함께 쓰기, 메일 · 메신저 알림, 기록 파일 돌려 쓰기(로테이션). + +`git add Doc/SPEC.md && git commit -m "SPEC"` + +## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분) + +SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(백엔드)을 먼저, 화면(TUI)은 나중에.** 화면은 기록 파일을 읽어 보여 주기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침). + +```markdown +# tretry IMP-PLAN + +## 엔진 1단계 · 다시 하기 · 대체 · 종료 코드 (R1 · R2 · R3 · R4 · R12 · R13) +- [ ] retry.py: Policy(tries, backoff, max_wait, jitter), wait(i) = min(backoff × 2^(i-1), max_wait) × (1 - jitter × 무작위) +- [ ] run_with_retry(attempt, policy, fallback, timeout, sleep, clock, rand) → Result(ok, attempts, stopped, used_fallback) +- [ ] runners.command(argv): 종료 코드 · timeout · 명령 없음 +- [ ] cli: tretry [옵션] -- <명령>, 종료 코드 0 · 1 · 2 · 3, --json +- [ ] tests: 가짜 시계로 [0.5, 1.0], 상한, 대체, 4xx 같은 "다시 못 함"은 1번 +- 확인: uv run pytest -q, uv run tretry --tries 3 --backoff 0.2 -- false + +## 엔진 2단계 · HTTP · 멱등 키 · 기록 (R5 · R6 · R8) +## TUI 1단계 · 기록 보기 (R11) +## 설치 (R14) +## 엔진 3단계 · 마감 · 순위 · 회로 차단 (R7 · R9 · R10) +``` + +
▶ 왜 엔진 먼저인가 + +- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tretry -- false`). 화면부터 만들면 다시 하는지 눈으로만 봅니다. +- 밤 작업 스크립트는 화면 없이 돌아갑니다. 종료 코드(R12)가 다음 줄을 정합니다 (74일차 게이트와 같음). +- TUI는 기록 파일을 읽어 표에 넣기만 합니다. 기록 모양(R8)이 정해져야 TUI를 만들 수 있습니다. +
+ +
▶ 단계 크기 + +한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 단계가 크면 에이전트가 많이 바꾸고, 틀렸을 때 어디가 틀렸는지 모릅니다. 오늘 Lab 01에서 기준을 한 번에 다 고치지 않고 "확인 → 재시도 → 멱등 키"로 나눈 것과 같습니다. +
+ +`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"` + +## 4. 구현 시작 (24분: 엔진 1단계 → 엔진 2단계 → TUI 1단계) + +이제 에이전트를 켭니다. 첫 요청은 늘 같습니다. + +```text +Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽어 줘. 아직 코드는 쓰지 말고, +IMP-PLAN 의 "엔진 1단계"를 어떻게 만들지와 각 체크 항목의 확인 방법을 5줄로 먼저 말해 줘. +``` + +그다음 **한 단계씩**. 단계마다 긴 요청문은 필요 없습니다. 할 일은 SPEC · IMP-PLAN에 있으니까요. + +```text +(처음) 좋아. 체크 안 된 첫 단계만 만들어 줘. 끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마. +(그다음) 확인했고 commit 했어. 다음 단계. +``` + +→ 내가 확인 → commit → 한 줄. 그냥 "계속"만 치면 여러 단계를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 틀렸을 때만 무엇이 틀렸는지 · SPEC의 어느 요구인지 · 어디만 고칠지 · 무엇으로 확인할지를 적어 고쳐 달라고 합니다. + +> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. `tretry`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 어디가 틀렸는지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`. + +
▶ 엔진 1단계 · 테스트가 기다리지 않게 + +`time.sleep`을 엔진 안에서 바로 부르면 테스트 하나에 몇 초씩 걸립니다. 엔진이 `sleep` · `clock`을 **인자로 받게** 하고, 테스트는 "기다린 시간을 적고 시계만 앞으로 돌리는" 가짜를 넣습니다. 그러면 "3초짜리 시도 · 마감 7초"도 0초 안에 시험합니다 (R7). +
+ +
▶ 엔진 1단계 · 무엇을 "다시 할 수 있는 실패"로 보나 + +엔진은 시도 결과에 **다시 할 수 있나**(retryable)를 함께 받습니다. 명령: 종료 코드 ≠ 0 · 시간 초과는 예, 명령이 없음은 아니오. HTTP(엔진 2단계): 5xx · 408 · 425 · 429 · 시간 초과 · 연결 실패는 예, 그 밖의 4xx는 아니오. 이 판단을 엔진 밖(실행기)에 두면 엔진은 하나로 충분합니다. +
+ +
▶ 엔진 2단계 · 멱등 키는 언제 만드나 + +키를 **시도마다** 만들면 데이터 API가 매번 새 요청으로 봅니다 (오늘 기준 워크플로우의 `$execution.id`와 같은 실수). 키는 **실행을 시작할 때 한 번** 만들고 모든 시도에 붙입니다. 오늘 데이터 API는 본문의 `key`를 봅니다 → `--body-key key`. +
+ +
▶ 엔진 2단계 · 기록에 무엇을 남기나 · 남기지 않나 + +남길 것: 언제 · 무엇을(대상) · 몇 번째 · 시작부터 몇 초 · 걸린 시간 · 결과 · 오류 · 상태 코드 · 기다린 시간 · 대체였나. 남기지 **않을** 것: 헤더 값, URL · 오류 문장 안의 키 · 토큰. 한 줄에 하나(JSON Lines)면 실행이 중간에 죽어도 앞 줄들은 살아 있습니다. +
+ +
▶ TUI 1단계 · 화면 나누기 + +순위 계산(엔진)과 화면을 나눕니다 (`tcost` · `tver`와 같음). 왼쪽 표의 줄을 고르면(`RowHighlighted`) 오른쪽 표를 다시 채웁니다. 기록 읽기 · 순위는 엔진 함수를 부르기만. +
+ +
▶ 다음 단계들 (8교시 뒤 · 집에서) + +- 마감(R7): 시도를 시작하기 전에 남은 시간을 보고, 기다림이 마감을 넘으면 멈춤. 마지막 시도의 시간 제한 = min(`--timeout`, 남은 시간). +- 순위(R9): 기록을 실행 단위로 묶어(`final`) 대상마다 셈. p95는 실행 시간으로. +- 회로 차단(R10): 기록에서 그 대상의 최근 60초 시도(대체 제외) N개가 모두 실패면 바로 대체. 60초가 지나면 저절로 다시 시도 (half-open). +
+ +## 5. 바이브 코딩으로 문제 해결 팁 + +| 이럴 때 | 이렇게 | +| --- | --- | +| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tretry --tries 3 -- false; echo $?`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 | +| 실패 · 오류 | 오류 출력 **전부**와 실행한 명령을 그대로 붙여 줍니다 | +| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 | +| 테스트가 몇 초씩 걸림 | "엔진이 sleep · clock을 인자로 받게, 테스트에서는 가짜로" | +| 400도 다시 함 | "고칠 수 없는 실패(4xx, 408 · 425 · 429 빼고)는 1번에 멈추고 종료 코드 3, 대체도 하지 않기" | +| 간격이 늘 같음 · 끝없이 늚 | "지수 backoff와 상한(--max-wait)" | +| 다시 보낸 POST로 접수 건이 둘 | "멱등 키는 실행에 한 번 만들고 모든 시도에 같은 값, 본문 key에도" | +| `--timeout`이 안 먹음 | "subprocess.run(timeout=...)과 urlopen(timeout=...) 둘 다, 시간 초과도 다시 할 실패로" | +| `-- ` 뒤 옵션을 tretry가 먹어 버림 | "`--` 앞만 tretry 옵션으로 읽고, 뒤는 그대로 명령으로" | +| 기록에 토큰이 보임 | "헤더 값은 기록하지 않고, URL · 오류의 sk-… · Bearer … · api_key=… 는 *** 로" | +| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console` | +| 대화가 길어져 에이전트가 앞 내용을 잊음 | 새 세션을 열고 "Doc/SPEC.md · Doc/IMP-PLAN.md를 읽고, 체크 안 된 첫 단계부터"로 다시 시작 | +| 설치한 `tretry`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) | + +## 6. 결과 확인 + +`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만: + +```bash +cd ~/vibe/tretry +uv run pytest -q # R13: 10초 안 +uv run tretry --tries 3 --backoff 0.2 -- false; echo "exit $?" # R1 · R2: 3번, 0.2 → 0.4 +uv run tretry --tries 1 --timeout 0.3 -- sleep 5; echo "exit $?" # R3 +uv run tretry --tries 2 --backoff 0 --fallback "echo cached" -- false # R4 +uv run tretry; echo "exit $?" # R12: 2 +L=~/aio-01d4-stu/Last3Mon/76 +(cd $L && uv run tools/flow.py fault tickets flaky) +uv run tretry http POST http://localhost:8800/tickets --body '{"title": "vibe test", "requester": "나"}' \ + --idem-key vibe-001 --body-key key --backoff 0.3 # R5 · R6: 500 → 201 +uv run tretry http POST http://localhost:8800/tickets --body '{"title": "vibe test"}' --idem-key vibe-001 --body-key key + # R6: 200 duplicate: true +uv run tretry http POST http://localhost:8800/tickets --body '{}' --fallback "echo cached"; echo "exit $?" # R5: 400 → 3 +(cd $L && uv run tools/flow.py fault tickets ok) +tail -3 ~/.tretry/log.jsonl # R8 +uv run tretry log # R11 +``` + +- [ ] R1 횟수. +- [ ] R2 backoff · 상한 · jitter. +- [ ] R3 시도마다 시간 제한. +- [ ] R4 대체 명령. +- [ ] R5 HTTP 5xx · timeout만 다시, 4xx는 1번 · 종료 코드 3. +- [ ] R6 같은 멱등 키 · `duplicate: true`. +- [ ] R8 기록 · 키 지움. +- [ ] R11 TUI. +- [ ] R12 종료 코드 · `--json`. +- [ ] R13 기다리지 않는 테스트. +- [ ] (이어서) R7 · R9 · R10. + +## 7. `uv tool install`로 설치 + +`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다. + +```toml +[project.scripts] +tretry = "tretry.cli:main" +``` + +```bash +cd ~/vibe/tretry +uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함) +uv tool list # tretry 가 보이는가 +which tretry # ~/.local/bin/tretry +# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶 +``` + +- 지우기: `uv tool uninstall tretry`. + +## 8. 다른 곳에서 사용 + +설치했으면 오늘 Lab과 내 스크립트에 씁니다. + +```bash +cd ~/aio-01d4-stu/Last3Mon/76 +uv run tools/flow.py fault tickets slow +tretry http POST http://localhost:8800/tickets --body '{"title": "deadline test"}' --idem-key dl-1 --body-key key \ + --timeout 3 --deadline 5 # (R7) Lab 01 예제 3의 12초 대신 5초 안에 포기 +uv run tools/flow.py fault tickets ok +tretry --tries 3 --fallback "echo '{\"status\": \"cached\"}'" -- curl -sf http://localhost:8800/health # 아무 명령이나 +tretry stats # (R9) 오늘 가장 자주 실패한 대상 +tretry log +``` + +- `~/vibe/tretry`는 지우지 마세요. 79일차 `tops`에서 다시 씁니다. + +## 발표 (8교시, 3분) + +| 내용 | | +| --- | | +| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) | +| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 | +| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 | +| 막힌 곳 | 5절의 어느 팁으로 풀었나 (4xx 재시도 · 테스트 시간 · 멱등 키 · `--` 뒤 옵션) | +| 시연 | 데이터 API `flaky` · `error` · `slow`에서 다시 하기 · 대체 · 마감 | + +## 확인 + +- [ ] `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`를 에이전트 전에 (또는 에이전트와 함께 쓰고 내가 고쳐서) 만들고 commit했다. +- [ ] IMP-PLAN의 엔진 1 · 2단계와 TUI 1단계를 체크했다 (단계마다 commit). +- [ ] R1 · R2 · R3 · R4 · R5 · R6 · R8 · R11 · R12 · R13 확인, `uv run pytest -q`. +- [ ] R14 `uv tool install`, 다른 폴더에서 `tretry`. +- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감). diff --git a/Last3Mon/76/VIBE-REQ-TRETRY.md b/Last3Mon/76/VIBE-REQ-TRETRY.md new file mode 100644 index 0000000..a460604 --- /dev/null +++ b/Last3Mon/76/VIBE-REQ-TRETRY.md @@ -0,0 +1,109 @@ +# 7교시 바이브 코딩 · 요청서 `tretry` (가장 먼저 읽기) + +- 순서: **이 요청서** → 내 정리 `~/vibe/tretry/Doc/TODO.md` (8분) → [VIBE-Q-TRETRY.md](VIBE-Q-TRETRY.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TRETRY.md`(정답본)로 확인 +- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 72일차 `tbench` · 73일차 `tscan` · 74일차 `tver` · 75일차 `tcost`와 같은 순서입니다. + +```mermaid +flowchart LR + REQ["요청서
(사람의 말)"] --> TODO["Doc/TODO.md
만들고 싶은 것 ·
요청의 말 정리"] --> SPEC["Doc/SPEC.md
확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md
엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·
직접 확인"] --> USE["uv tool install
다른 곳에서 쓰기"] +``` + +## 왜 요청서부터인가 + +요구 사항 표를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 오늘 Lab 01 예제 1에서 기준 워크플로우를 먼저 망가뜨려 본 것과 같습니다: 무엇이 실패인지 알아야 고쳤는지 말할 수 있습니다. + +| 단계 | 누가 | 결과 | +| --- | --- | --- | +| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 | +| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 | +| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 | +| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `Doc/*.md`를 함께 읽게 함 | +| 확인 | **나** | 직접 `uv run tretry …`, `pytest`, 요구 표에 체크 | + +## 요청 (운영 담당에게서 온 메시지, 그대로) + +> 오늘 n8n에서 재시도 · 대체 · 알림을 노드 설정으로 했잖아요. 그런데 우리 밤 작업은 n8n 밖에도 많아요. 셸 스크립트에서 `curl`로 API를 부르고, 백업 명령을 돌리고요. 그런 것들이 **한 번 실패하면 그냥 끝나** 버려서 아침에 보면 빠져 있어요. 아무 명령이나 감싸서 **n8n처럼 다시 해 보게** 해 주는 작은 도구가 있으면 좋겠어요. **어느 폴더에서나** `tretry`라고 치면 되게요. +> +> ① 명령을 주면 실행하고, 실패하면(**종료 코드가 0이 아니면**) 정해진 **횟수**만큼 다시 해 주세요. +> +> ② 바로바로 다시 하면 이미 힘든 서버가 더 힘들어진대요. 기다리는 시간을 **점점 늘려** 주시고, 너무 길어지지 않게 **상한**도요. 그리고 밤 작업 여러 개가 **같은 순간에 한꺼번에** 다시 보내지 않게 해 주세요. +> +> ③ 한 번 시도가 **멈춰 버리면**(응답이 안 오면) 정해진 시간에 끊고 다시 해 주세요. +> +> ④ 다 해도 안 되면 **대신 할 명령**을 돌려 주세요. 예를 들어 어제 받아 둔 파일을 쓰는 거요. 대신 한 거라면 그렇다고 알려 주세요. +> +> ⑤ API를 부를 때는 **고칠 수 없는 실패**(우리 요청이 잘못된 것)까지 다시 하면 안 되잖아요. 몇 번을 해도 똑같으니까요. 그런 건 바로 멈추고, 그때는 대신 할 명령도 돌리지 말아 주세요. 실수가 가려지니까요. 다만 서버가 "**잠깐 뒤에 다시 와**"라고 하면 그 말대로 기다려 주세요. +> +> ⑥ 같은 요청을 다시 보낼 때 **두 번 만들어지면** 안 돼요. 오늘 접수 건이 그랬잖아요. 다시 보낼 때마다 같은 표시(키)를 붙여 주세요. +> +> ⑦ 다시 하는 건 좋은데, 전체가 **몇 초 안에는** 끝나야 할 때가 있어요. 오늘 느린 API에 3초씩 세 번 기다린 것처럼요. 정해진 시간 안에 끝낼 수 없는 시도는 **시작하지 말아** 주세요. +> +> ⑧ 시도마다 **언제 · 무엇을 · 몇 번째 · 몇 초 · 결과 · 오류**를 파일에 남겨 주세요. 나중에 모아서 보려고요. 그런데 거기에 **API 키나 토큰이 남으면 큰일** 나요. +> +> ⑨ 그 기록으로 **어느 대상이 가장 자주 실패하는지**, 성공률 · 평균 몇 번 만에 됐는지 · 오래 걸린 정도를 보여 주세요. +> +> ⑩ 하루 종일 죽어 있는 대상에 매번 세 번씩 다시 보내는 건 낭비예요. **방금 계속 실패한 대상**이면 시도하지 말고 바로 대신 할 명령으로 가 주세요. 조금 지나면 다시 시도해 보고요. +> +> ⑪ **화면에서** 보고 싶어요. 실패가 많은 대상 순서로, 하나를 고르면 그 시도들이 보이게요. 실패한 것만 볼 수도 있게요. +> +> ⑫ 스크립트 안에서 쓸 거라 **종료 코드**가 중요해요. 됐다 · 다 실패했다 · 쓰는 법이 틀렸다 · 고칠 수 없는 실패다를 서로 다른 번호로 주세요. 그리고 **다른 프로그램이 읽을 수 있는** 결과도요. +> +> 아, 시험할 때 **진짜로 몇 초씩 기다리면** 안 돼요. 그리고 오늘 수업의 데이터 API(고장 스위치)로 직접 해 보고 싶어요. + +## 함께 받은 것 + +| 파일 · 자료 | 메모 | +| --- | --- | +| 오늘 데이터 API `http://localhost:8800` | `POST /tickets` (멱등 키 `key`), 고장 스위치: `uv run tools/flow.py fault tickets flaky` · `error` · `slow` · `ok` | +| `requests/batch-ok.json` | 요청 본문 예. `tretry http POST ... --body @파일` | +| 오늘 Lab 01 예제 3 | 고장 종류마다 재시도가 하는 일 (flaky · error · slow) | +| Python 표준 라이브러리 | `subprocess`(시간 제한), `urllib.request`. TUI는 Textual | + +## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이) + +`~/vibe/tretry/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다. + +```markdown +# tretry TODO + +## 만들고 싶은 것 (한 문단) +어느 폴더에서나 `tretry`로 아무 명령이나 API 호출을 감싸, 실패하면 간격을 늘려 가며 다시 하고 ... + +## 요청의 말 → 할 일 +| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 | +| --- | --- | --- | --- | +| ① | 종료 코드 ≠ 0이면 다시 | | 꼭 | +| ② | 점점 늘려 · 상한 · 한꺼번에 X | | | +| ... | | | | +| 끝 | 시험에서 기다리지 않기 | | | + +## 요청에 없지만 걱정되는 것 +## 오늘 50분 안에 못 할 것 같은 것 +``` + +예 하나만 보여 드립니다. + +| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 | +| --- | --- | --- | +| ⑤ "고칠 수 없는 실패까지 다시 하면 안 돼요" | "API가 400처럼 내 잘못을 말하면 한 번만 하고 멈춤, 대신 할 명령도 안 함" | R5: 가짜 서버가 400 → 시도 1번, 종료 코드 3, 대체 명령의 출력 없음 | + +
▶ 1단계 · 꼭 할 것과 나중에 할 것 + +요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 담당자가 가장 아파하는 것은 "한 번 실패하면 그냥 끝나 버려서"입니다. 그리고 기록 · 순위 · 화면 · 바로 대체(⑩)를 만들려면 먼저 무엇이 있어야 하나요? (다시 하기 엔진 → 명령 · HTTP → 기록 → 기록 읽기 → 화면) +
+ +
▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗) + +- ② "점점 늘려": 0.5 → 1 → 2 → 4초면, 열 번째는 몇 초일까요? 상한이 없으면? "한꺼번에 X"는 기다리는 시간을 조금씩 다르게 하면 됩니다. 그런데 무작위면 시험 결과가 날마다 달라지지 않을까요? +- ③ "멈춰 버리면": 명령이 멈춘 것과 API가 멈춘 것은 끊는 방법이 다릅니다. 둘 다 끊어야 합니다. +- ⑤ "고칠 수 없는 실패": HTTP 4xx 중에도 기다리면 되는 것이 있습니다 (429 Too Many Requests · 408). 명령은? 명령이 없을 때(오타)도 다시 할까요? +- ⑥ "같은 표시": 키를 시도마다 새로 만들면 아무 소용이 없습니다. 오늘 데이터 API는 키를 **본문**의 `key`로 받습니다. 헤더로만 보내면? +- ⑦ "몇 초 안에": 남은 시간이 1.5초인데 한 시도의 시간 제한이 3초라면? +- ⑧ "키나 토큰": 헤더뿐 아니라 URL(`?api_key=...`)이나 오류 문장에도 들어갈 수 있습니다. +- ⑩ "계속 실패한 대상": 몇 번 실패하면 · 얼마 동안 바로 대체로? 대신 할 명령이 없으면? +- 끝 "진짜로 기다리면 안 돼요": 기다리는 일을 프로그램 밖에서 바꿔 끼울 수 있어야 합니다. +
+ +## 그다음 + +[VIBE-Q-TRETRY.md](VIBE-Q-TRETRY.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다. diff --git a/Last3Mon/76/answers/00-prep.txt b/Last3Mon/76/answers/00-prep.txt new file mode 100644 index 0000000..4e75b15 --- /dev/null +++ b/Last3Mon/76/answers/00-prep.txt @@ -0,0 +1,34 @@ +$ uv run tools/flow.py ops --reset +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +$ uv run tools/flow.py budget --reset +✔ budget back to data/budget.json +budget $1.0 / used $0.000000 · remaining $1.000000 · over False · per user 20/min · question 500 chars + +$ uv run tools/flow.py usage --clear +✔ usage cleared: {"cleared": 0} + +$ uv run tools/flow.py off my-intake my-replay my-ops-status reference/lab02-example2-error-handler.json +✔ unpublished my-intake (id w75a95815facf2d6) +✔ unpublished my-replay (id w7d8de63c811909b) +✔ unpublished my-ops-status (id w07b5812eaa88ef9) +✔ n8n restarted. Turn it back on with: uv run tools/flow.py push + +$ uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json +✔ pushed '76 Intake baseline (provided)' (id w4ef8dc8a25b82e3) + POST http://localhost:5678/webhook/base-intake +✔ pushed '76 Nightly report (provided, has a bug)' (id wb9ce32c18d7df56) + POST http://localhost:5678/webhook/nightly-report + +$ uv run tools/flow.py doctor +! n8n install folder /Users/mcchae/day65-n8n + → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더> +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready diff --git a/Last3Mon/76/answers/01-lab01-break.txt b/Last3Mon/76/answers/01-lab01-break.txt new file mode 100644 index 0000000..210d3c4 --- /dev/null +++ b/Last3Mon/76/answers/01-lab01-break.txt @@ -0,0 +1,64 @@ +$ uv run tools/flow.py ops --reset +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +$ uv run tools/flow.py call base-intake requests/batch-ok.json +HTTP 200 · 5.2s +{ + "processed": 3, + "tickets": [ + "T-2001", + "T-2002", + "T-2003" + ] +} + +$ uv run tools/flow.py call base-intake requests/batch-bad-item.json +HTTP 500 · 3.2s +{ + "message": "Error in workflow" +} + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 5 + T-2001 key 10057-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 + T-2002 key 10057-1 장비/낮음 새 모니터를 하나 더 받을 수 있을까요? + T-2003 key 10057-2 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요 + T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요 +DLQ: 0 (parked 0) +alerts: 0 + +$ uv run tools/flow.py fault tickets flaky +✔ fault switch: {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py call base-intake requests/batch-two.json +HTTP 500 · 2.5s +{ + "message": "Error in workflow" +} + +$ uv run tools/flow.py fault tickets ok +✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py call base-intake requests/batch-ok.json +HTTP 200 · 3.0s +{ + "processed": 3, + "tickets": [ + "T-2006", + "T-2008", + "T-2007" + ] +} + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 8 + T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요 + T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요 + T-2006 key 10060-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 + T-2007 key 10060-2 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2008 key 10060-1 장비/낮음 새 모니터를 하나 더 받을 수 있을까요? +DLQ: 0 (parked 0) +alerts: 0 diff --git a/Last3Mon/76/answers/02-lab01-before.json b/Last3Mon/76/answers/02-lab01-before.json new file mode 100644 index 0000000..680d310 --- /dev/null +++ b/Last3Mon/76/answers/02-lab01-before.json @@ -0,0 +1,236 @@ +{ + "cases": "lab01.json", + "webhook": "base-intake", + "passed": 0, + "total": 6, + "results": [ + { + "case": "정상 3건", + "webhook": "base-intake", + "run": 1, + "passed": false, + "problems": [ + "created = null (expected 3)", + "failed = null (expected 0)", + "rejected = null (expected 0)" + ], + "seconds": 2.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "processed": 3, + "tickets": [ + "T-2001", + "T-2002", + "T-2003" + ] + } + }, + { + "case": "같은 요청을 다시 보내도 중복 없음", + "webhook": "base-intake", + "run": 1, + "passed": false, + "problems": [ + "created = null (expected 3)", + "duplicates = null (expected 3)" + ], + "seconds": 3.1, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "processed": 3, + "tickets": [ + "T-2004", + "T-2005", + "T-2006" + ] + } + }, + { + "case": "만들어진 접수 건은 3개", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": false, + "problems": [ + "count = 6 (expected 3)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 6, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "10061-0", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "10061-1", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2003", + "key": "10061-2", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "10062-0", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2005", + "key": "10062-1", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2006", + "key": "10062-2", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + } + ] + } + }, + { + "case": "잘못된 항목은 그 항목만 거절", + "webhook": "base-intake", + "run": 1, + "passed": false, + "problems": [ + "status 500 (expected 200)", + "created = null (expected 2)", + "rejected = null (expected 1)", + "failed = null (expected 0)", + "results has none of ['text is required']" + ], + "seconds": 3.4, + "status": 500, + "request": { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + }, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "목록이 아니면 400", + "webhook": "base-intake", + "run": 1, + "passed": false, + "problems": [ + "status 500 (expected 400)" + ], + "seconds": 0.1, + "status": 500, + "request": { + "requests": "VPN이 끊겨요" + }, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "가끔 실패하는 API · 다시 해 보기로 성공", + "webhook": "base-intake", + "run": 1, + "passed": false, + "problems": [ + "status 500 (expected 200)", + "created = null (expected 2)", + "failed = null (expected 0)" + ], + "seconds": 2.0, + "status": 500, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "message": "Error in workflow" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/02-lab01-before.txt b/Last3Mon/76/answers/02-lab01-before.txt new file mode 100644 index 0000000..98323ba --- /dev/null +++ b/Last3Mon/76/answers/02-lab01-before.txt @@ -0,0 +1,264 @@ +$ uv run tools/flow.py test cases/lab01.json --webhook base-intake -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/base-intake · 6 case(s) × 1 +✘ 정상 3건 · 2.7s · created = null (expected 3); failed = null (expected 0); rejected = null (expected 0) + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "processed": 3, + "tickets": [ + "T-2001", + "T-2002", + "T-2003" + ] + } +✘ 같은 요청을 다시 보내도 중복 없음 · 3.1s · created = null (expected 3); duplicates = null (expected 3) + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "processed": 3, + "tickets": [ + "T-2004", + "T-2005", + "T-2006" + ] + } +✘ 만들어진 접수 건은 3개 · 0.0s · count = 6 (expected 3) + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 6, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "10061-0", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "10061-1", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2003", + "key": "10061-2", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "10062-0", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2005", + "key": "10062-1", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2006", + "key": "10062-2", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✘ 잘못된 항목은 그 항목만 거절 · 3.4s · status 500 (expected 200); created = null (expected 2); rejected = null (expected 1); failed = null (expected 0); results has none of ['text is required'] + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 500 + { + "message": "Error in workflow" + } +✘ 목록이 아니면 400 · 0.1s · status 500 (expected 400) + → request POST /webhook/base-intake + { + "requests": "VPN이 끊겨요" + } + ← response HTTP 500 + { + "message": "Error in workflow" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "flaky" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "flaky", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.0s · status 500 (expected 200); created = null (expected 2); failed = null (expected 0) + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 500 + { + "message": "Error in workflow" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +Passed 0/6 +report: runs/lab01-20261029-141145.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/03-lab01-intake.json b/Last3Mon/76/answers/03-lab01-intake.json new file mode 100644 index 0000000..0607230 --- /dev/null +++ b/Last3Mon/76/answers/03-lab01-intake.json @@ -0,0 +1,294 @@ +{ + "cases": "lab01.json", + "webhook": "my-intake", + "passed": 6, + "total": 6, + "results": [ + { + "case": "정상 3건", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.9, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } + }, + { + "case": "같은 요청을 다시 보내도 중복 없음", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.1, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 3, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": true, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": true, + "category": "소프트웨어", + "priority": "높음" + } + ] + } + }, + { + "case": "만들어진 접수 건은 3개", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 3, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + } + ] + } + }, + { + "case": "잘못된 항목은 그 항목만 거절", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + }, + "response": { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-203", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "소프트웨어", + "priority": "보통" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } + }, + { + "case": "목록이 아니면 400", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 400, + "request": { + "requests": "VPN이 끊겨요" + }, + "response": { + "error": "requests must be a list" + } + }, + { + "case": "가끔 실패하는 API · 다시 해 보기로 성공", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2006", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2007", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/03-lab01-intake.txt b/Last3Mon/76/answers/03-lab01-intake.txt new file mode 100644 index 0000000..d532c61 --- /dev/null +++ b/Last3Mon/76/answers/03-lab01-intake.txt @@ -0,0 +1,358 @@ +$ uv run tools/flow.py push reference/lab01-example2-intake.json +✔ pushed '76 Intake · retry and idempotency key (reference, Lab 01 example 2)' (id w8399f905b79a073) + POST http://localhost:5678/webhook/my-intake + +$ uv run tools/flow.py test cases/lab01.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 3.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 3, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": true, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": true, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✔ 만들어진 접수 건은 3개 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 3, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✔ 잘못된 항목은 그 항목만 거절 · 1.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-203", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "소프트웨어", + "priority": "보통" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } +✔ 목록이 아니면 400 · 0.0s + → request POST /webhook/my-intake + { + "requests": "VPN이 끊겨요" + } + ← response HTTP 400 + { + "error": "requests must be a list" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "flaky" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "flaky", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2006", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2007", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +Passed 6/6 +report: runs/lab01-20261029-141207.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 7 + T-2003 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2004 key R-201 장비/보통 프린터 용지가 걸려요 + T-2005 key R-203 소프트웨어/보통 메일 첨부가 안 열려요 + T-2006 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2007 key R-302 장비/보통 노트북 배터리가 금방 닳아요 +DLQ: 0 (parked 0) +alerts: 0 diff --git a/Last3Mon/76/answers/04-lab01-faults.json b/Last3Mon/76/answers/04-lab01-faults.json new file mode 100644 index 0000000..e0fde2b --- /dev/null +++ b/Last3Mon/76/answers/04-lab01-faults.json @@ -0,0 +1,280 @@ +{ + "cases": "lab01-faults.json", + "webhook": "my-intake", + "passed": 5, + "total": 5, + "results": [ + { + "case": "flaky · 다시 해 보면 됨", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + }, + { + "case": "error · 세 번 해도 실패", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } + }, + { + "case": "slow · 3초씩 세 번 기다리다 실패", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 12.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-102", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-103", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + } + ] + } + }, + { + "case": "실패라던 3건이 늦게 만들어짐 (2 + 3)", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2005", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + } + ] + } + }, + { + "case": "같은 요청을 다시 · 멱등 키로 중복 없음", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.9, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 3, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2005", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2004", + "duplicate": true, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": true, + "category": "소프트웨어", + "priority": "높음" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/04-lab01-faults.txt b/Last3Mon/76/answers/04-lab01-faults.txt new file mode 100644 index 0000000..83b5f57 --- /dev/null +++ b/Last3Mon/76/answers/04-lab01-faults.txt @@ -0,0 +1,375 @@ +$ uv run tools/flow.py test cases/lab01-faults.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 7 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "flaky" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "flaky", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ flaky · 다시 해 보면 됨 · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ error · 세 번 해도 실패 · 3.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "slow", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "slow" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "slow", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ slow · 3초씩 세 번 기다리다 실패 · 12.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-102", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-103", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + } + ] + } +… before: wait 8s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 실패라던 3건이 늦게 만들어짐 (2 + 3) · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2005", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + } + ] + } +✔ 같은 요청을 다시 · 멱등 키로 중복 없음 · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 3, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2005", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2004", + "duplicate": true, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": true, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +Passed 5/5 +report: runs/lab01-faults-20261029-141237.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 5 + T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요 + T-2003 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2004 key R-102 장비/낮음 새 모니터를 하나 더 받을 수 있을까요? + T-2005 key R-101 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 +DLQ: 0 (parked 0) +alerts: 0 diff --git a/Last3Mon/76/answers/05-lab01-challenge-before.json b/Last3Mon/76/answers/05-lab01-challenge-before.json new file mode 100644 index 0000000..55005a1 --- /dev/null +++ b/Last3Mon/76/answers/05-lab01-challenge-before.json @@ -0,0 +1,126 @@ +{ + "cases": "lab01-challenge.json", + "webhook": "my-intake", + "passed": 1, + "total": 3, + "results": [ + { + "case": "평소에는 분류됨", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.1, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + }, + { + "case": "LLM이 멈추면 미분류로 접수", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "status 500 (expected 200)", + "created = null (expected 3)", + "unclassified = null (expected 3)", + "failed = null (expected 0)" + ], + "seconds": 0.8, + "status": 500, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "미분류도 접수 건으로", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": false, + "problems": [ + "tickets has none of ['미분류']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/05-lab01-challenge-before.txt b/Last3Mon/76/answers/05-lab01-challenge-before.txt new file mode 100644 index 0000000..236bc44 --- /dev/null +++ b/Last3Mon/76/answers/05-lab01-challenge-before.txt @@ -0,0 +1,166 @@ +$ uv run tools/flow.py test cases/lab01-challenge.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1 +✔ 평소에는 분류됨 · 2.1s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up +✘ LLM이 멈추면 미분류로 접수 · 0.8s · status 500 (expected 200); created = null (expected 3); unclassified = null (expected 3); failed = null (expected 0) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 500 + { + "message": "Error in workflow" + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✘ 미분류도 접수 건으로 · 0.0s · tickets has none of ['미분류'] + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } +Passed 1/3 +report: runs/lab01-challenge-20261029-141245.json + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/06-lab01-challenge.json b/Last3Mon/76/answers/06-lab01-challenge.json new file mode 100644 index 0000000..59f6b6c --- /dev/null +++ b/Last3Mon/76/answers/06-lab01-challenge.json @@ -0,0 +1,174 @@ +{ + "cases": "lab01-challenge.json", + "webhook": "my-intake", + "passed": 3, + "total": 3, + "results": [ + { + "case": "평소에는 분류됨", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.5, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + }, + { + "case": "LLM이 멈추면 미분류로 접수", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.9, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 3, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "미분류", + "priority": "보통" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "미분류", + "priority": "보통" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "미분류", + "priority": "보통" + } + ] + } + }, + { + "case": "미분류도 접수 건으로", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "미분류", + "priority": "보통" + }, + { + "ticket_id": "T-2004", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "미분류", + "priority": "보통" + }, + { + "ticket_id": "T-2005", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "미분류", + "priority": "보통" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/06-lab01-challenge.txt b/Last3Mon/76/answers/06-lab01-challenge.txt new file mode 100644 index 0000000..0d0e554 --- /dev/null +++ b/Last3Mon/76/answers/06-lab01-challenge.txt @@ -0,0 +1,244 @@ +$ uv run tools/flow.py push answers/76-lab01-challenge-fallback.json +! unpublished '76 Intake · retry and idempotency key (reference, Lab 01 example 2)' (w8399f905b79a073): it used the same webhook path +✔ pushed '76 Intake · LLM fallback (answer, Lab 01 task 1)' (id w0f5e1ef11aa4c14) + POST http://localhost:5678/webhook/my-intake + +$ uv run tools/flow.py test cases/lab01-challenge.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1 +✔ 평소에는 분류됨 · 3.5s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up +✔ LLM이 멈추면 미분류로 접수 · 0.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 3, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "미분류", + "priority": "보통" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "미분류", + "priority": "보통" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "미분류", + "priority": "보통" + } + ] + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✔ 미분류도 접수 건으로 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "미분류", + "priority": "보통" + }, + { + "ticket_id": "T-2004", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "미분류", + "priority": "보통" + }, + { + "ticket_id": "T-2005", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "미분류", + "priority": "보통" + } + ] + } +Passed 3/3 +report: runs/lab01-challenge-20261029-141310.json + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py test cases/lab01.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 3.1s +✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s +✔ 만들어진 접수 건은 3개 · 0.0s +✔ 잘못된 항목은 그 항목만 거절 · 1.8s +✔ 목록이 아니면 400 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +Passed 6/6 +report: runs/lab01-20261029-141323.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} diff --git a/Last3Mon/76/answers/07-lab01-challenge2-before.json b/Last3Mon/76/answers/07-lab01-challenge2-before.json new file mode 100644 index 0000000..0b541b6 --- /dev/null +++ b/Last3Mon/76/answers/07-lab01-challenge2-before.json @@ -0,0 +1,427 @@ +{ + "cases": "lab01-challenge2.json", + "webhook": "my-intake", + "passed": 1, + "total": 4, + "results": [ + { + "case": "같은 id가 두 번이면 두 번째는 거절", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "created = 3 (expected 2)", + "rejected = 0 (expected 1)", + "duplicates = 1 (expected 0)", + "results has none of ['duplicate id']" + ], + "seconds": 2.8, + "status": 200, + "request": { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 1, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + } + ] + } + }, + { + "case": "11건은 400 (한 번에 10건까지)", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "status 200 (expected 400)", + "error has none of ['too many']" + ], + "seconds": 11.0, + "status": 200, + "request": { + "requests": [ + { + "id": "R-501", + "user": "박서연", + "text": "모니터 1번이 켜지지 않아요" + }, + { + "id": "R-502", + "user": "최유진", + "text": "모니터 2번이 켜지지 않아요" + }, + { + "id": "R-503", + "user": "정우성", + "text": "모니터 3번이 켜지지 않아요" + }, + { + "id": "R-504", + "user": "한지훈", + "text": "모니터 4번이 켜지지 않아요" + }, + { + "id": "R-505", + "user": "김민지", + "text": "모니터 5번이 켜지지 않아요" + }, + { + "id": "R-506", + "user": "이준호", + "text": "모니터 6번이 켜지지 않아요" + }, + { + "id": "R-507", + "user": "박서연", + "text": "모니터 7번이 켜지지 않아요" + }, + { + "id": "R-508", + "user": "최유진", + "text": "모니터 8번이 켜지지 않아요" + }, + { + "id": "R-509", + "user": "정우성", + "text": "모니터 9번이 켜지지 않아요" + }, + { + "id": "R-510", + "user": "한지훈", + "text": "모니터 10번이 켜지지 않아요" + }, + { + "id": "R-511", + "user": "김민지", + "text": "모니터 11번이 켜지지 않아요" + } + ] + }, + "response": { + "total": 11, + "created": 11, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-501", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-502", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-503", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-504", + "status": "created", + "ticket_id": "T-2006", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-505", + "status": "created", + "ticket_id": "T-2007", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-506", + "status": "created", + "ticket_id": "T-2008", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-507", + "status": "created", + "ticket_id": "T-2013", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-508", + "status": "created", + "ticket_id": "T-2012", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-509", + "status": "created", + "ticket_id": "T-2010", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-510", + "status": "created", + "ticket_id": "T-2011", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-511", + "status": "created", + "ticket_id": "T-2009", + "duplicate": false, + "category": "장비", + "priority": "높음" + } + ] + } + }, + { + "case": "400이면 하나도 만들지 않음", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": false, + "problems": [ + "count = 13 (expected 2)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 13, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-401", + "title": "회의실 화면 공유가 안 돼요", + "requester": "김민지", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-402", + "title": "비밀번호를 바꾸라는 창이 계속 떠요", + "requester": "최유진", + "category": "계정", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-502", + "title": "모니터 2번이 켜지지 않아요", + "requester": "최유진", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-501", + "title": "모니터 1번이 켜지지 않아요", + "requester": "박서연", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2005", + "key": "R-503", + "title": "모니터 3번이 켜지지 않아요", + "requester": "정우성", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2006", + "key": "R-504", + "title": "모니터 4번이 켜지지 않아요", + "requester": "한지훈", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2007", + "key": "R-505", + "title": "모니터 5번이 켜지지 않아요", + "requester": "김민지", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2008", + "key": "R-506", + "title": "모니터 6번이 켜지지 않아요", + "requester": "이준호", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2009", + "key": "R-511", + "title": "모니터 11번이 켜지지 않아요", + "requester": "김민지", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2010", + "key": "R-509", + "title": "모니터 9번이 켜지지 않아요", + "requester": "정우성", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2011", + "key": "R-510", + "title": "모니터 10번이 켜지지 않아요", + "requester": "한지훈", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2012", + "key": "R-508", + "title": "모니터 8번이 켜지지 않아요", + "requester": "최유진", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2013", + "key": "R-507", + "title": "모니터 7번이 켜지지 않아요", + "requester": "박서연", + "category": "장비", + "priority": "높음" + } + ] + } + }, + { + "case": "평소 요청은 그대로", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.2, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2014", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2015", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2016", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/07-lab01-challenge2-before.txt b/Last3Mon/76/answers/07-lab01-challenge2-before.txt new file mode 100644 index 0000000..85e0fbf --- /dev/null +++ b/Last3Mon/76/answers/07-lab01-challenge2-before.txt @@ -0,0 +1,446 @@ +$ uv run tools/flow.py test cases/lab01-challenge2.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 7 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✘ 같은 id가 두 번이면 두 번째는 거절 · 2.8s · created = 3 (expected 2); rejected = 0 (expected 1); duplicates = 1 (expected 0); results has none of ['duplicate id'] + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 1, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + } + ] + } +✘ 11건은 400 (한 번에 10건까지) · 11.0s · status 200 (expected 400); error has none of ['too many'] + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-501", + "user": "박서연", + "text": "모니터 1번이 켜지지 않아요" + }, + { + "id": "R-502", + "user": "최유진", + "text": "모니터 2번이 켜지지 않아요" + }, + { + "id": "R-503", + "user": "정우성", + "text": "모니터 3번이 켜지지 않아요" + }, + { + "id": "R-504", + "user": "한지훈", + "text": "모니터 4번이 켜지지 않아요" + }, + { + "id": "R-505", + "user": "김민지", + "text": "모니터 5번이 켜지지 않아요" + }, + { + "id": "R-506", + "user": "이준호", + "text": "모니터 6번이 켜지지 않아요" + }, + { + "id": "R-507", + "user": "박서연", + "text": "모니터 7번이 켜지지 않아요" + }, + { + "id": "R-508", + "user": "최유진", + "text": "모니터 8번이 켜지지 않아요" + }, + { + "id": "R-509", + "user": "정우성", + "text": "모니터 9번이 켜지지 않아요" + }, + { + "id": "R-510", + "user": "한지훈", + "text": "모니터 10번이 켜지지 않아요" + }, + { + "id": "R-511", + "user": "김민지", + "text": "모니터 11번이 켜지지 않아요" + } + ] + } + ← response HTTP 200 + { + "total": 11, + "created": 11, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-501", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-502", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-503", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-504", + "status": "created", + "ticket_id": "T-2006", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-505", + "status": "created", + "ticket_id": "T-2007", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-506", + "status": "created", + "ticket_id": "T-2008", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-507", + "status": "created", + "ticket_id": "T-2013", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-508", + "status": "created", + "ticket_id": "T-2012", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-509", + "status": "created", + "ticket_id": "T-2010", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-510", + "status": "created", + "ticket_id": "T-2011", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-511", + "status": "created", + "ticket_id": "T-2009", + "duplicate": false, + "category": "장비", + "priority": "높음" + } + ] + } +✘ 400이면 하나도 만들지 않음 · 0.0s · count = 13 (expected 2) + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 13, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-401", + "title": "회의실 화면 공유가 안 돼요", + "requester": "김민지", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-402", + "title": "비밀번호를 바꾸라는 창이 계속 떠요", + "requester": "최유진", + "category": "계정", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-502", + "title": "모니터 2번이 켜지지 않아요", + "requester": "최유진", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-501", + "title": "모니터 1번이 켜지지 않아요", + "requester": "박서연", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2005", + "key": "R-503", + "title": "모니터 3번이 켜지지 않아요", + "requester": "정우성", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2006", + "key": "R-504", + "title": "모니터 4번이 켜지지 않아요", + "requester": "한지훈", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2007", + "key": "R-505", + "title": "모니터 5번이 켜지지 않아요", + "requester": "김민지", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2008", + "key": "R-506", + "title": "모니터 6번이 켜지지 않아요", + "requester": "이준호", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2009", + "key": "R-511", + "title": "모니터 11번이 켜지지 않아요", + "requester": "김민지", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2010", + "key": "R-509", + "title": "모니터 9번이 켜지지 않아요", + "requester": "정우성", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2011", + "key": "R-510", + "title": "모니터 10번이 켜지지 않아요", + "requester": "한지훈", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2012", + "key": "R-508", + "title": "모니터 8번이 켜지지 않아요", + "requester": "최유진", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2013", + "key": "R-507", + "title": "모니터 7번이 켜지지 않아요", + "requester": "박서연", + "category": "장비", + "priority": "높음" + } + ] + } +✔ 평소 요청은 그대로 · 3.2s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2014", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2015", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2016", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +Passed 1/4 +report: runs/lab01-challenge2-20261029-141341.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/08-lab01-challenge2.json b/Last3Mon/76/answers/08-lab01-challenge2.json new file mode 100644 index 0000000..2b93e67 --- /dev/null +++ b/Last3Mon/76/answers/08-lab01-challenge2.json @@ -0,0 +1,231 @@ +{ + "cases": "lab01-challenge2.json", + "webhook": "my-intake", + "passed": 4, + "total": 4, + "results": [ + { + "case": "같은 id가 두 번이면 두 번째는 거절", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + }, + "response": { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ] + } + }, + { + "case": "11건은 400 (한 번에 10건까지)", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.2, + "status": 400, + "request": { + "requests": [ + { + "id": "R-501", + "user": "박서연", + "text": "모니터 1번이 켜지지 않아요" + }, + { + "id": "R-502", + "user": "최유진", + "text": "모니터 2번이 켜지지 않아요" + }, + { + "id": "R-503", + "user": "정우성", + "text": "모니터 3번이 켜지지 않아요" + }, + { + "id": "R-504", + "user": "한지훈", + "text": "모니터 4번이 켜지지 않아요" + }, + { + "id": "R-505", + "user": "김민지", + "text": "모니터 5번이 켜지지 않아요" + }, + { + "id": "R-506", + "user": "이준호", + "text": "모니터 6번이 켜지지 않아요" + }, + { + "id": "R-507", + "user": "박서연", + "text": "모니터 7번이 켜지지 않아요" + }, + { + "id": "R-508", + "user": "최유진", + "text": "모니터 8번이 켜지지 않아요" + }, + { + "id": "R-509", + "user": "정우성", + "text": "모니터 9번이 켜지지 않아요" + }, + { + "id": "R-510", + "user": "한지훈", + "text": "모니터 10번이 켜지지 않아요" + }, + { + "id": "R-511", + "user": "김민지", + "text": "모니터 11번이 켜지지 않아요" + } + ] + }, + "response": { + "error": "too many requests (max 10)" + } + }, + { + "case": "400이면 하나도 만들지 않음", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-401", + "title": "회의실 화면 공유가 안 돼요", + "requester": "김민지", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-402", + "title": "비밀번호를 바꾸라는 창이 계속 떠요", + "requester": "최유진", + "category": "계정", + "priority": "보통" + } + ] + } + }, + { + "case": "평소 요청은 그대로", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/08-lab01-challenge2.txt b/Last3Mon/76/answers/08-lab01-challenge2.txt new file mode 100644 index 0000000..9746ca4 --- /dev/null +++ b/Last3Mon/76/answers/08-lab01-challenge2.txt @@ -0,0 +1,299 @@ +$ uv run tools/flow.py push answers/76-lab01-challenge2-rules.json +! unpublished '76 Intake · LLM fallback (answer, Lab 01 task 1)' (w0f5e1ef11aa4c14): it used the same webhook path +✔ pushed '76 Intake · request rules (answer, Lab 01 task 2)' (id w4bb85fc171aea9d) + POST http://localhost:5678/webhook/my-intake + +$ uv run tools/flow.py test cases/lab01-challenge2.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 16} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 16 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ 같은 id가 두 번이면 두 번째는 거절 · 3.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ] + } +✔ 11건은 400 (한 번에 10건까지) · 0.2s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-501", + "user": "박서연", + "text": "모니터 1번이 켜지지 않아요" + }, + { + "id": "R-502", + "user": "최유진", + "text": "모니터 2번이 켜지지 않아요" + }, + { + "id": "R-503", + "user": "정우성", + "text": "모니터 3번이 켜지지 않아요" + }, + { + "id": "R-504", + "user": "한지훈", + "text": "모니터 4번이 켜지지 않아요" + }, + { + "id": "R-505", + "user": "김민지", + "text": "모니터 5번이 켜지지 않아요" + }, + { + "id": "R-506", + "user": "이준호", + "text": "모니터 6번이 켜지지 않아요" + }, + { + "id": "R-507", + "user": "박서연", + "text": "모니터 7번이 켜지지 않아요" + }, + { + "id": "R-508", + "user": "최유진", + "text": "모니터 8번이 켜지지 않아요" + }, + { + "id": "R-509", + "user": "정우성", + "text": "모니터 9번이 켜지지 않아요" + }, + { + "id": "R-510", + "user": "한지훈", + "text": "모니터 10번이 켜지지 않아요" + }, + { + "id": "R-511", + "user": "김민지", + "text": "모니터 11번이 켜지지 않아요" + } + ] + } + ← response HTTP 400 + { + "error": "too many requests (max 10)" + } +✔ 400이면 하나도 만들지 않음 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-401", + "title": "회의실 화면 공유가 안 돼요", + "requester": "김민지", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-402", + "title": "비밀번호를 바꾸라는 창이 계속 떠요", + "requester": "최유진", + "category": "계정", + "priority": "보통" + } + ] + } +✔ 평소 요청은 그대로 · 3.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +Passed 4/4 +report: runs/lab01-challenge2-20261029-141401.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py test cases/lab01.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 2.8s +✔ 같은 요청을 다시 보내도 중복 없음 · 2.8s +✔ 만들어진 접수 건은 3개 · 0.0s +✔ 잘못된 항목은 그 항목만 거절 · 1.8s +✔ 목록이 아니면 400 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +Passed 6/6 +report: runs/lab01-20261029-141411.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py test cases/lab01-challenge.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1 +✔ 평소에는 분류됨 · 2.0s +✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up +✔ LLM이 멈추면 미분류로 접수 · 1.4s +✔ OpenAI credential: real key from .env restored +✔ 미분류도 접수 건으로 · 0.0s +Passed 3/3 +report: runs/lab01-challenge-20261029-141421.json +✔ OpenAI credential: real key from .env restored +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} diff --git a/Last3Mon/76/answers/09-lab02-before.json b/Last3Mon/76/answers/09-lab02-before.json new file mode 100644 index 0000000..472dd85 --- /dev/null +++ b/Last3Mon/76/answers/09-lab02-before.json @@ -0,0 +1,98 @@ +{ + "cases": "lab02.json", + "webhook": "my-intake", + "passed": 2, + "total": 4, + "results": [ + { + "case": "예제 1 · API가 멈추면 실패로 표시 · 200", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.8, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } + }, + { + "case": "예제 1 · 실패한 2건은 DLQ에", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": false, + "problems": [ + "size = 0 (expected 2)", + "items has none of ['R-301', 'R-302']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 0, + "parked": 0, + "items": [] + } + }, + { + "case": "예제 2 · 예상 못 한 오류 (nightly-report)", + "webhook": "nightly-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 500, + "request": {}, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "예제 2 · Error Workflow가 알림을 남김", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": false, + "problems": [ + "severity = null (expected critical)", + "reason = null (expected unexpected)", + "workflow = null (expected 76 Nightly report (provided, has a bug))", + "node has none of ['Count by category']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": {} + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/09-lab02-before.txt b/Last3Mon/76/answers/09-lab02-before.txt new file mode 100644 index 0000000..d410892 --- /dev/null +++ b/Last3Mon/76/answers/09-lab02-before.txt @@ -0,0 +1,196 @@ +$ uv run tools/flow.py ops --reset +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +$ uv run tools/flow.py fault tickets error +✔ fault switch: {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py call my-intake requests/batch-two.json +HTTP 200 · 2.8s +{ + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] +} + +$ uv run tools/flow.py fault tickets ok +✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 0 +DLQ: 0 (parked 0) +alerts: 0 + +$ uv run tools/flow.py call nightly-report +HTTP 500 · 0.1s +{ + "message": "Error in workflow" +} + +$ uv run tools/flow.py test cases/lab02.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 예제 1 · 실패한 2건은 DLQ에 · 0.0s · size = 0 (expected 2); items has none of ['R-301', 'R-302'] + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 0, + "parked": 0, + "items": [] + } +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); reason = null (expected unexpected); workflow = null (expected 76 Nightly report (provided, has a bug)); node has none of ['Count by category'] + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +Passed 2/4 +report: runs/lab02-20261029-141433.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/10-lab02-dlq.json b/Last3Mon/76/answers/10-lab02-dlq.json new file mode 100644 index 0000000..887e9b4 --- /dev/null +++ b/Last3Mon/76/answers/10-lab02-dlq.json @@ -0,0 +1,122 @@ +{ + "cases": "lab02.json", + "webhook": "my-intake", + "passed": 3, + "total": 4, + "results": [ + { + "case": "예제 1 · API가 멈추면 실패로 표시 · 200", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.1, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } + }, + { + "case": "예제 1 · 실패한 2건은 DLQ에", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250889.918716 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250889.918718 + } + ] + } + }, + { + "case": "예제 2 · 예상 못 한 오류 (nightly-report)", + "webhook": "nightly-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 500, + "request": {}, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "예제 2 · Error Workflow가 알림을 남김", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": false, + "problems": [ + "severity = null (expected critical)", + "reason = null (expected unexpected)", + "workflow = null (expected 76 Nightly report (provided, has a bug))", + "node has none of ['Count by category']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": {} + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/10-lab02-dlq.txt b/Last3Mon/76/answers/10-lab02-dlq.txt new file mode 100644 index 0000000..c52ab37 --- /dev/null +++ b/Last3Mon/76/answers/10-lab02-dlq.txt @@ -0,0 +1,192 @@ +$ uv run tools/flow.py push reference/lab02-example1-dlq.json +! unpublished '76 Intake · request rules (answer, Lab 01 task 2)' (w4bb85fc171aea9d): it used the same webhook path +✔ pushed '76 Intake · DLQ (reference, Lab 02 example 1, includes Lab 01 tasks)' (id w92de92194fc1657) + POST http://localhost:5678/webhook/my-intake + +$ uv run tools/flow.py test cases/lab02.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.1s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250889.918716 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250889.918718 + } + ] + } +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); reason = null (expected unexpected); workflow = null (expected 76 Nightly report (provided, has a bug)); node has none of ['Count by category'] + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +Passed 3/4 +report: runs/lab02-20261029-141452.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 0 diff --git a/Last3Mon/76/answers/11-lab02-error-workflow.json b/Last3Mon/76/answers/11-lab02-error-workflow.json new file mode 100644 index 0000000..263c357 --- /dev/null +++ b/Last3Mon/76/answers/11-lab02-error-workflow.json @@ -0,0 +1,126 @@ +{ + "cases": "lab02.json", + "webhook": "my-intake", + "passed": 4, + "total": 4, + "results": [ + { + "case": "예제 1 · API가 멈추면 실패로 표시 · 200", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.8, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } + }, + { + "case": "예제 1 · 실패한 2건은 DLQ에", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250914.161773 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250914.161775 + } + ] + } + }, + { + "case": "예제 2 · 예상 못 한 오류 (nightly-report)", + "webhook": "nightly-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 500, + "request": {}, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "예제 2 · Error Workflow가 알림을 남김", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10104", + "severity": "critical", + "reason": "unexpected", + "at": 1793250914.256605 + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/11-lab02-error-workflow.txt b/Last3Mon/76/answers/11-lab02-error-workflow.txt new file mode 100644 index 0000000..60bd5e0 --- /dev/null +++ b/Last3Mon/76/answers/11-lab02-error-workflow.txt @@ -0,0 +1,221 @@ +$ uv run tools/flow.py push reference/lab02-example2-error-handler.json reference/lab02-example2-intake.json +! unpublished '76 Intake · DLQ (reference, Lab 02 example 1, includes Lab 01 tasks)' (w92de92194fc1657): it used the same webhook path +✔ pushed '76 Error handler (reference)' (id errHandler76001) +✔ pushed '76 Intake · DLQ + error workflow (reference, Lab 02 example 2)' (id w01376519d41c1ce) + POST http://localhost:5678/webhook/my-intake + +$ uv run tools/flow.py test cases/lab02.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250914.161773 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250914.161775 + } + ] + } +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10104", + "severity": "critical", + "reason": "unexpected", + "at": 1793250914.256605 + } +Passed 4/4 +report: runs/lab02-20261029-141517.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 1 + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] + +$ uv run tools/flow.py test cases/lab01.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 2.9s +✔ 같은 요청을 다시 보내도 중복 없음 · 2.6s +✔ 만들어진 접수 건은 3개 · 0.0s +✔ 잘못된 항목은 그 항목만 거절 · 1.9s +✔ 목록이 아니면 400 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.5s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +Passed 6/6 +report: runs/lab01-20261029-141527.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} diff --git a/Last3Mon/76/answers/12-lab02-nets.json b/Last3Mon/76/answers/12-lab02-nets.json new file mode 100644 index 0000000..a4f819a --- /dev/null +++ b/Last3Mon/76/answers/12-lab02-nets.json @@ -0,0 +1,262 @@ +{ + "cases": "lab02-nets.json", + "webhook": "my-intake", + "passed": 5, + "total": 7, + "results": [ + { + "case": "1 · 잘못된 항목 → 결과에만 (rejected)", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + }, + "response": { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-203", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "소프트웨어", + "priority": "보통" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } + }, + { + "case": "2 · API 오류 → failed", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.8, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } + }, + { + "case": "3 · failed는 DLQ에", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250932.1935513 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250932.1935537 + } + ] + } + }, + { + "case": "4 · 버그 → 500", + "webhook": "nightly-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 500, + "request": {}, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "5 · Error Workflow → critical 알림", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10114", + "severity": "critical", + "reason": "unexpected", + "at": 1793250932.293378 + } + }, + { + "case": "6 · DLQ마저 멈추면 결과가 알려야", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "dlq_saved = null (expected false)" + ], + "seconds": 4.1, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } + }, + { + "case": "7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down)", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": false, + "problems": [ + "reason = unexpected (expected dlq_down)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10114", + "severity": "critical", + "reason": "unexpected", + "at": 1793250932.293378 + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/12-lab02-nets.txt b/Last3Mon/76/answers/12-lab02-nets.txt new file mode 100644 index 0000000..1a1dae2 --- /dev/null +++ b/Last3Mon/76/answers/12-lab02-nets.txt @@ -0,0 +1,310 @@ +$ uv run tools/flow.py test cases/lab02-nets.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 7 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 1.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-203", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "소프트웨어", + "priority": "보통" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · API 오류 → failed · 2.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ 3 · failed는 DLQ에 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250932.1935513 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250932.1935537 + } + ] + } +✔ 4 · 버그 → 500 · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 5 · Error Workflow → critical 알림 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10114", + "severity": "critical", + "reason": "unexpected", + "at": 1793250932.293378 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✘ 6 · DLQ마저 멈추면 결과가 알려야 · 4.1s · dlq_saved = null (expected false) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✘ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s · reason = unexpected (expected dlq_down) + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10114", + "severity": "critical", + "reason": "unexpected", + "at": 1793250932.293378 + } +Passed 5/7 +report: runs/lab02-nets-20261029-141539.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 2 + T-2001 key R-201 장비/보통 프린터 용지가 걸려요 + T-2002 key R-203 소프트웨어/보통 메일 첨부가 안 열려요 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 1 + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] diff --git a/Last3Mon/76/answers/13-lab02-challenge-before.json b/Last3Mon/76/answers/13-lab02-challenge-before.json new file mode 100644 index 0000000..7556d2d --- /dev/null +++ b/Last3Mon/76/answers/13-lab02-challenge-before.json @@ -0,0 +1,221 @@ +{ + "cases": "lab02-challenge.json", + "webhook": "my-intake", + "passed": 0, + "total": 6, + "results": [ + { + "case": "API가 멈춤 → DLQ", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "dlq_saved = null (expected true)", + "alert = null (expected sent)" + ], + "seconds": 2.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } + }, + { + "case": "처리한 실패도 알림 (high · 개수)", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": false, + "problems": [ + "severity = null (expected high)", + "reason = null (expected dlq)", + "count = null (expected 2)", + "workflow = null (expected intake)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": {} + }, + { + "case": "실패가 없으면 알림 없음", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "alert = null (expected none)" + ], + "seconds": 2.8, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } + }, + { + "case": "알림은 그대로 1개", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": false, + "problems": [ + "count = 0 (expected 1)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 0, + "alerts": [] + } + }, + { + "case": "DLQ도 멈추면 critical", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "dlq_saved = null (expected false)" + ], + "seconds": 2.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } + }, + { + "case": "잃어버린 id를 알림에", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": false, + "problems": [ + "severity = null (expected critical)", + "reason = null (expected dlq_down)", + "count = null (expected 2)", + "message has none of ['R-201']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": {} + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/13-lab02-challenge-before.txt b/Last3Mon/76/answers/13-lab02-challenge-before.txt new file mode 100644 index 0000000..4465dc8 --- /dev/null +++ b/Last3Mon/76/answers/13-lab02-challenge-before.txt @@ -0,0 +1,284 @@ +$ uv run tools/flow.py test cases/lab02-challenge.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ API가 멈춤 → DLQ · 2.7s · dlq_saved = null (expected true); alert = null (expected sent) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 처리한 실패도 알림 (high · 개수) · 0.0s · severity = null (expected high); reason = null (expected dlq); count = null (expected 2); workflow = null (expected intake) + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +✘ 실패가 없으면 알림 없음 · 2.8s · alert = null (expected none) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✘ 알림은 그대로 1개 · 0.0s · count = 0 (expected 1) + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 0, + "alerts": [] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✘ DLQ도 멈추면 critical · 2.4s · dlq_saved = null (expected false) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 잃어버린 id를 알림에 · 0.0s · severity = null (expected critical); reason = null (expected dlq_down); count = null (expected 2); message has none of ['R-201'] + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +Passed 0/6 +report: runs/lab02-challenge-20261029-141547.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/14-lab02-challenge.json b/Last3Mon/76/answers/14-lab02-challenge.json new file mode 100644 index 0000000..3785028 --- /dev/null +++ b/Last3Mon/76/answers/14-lab02-challenge.json @@ -0,0 +1,240 @@ +{ + "cases": "lab02-challenge.json", + "webhook": "my-intake", + "passed": 6, + "total": 6, + "results": [ + { + "case": "API가 멈춤 → DLQ", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.3, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "처리한 실패도 알림 (high · 개수)", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10120", + "at": 1793250965.2341518 + } + }, + { + "case": "실패가 없으면 알림 없음", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.9, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ], + "alert": "none" + } + }, + { + "case": "알림은 그대로 1개", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 1, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10120", + "at": 1793250965.2341518 + } + ] + } + }, + { + "case": "DLQ도 멈추면 critical", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } + }, + { + "case": "잃어버린 id를 알림에", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "critical", + "reason": "dlq_down", + "count": 2, + "message": "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203", + "execution_id": "10122", + "ids": [ + "R-201", + "R-203" + ], + "at": 1793250970.5394928 + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/14-lab02-challenge.txt b/Last3Mon/76/answers/14-lab02-challenge.txt new file mode 100644 index 0000000..55f611c --- /dev/null +++ b/Last3Mon/76/answers/14-lab02-challenge.txt @@ -0,0 +1,374 @@ +$ uv run tools/flow.py push answers/76-lab02-challenge-alert.json +! unpublished '76 Intake · DLQ + error workflow (reference, Lab 02 example 2)' (w01376519d41c1ce): it used the same webhook path +✔ pushed '76 Intake · alert on handled failures (answer, Lab 02 task 1)' (id w82959c505ef75f7) + POST http://localhost:5678/webhook/my-intake + +$ uv run tools/flow.py test cases/lab02-challenge.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 멈춤 → DLQ · 4.3s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 처리한 실패도 알림 (high · 개수) · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10120", + "at": 1793250965.2341518 + } +✔ 실패가 없으면 알림 없음 · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ], + "alert": "none" + } +✔ 알림은 그대로 1개 · 0.0s + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 1, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10120", + "at": 1793250965.2341518 + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✔ DLQ도 멈추면 critical · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 잃어버린 id를 알림에 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "critical", + "reason": "dlq_down", + "count": 2, + "message": "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203", + "execution_id": "10122", + "ids": [ + "R-201", + "R-203" + ], + "at": 1793250970.5394928 + } +Passed 6/6 +report: runs/lab02-challenge-20261029-141610.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py test cases/lab02-nets.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 2.1s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 2 · API 오류 → failed · 2.9s +✔ 3 · failed는 DLQ에 · 0.0s +✔ 4 · 버그 → 500 · 0.1s +… before: wait 3s +✔ 5 · Error Workflow → critical 알림 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} +✔ 6 · DLQ마저 멈추면 결과가 알려야 · 3.7s +✔ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s +Passed 7/7 +report: runs/lab02-nets-20261029-141622.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py test cases/lab02.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.7s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s +… before: wait 3s +✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s +Passed 4/4 +report: runs/lab02-20261029-141628.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 2 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] diff --git a/Last3Mon/76/answers/15-lab02-challenge2-before.json b/Last3Mon/76/answers/15-lab02-challenge2-before.json new file mode 100644 index 0000000..0da33d7 --- /dev/null +++ b/Last3Mon/76/answers/15-lab02-challenge2-before.json @@ -0,0 +1,345 @@ +{ + "cases": "lab02-challenge2.json", + "webhook": "my-intake", + "passed": 4, + "total": 6, + "results": [ + { + "case": "첫 실패 → 알림 보냄", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.9, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "10분 안에 또 실패 → 보내지 않음", + "webhook": "my-intake", + "run": 1, + "passed": false, + "problems": [ + "alert = sent (expected suppressed)" + ], + "seconds": 3.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "dlq 알림은 1개", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": false, + "problems": [ + "count = 2 (expected 1)" + ], + "seconds": 0.0, + "status": 200, + "request": { + "reason": "dlq" + }, + "response": { + "count": 2, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10131", + "at": 1793250991.527381 + }, + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 3, + "message": "3 request(s) could not be created and wait in the DLQ", + "execution_id": "10132", + "at": 1793250995.206434 + } + ] + } + }, + { + "case": "그래도 DLQ에는 5건 모두", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 5, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250991.5196192 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250991.5196211 + }, + { + "dlq_id": "D-3", + "attempts": 1, + "parked": false, + "item": { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972318 + }, + { + "dlq_id": "D-4", + "attempts": 1, + "parked": false, + "item": { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972342 + }, + { + "dlq_id": "D-5", + "attempts": 1, + "parked": false, + "item": { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972353 + } + ] + } + }, + { + "case": "DLQ가 멈춘 것은 늘 보냄 (critical)", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.5, + "status": 200, + "request": { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } + }, + { + "case": "고친 뒤 · 실패 없음 · 알림 없음", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.0, + "status": 200, + "request": { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + }, + "response": { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ], + "alert": "none" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/15-lab02-challenge2-before.txt b/Last3Mon/76/answers/15-lab02-challenge2-before.txt new file mode 100644 index 0000000..5e9a958 --- /dev/null +++ b/Last3Mon/76/answers/15-lab02-challenge2-before.txt @@ -0,0 +1,405 @@ +$ uv run tools/flow.py test cases/lab02-challenge2.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 첫 실패 → 알림 보냄 · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✘ 10분 안에 또 실패 → 보내지 않음 · 3.7s · alert = sent (expected suppressed) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✘ dlq 알림은 1개 · 0.0s · count = 2 (expected 1) + → request POST http://localhost:8800/alerts/list + { + "reason": "dlq" + } + ← response HTTP 200 + { + "count": 2, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10131", + "at": 1793250991.527381 + }, + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 3, + "message": "3 request(s) could not be created and wait in the DLQ", + "execution_id": "10132", + "at": 1793250995.206434 + } + ] + } +✔ 그래도 DLQ에는 5건 모두 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 5, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250991.5196192 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250991.5196211 + }, + { + "dlq_id": "D-3", + "attempts": 1, + "parked": false, + "item": { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972318 + }, + { + "dlq_id": "D-4", + "attempts": 1, + "parked": false, + "item": { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972342 + }, + { + "dlq_id": "D-5", + "attempts": 1, + "parked": false, + "item": { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972353 + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.5s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.0s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ], + "alert": "none" + } +Passed 4/6 +report: runs/lab02-challenge2-20261029-141639.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/16-lab02-challenge2.json b/Last3Mon/76/answers/16-lab02-challenge2.json new file mode 100644 index 0000000..6af2e2a --- /dev/null +++ b/Last3Mon/76/answers/16-lab02-challenge2.json @@ -0,0 +1,331 @@ +{ + "cases": "lab02-challenge2.json", + "webhook": "my-intake", + "passed": 6, + "total": 6, + "results": [ + { + "case": "첫 실패 → 알림 보냄", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.5, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "10분 안에 또 실패 → 보내지 않음", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "suppressed" + } + }, + { + "case": "dlq 알림은 1개", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "reason": "dlq" + }, + "response": { + "count": 1, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10135", + "at": 1793251017.2530532 + } + ] + } + }, + { + "case": "그래도 DLQ에는 5건 모두", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 5, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251017.2315063 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251017.2315085 + }, + { + "dlq_id": "D-3", + "attempts": 1, + "parked": false, + "item": { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.9757996 + }, + { + "dlq_id": "D-4", + "attempts": 1, + "parked": false, + "item": { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.9758017 + }, + { + "dlq_id": "D-5", + "attempts": 1, + "parked": false, + "item": { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.975803 + } + ] + } + }, + { + "case": "DLQ가 멈춘 것은 늘 보냄 (critical)", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + }, + "response": { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } + }, + { + "case": "고친 뒤 · 실패 없음 · 알림 없음", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.8, + "status": 200, + "request": { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + }, + "response": { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "보통" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ], + "alert": "none" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/16-lab02-challenge2.txt b/Last3Mon/76/answers/16-lab02-challenge2.txt new file mode 100644 index 0000000..d898c99 --- /dev/null +++ b/Last3Mon/76/answers/16-lab02-challenge2.txt @@ -0,0 +1,445 @@ +$ uv run tools/flow.py push answers/76-lab02-challenge2-throttle.json +! unpublished '76 Intake · alert on handled failures (answer, Lab 02 task 1)' (w82959c505ef75f7): it used the same webhook path +✔ pushed '76 Intake · alert once per 10 minutes (answer, Lab 02 task 2)' (id w75a95815facf2d6) + POST http://localhost:5678/webhook/my-intake + +$ uv run tools/flow.py test cases/lab02-challenge2.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 첫 실패 → 알림 보냄 · 4.5s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 10분 안에 또 실패 → 보내지 않음 · 3.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "suppressed" + } +✔ dlq 알림은 1개 · 0.0s + → request POST http://localhost:8800/alerts/list + { + "reason": "dlq" + } + ← response HTTP 200 + { + "count": 1, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10135", + "at": 1793251017.2530532 + } + ] + } +✔ 그래도 DLQ에는 5건 모두 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 5, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251017.2315063 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251017.2315085 + }, + { + "dlq_id": "D-3", + "attempts": 1, + "parked": false, + "item": { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.9757996 + }, + { + "dlq_id": "D-4", + "attempts": 1, + "parked": false, + "item": { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.9758017 + }, + { + "dlq_id": "D-5", + "attempts": 1, + "parked": false, + "item": { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.975803 + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "보통" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ], + "alert": "none" + } +Passed 6/6 +report: runs/lab02-challenge2-20261029-141706.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py test cases/lab02-challenge.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 5} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ API가 멈춤 → DLQ · 2.7s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 처리한 실패도 알림 (high · 개수) · 0.0s +✔ 실패가 없으면 알림 없음 · 2.9s +✔ 알림은 그대로 1개 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} +✔ DLQ도 멈추면 critical · 2.3s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 잃어버린 id를 알림에 · 0.0s +Passed 6/6 +report: runs/lab02-challenge-20261029-141714.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py test cases/lab01-challenge2.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ 같은 id가 두 번이면 두 번째는 거절 · 2.1s +✔ 11건은 400 (한 번에 10건까지) · 0.0s +✔ 400이면 하나도 만들지 않음 · 0.0s +✔ 평소 요청은 그대로 · 2.7s +Passed 4/4 +report: runs/lab01-challenge2-20261029-141719.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 5 + T-2001 key R-401 네트워크/높음 회의실 화면 공유가 안 돼요 + T-2002 key R-402 계정/보통 비밀번호를 바꾸라는 창이 계속 떠요 + T-2003 key R-101 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 + T-2004 key R-102 장비/보통 새 모니터를 하나 더 받을 수 있을까요? + T-2005 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 +DLQ: 0 (parked 0) +alerts: 0 diff --git a/Last3Mon/76/answers/17-lab03-before.json b/Last3Mon/76/answers/17-lab03-before.json new file mode 100644 index 0000000..9e28de0 --- /dev/null +++ b/Last3Mon/76/answers/17-lab03-before.json @@ -0,0 +1,151 @@ +{ + "cases": "lab03.json", + "webhook": "my-intake", + "passed": 1, + "total": 5, + "results": [ + { + "case": "API가 멈춘 동안 2건 → DLQ", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.9, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "고친 뒤 다시 돌리기", + "webhook": "my-replay", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "replayed = null (expected 2)", + "ok = null (expected 2)", + "still_failed = null (expected 0)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-replay\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "DLQ가 비었음", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": false, + "problems": [ + "size = 2 (expected 0)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251042.6309726 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251042.630976 + } + ] + } + }, + { + "case": "접수 건은 2개 (중복 없음)", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": false, + "problems": [ + "count = 0 (expected 2)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 0, + "tickets": [] + } + }, + { + "case": "한 번 더 돌려도 할 일 없음", + "webhook": "my-replay", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "replayed = null (expected 0)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-replay\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/17-lab03-before.txt b/Last3Mon/76/answers/17-lab03-before.txt new file mode 100644 index 0000000..d886f3d --- /dev/null +++ b/Last3Mon/76/answers/17-lab03-before.txt @@ -0,0 +1,194 @@ +$ uv run tools/flow.py test cases/lab03.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 멈춘 동안 2건 → DLQ · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 고친 뒤 다시 돌리기 · 0.0s · status 404 (expected 200); replayed = null (expected 2); ok = null (expected 2); still_failed = null (expected 0) + → request POST /webhook/my-replay + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-replay\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ DLQ가 비었음 · 0.0s · size = 2 (expected 0) + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251042.6309726 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251042.630976 + } + ] + } +✘ 접수 건은 2개 (중복 없음) · 0.0s · count = 0 (expected 2) + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 0, + "tickets": [] + } +✘ 한 번 더 돌려도 할 일 없음 · 0.0s · status 404 (expected 200); replayed = null (expected 0) + → request POST /webhook/my-replay + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-replay\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +Passed 1/5 +report: runs/lab03-20261029-141722.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/18-lab03-replay.json b/Last3Mon/76/answers/18-lab03-replay.json new file mode 100644 index 0000000..1e483cd --- /dev/null +++ b/Last3Mon/76/answers/18-lab03-replay.json @@ -0,0 +1,132 @@ +{ + "cases": "lab03.json", + "webhook": "my-intake", + "passed": 5, + "total": 5, + "results": [ + { + "case": "API가 멈춘 동안 2건 → DLQ", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "고친 뒤 다시 돌리기", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.1, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "DLQ가 비었음", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 0, + "parked": 0, + "items": [] + } + }, + { + "case": "접수 건은 2개 (중복 없음)", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2002", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + } + ] + } + }, + { + "case": "한 번 더 돌려도 할 일 없음", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 0 + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/18-lab03-replay.txt b/Last3Mon/76/answers/18-lab03-replay.txt new file mode 100644 index 0000000..74283b3 --- /dev/null +++ b/Last3Mon/76/answers/18-lab03-replay.txt @@ -0,0 +1,200 @@ +$ uv run tools/flow.py push reference/lab03-example1-replay.json +✔ pushed '76 Replay (reference, Lab 03 example 1)' (id we4c5fdffca93da1) + POST http://localhost:5678/webhook/my-replay + +$ uv run tools/flow.py test cases/lab03.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 멈춘 동안 2건 → DLQ · 4.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤 다시 돌리기 · 1.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ DLQ가 비었음 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 0, + "parked": 0, + "items": [] + } +✔ 접수 건은 2개 (중복 없음) · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2002", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + } + ] + } +✔ 한 번 더 돌려도 할 일 없음 · 0.0s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 0 + } +Passed 5/5 +report: runs/lab03-20261029-141738.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 2 + T-2001 key R-302 장비/보통 노트북 배터리가 금방 닳아요 + T-2002 key R-301 계정/높음 사내 위키 로그인이 안 돼요 +DLQ: 0 (parked 0) +alerts: 1 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ diff --git a/Last3Mon/76/answers/19-lab03-drill.json b/Last3Mon/76/answers/19-lab03-drill.json new file mode 100644 index 0000000..e08f4ba --- /dev/null +++ b/Last3Mon/76/answers/19-lab03-drill.json @@ -0,0 +1,263 @@ +{ + "cases": "lab03-drill.json", + "webhook": "my-intake", + "passed": 7, + "total": 7, + "results": [ + { + "case": "1 · 평소", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.6, + "status": 200, + "request": { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + }, + "response": { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ], + "alert": "none" + } + }, + { + "case": "2 · 장애 시작 · API 오류", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.0, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "3 · DLQ 확인", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251069.7296107 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251069.7296135 + } + ] + } + }, + { + "case": "4 · 보고서 작업도 실패", + "webhook": "nightly-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 500, + "request": {}, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "5 · 알림 확인", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10159", + "severity": "critical", + "reason": "unexpected", + "at": 1793251069.8482687 + } + }, + { + "case": "6 · 고친 뒤 재처리", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.4, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "7 · 결과 확인 · 중복 없이 5건", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2005", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/19-lab03-drill.txt b/Last3Mon/76/answers/19-lab03-drill.txt new file mode 100644 index 0000000..e0c0b3b --- /dev/null +++ b/Last3Mon/76/answers/19-lab03-drill.txt @@ -0,0 +1,377 @@ +$ uv run tools/flow.py ops --reset +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +$ uv run tools/flow.py fault tickets error +✔ fault switch: {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py call my-intake requests/batch-two.json +HTTP 200 · 2.8s +{ + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" +} + +$ uv run tools/flow.py call nightly-report +HTTP 500 · 0.1s +{ + "message": "Error in workflow" +} + +$ uv run tools/flow.py ops +fault switch: {"tickets": "error"} +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 2 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] + +$ uv run tools/flow.py fault tickets ok +✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py call my-replay +HTTP 200 · 1.4s +{ + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 +} + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 2 + T-2001 key R-302 장비/보통 노트북 배터리가 금방 닳아요 + T-2002 key R-301 계정/높음 사내 위키 로그인이 안 돼요 +DLQ: 0 (parked 0) +alerts: 2 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] + +$ uv run tools/flow.py test cases/lab03-drill.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ 1 · 평소 · 2.6s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ], + "alert": "none" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · 장애 시작 · API 오류 · 3.0s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 3 · DLQ 확인 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251069.7296107 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251069.7296135 + } + ] + } +✔ 4 · 보고서 작업도 실패 · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 5 · 알림 확인 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10159", + "severity": "critical", + "reason": "unexpected", + "at": 1793251069.8482687 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 6 · 고친 뒤 재처리 · 1.4s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ 7 · 결과 확인 · 중복 없이 5건 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2005", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + } + ] + } +Passed 7/7 +report: runs/lab03-drill-20261029-141754.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/20-lab03-half.json b/Last3Mon/76/answers/20-lab03-half.json new file mode 100644 index 0000000..fcaa6ba --- /dev/null +++ b/Last3Mon/76/answers/20-lab03-half.json @@ -0,0 +1,189 @@ +{ + "cases": "lab03-half.json", + "webhook": "my-intake", + "passed": 6, + "total": 6, + "results": [ + { + "case": "1 · API가 느림 → 기다리다 실패", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 11.6, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-302", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "2 · DLQ에 2건", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "timeout of 3000ms exceeded", + "workflow": "intake", + "at": 1793251086.0805922 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "timeout of 3000ms exceeded", + "workflow": "intake", + "at": 1793251086.080595 + } + ] + } + }, + { + "case": "3 · 그런데 접수 건은 이미 2개", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } + }, + { + "case": "4 · 재처리 · 이미 있는 건이라 duplicate", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.1, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 2 + } + }, + { + "case": "5 · 그래도 2개 (중복 없음)", + "webhook": "http://localhost:8800/tickets-created/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } + }, + { + "case": "6 · DLQ가 비었음", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 0, + "parked": 0, + "items": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/20-lab03-half.txt b/Last3Mon/76/answers/20-lab03-half.txt new file mode 100644 index 0000000..9b4e2b8 --- /dev/null +++ b/Last3Mon/76/answers/20-lab03-half.txt @@ -0,0 +1,248 @@ +$ uv run tools/flow.py test cases/lab03-half.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "slow", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "slow" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "slow", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 1 · API가 느림 → 기다리다 실패 · 11.6s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-302", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 2 · DLQ에 2건 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "timeout of 3000ms exceeded", + "workflow": "intake", + "at": 1793251086.0805922 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "timeout of 3000ms exceeded", + "workflow": "intake", + "at": 1793251086.080595 + } + ] + } +… before: wait 8s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 3 · 그런데 접수 건은 이미 2개 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } +✔ 4 · 재처리 · 이미 있는 건이라 duplicate · 1.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 2 + } +✔ 5 · 그래도 2개 (중복 없음) · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } +✔ 6 · DLQ가 비었음 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 0, + "parked": 0, + "items": [] + } +Passed 6/6 +report: runs/lab03-half-20261029-141815.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 2 + T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요 +DLQ: 0 (parked 0) +alerts: 1 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ diff --git a/Last3Mon/76/answers/21-lab03-challenge-before.json b/Last3Mon/76/answers/21-lab03-challenge-before.json new file mode 100644 index 0000000..4971701 --- /dev/null +++ b/Last3Mon/76/answers/21-lab03-challenge-before.json @@ -0,0 +1,192 @@ +{ + "cases": "lab03-challenge.json", + "webhook": "my-intake", + "passed": 3, + "total": 7, + "results": [ + { + "case": "API가 오래 멈춤 · 처음 실패", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.4, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "재처리 1 · 또 실패", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.2, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "재처리 2 · 또 실패", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.4, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "재처리 3 · 세 번 실패한 것은 멈춤 (parked)", + "webhook": "my-replay", + "run": 1, + "passed": false, + "problems": [ + "replayed = 2 (expected 0)", + "parked = 0 (expected 2)" + ], + "seconds": 2.0, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "parked로 남음", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": false, + "problems": [ + "parked = 0 (expected 2)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 4, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251105.2922482 + }, + { + "dlq_id": "D-2", + "attempts": 4, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251105.5231082 + } + ] + } + }, + { + "case": "멈춘 것은 알림", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": false, + "problems": [ + "reason = dlq (expected parked)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10168", + "at": 1793251099.0033967 + } + }, + { + "case": "고친 뒤에도 parked는 자동으로 돌리지 않음", + "webhook": "my-replay", + "run": 1, + "passed": false, + "problems": [ + "replayed = 2 (expected 0)" + ], + "seconds": 1.6, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/21-lab03-challenge-before.txt b/Last3Mon/76/answers/21-lab03-challenge-before.txt new file mode 100644 index 0000000..3a6f44c --- /dev/null +++ b/Last3Mon/76/answers/21-lab03-challenge-before.txt @@ -0,0 +1,226 @@ +$ uv run tools/flow.py test cases/lab03-challenge.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 오래 멈춤 · 처음 실패 · 3.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 재처리 1 · 또 실패 · 2.2s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✔ 재처리 2 · 또 실패 · 2.4s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✘ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 2.0s · replayed = 2 (expected 0); parked = 0 (expected 2) + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✘ parked로 남음 · 0.0s · parked = 0 (expected 2) + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 4, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251105.2922482 + }, + { + "dlq_id": "D-2", + "attempts": 4, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251105.5231082 + } + ] + } +✘ 멈춘 것은 알림 · 0.0s · reason = dlq (expected parked) + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10168", + "at": 1793251099.0033967 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 1.6s · replayed = 2 (expected 0) + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +Passed 3/7 +report: runs/lab03-challenge-20261029-141827.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/22-lab03-challenge.json b/Last3Mon/76/answers/22-lab03-challenge.json new file mode 100644 index 0000000..e4eda12 --- /dev/null +++ b/Last3Mon/76/answers/22-lab03-challenge.json @@ -0,0 +1,181 @@ +{ + "cases": "lab03-challenge.json", + "webhook": "my-intake", + "passed": 7, + "total": 7, + "results": [ + { + "case": "API가 오래 멈춤 · 처음 실패", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.3, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "재처리 1 · 또 실패", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.9, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "재처리 2 · 또 실패", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.1, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "재처리 3 · 세 번 실패한 것은 멈춤 (parked)", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": {}, + "response": { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 2, + "duplicates": 0 + } + }, + { + "case": "parked로 남음", + "webhook": "http://localhost:8800/dlq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "parked": 2, + "items": [ + { + "dlq_id": "D-1", + "attempts": 3, + "parked": true, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251128.6803358 + }, + { + "dlq_id": "D-2", + "attempts": 3, + "parked": true, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251128.9017823 + } + ] + } + }, + { + "case": "멈춘 것은 알림", + "webhook": "http://localhost:8800/alerts/latest", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "workflow": "replay", + "node": "Park it", + "severity": "high", + "reason": "parked", + "dlq_id": "D-2", + "message": "D-2 (R-302) parked after 3 attempts", + "at": 1793251129.0694785 + } + }, + { + "case": "고친 뒤에도 parked는 자동으로 돌리지 않음", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 0 + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/22-lab03-challenge.txt b/Last3Mon/76/answers/22-lab03-challenge.txt new file mode 100644 index 0000000..0001903 --- /dev/null +++ b/Last3Mon/76/answers/22-lab03-challenge.txt @@ -0,0 +1,255 @@ +$ uv run tools/flow.py push answers/76-lab03-challenge-park.json +! unpublished '76 Replay (reference, Lab 03 example 1)' (we4c5fdffca93da1): it used the same webhook path +✔ pushed '76 Replay · park after 3 attempts (answer, Lab 03 task 1)' (id w7d8de63c811909b) + POST http://localhost:5678/webhook/my-replay + +$ uv run tools/flow.py test cases/lab03-challenge.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 오래 멈춤 · 처음 실패 · 4.3s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 재처리 1 · 또 실패 · 1.9s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✔ 재처리 2 · 또 실패 · 2.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 2, + "duplicates": 0 + } +✔ parked로 남음 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 2, + "items": [ + { + "dlq_id": "D-1", + "attempts": 3, + "parked": true, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251128.6803358 + }, + { + "dlq_id": "D-2", + "attempts": 3, + "parked": true, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251128.9017823 + } + ] + } +✔ 멈춘 것은 알림 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "replay", + "node": "Park it", + "severity": "high", + "reason": "parked", + "dlq_id": "D-2", + "message": "D-2 (R-302) parked after 3 attempts", + "at": 1793251129.0694785 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 0 + } +Passed 7/7 +report: runs/lab03-challenge-20261029-141849.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py test cases/lab03.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ API가 멈춘 동안 2건 → DLQ · 3.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 고친 뒤 다시 돌리기 · 1.4s +✔ DLQ가 비었음 · 0.0s +✔ 접수 건은 2개 (중복 없음) · 0.0s +✔ 한 번 더 돌려도 할 일 없음 · 0.0s +Passed 5/5 +report: runs/lab03-20261029-141853.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py ops +fault switch: all ok +created tickets: 2 + T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요 +DLQ: 0 (parked 0) +alerts: 1 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ diff --git a/Last3Mon/76/answers/23-lab03-challenge2-before.json b/Last3Mon/76/answers/23-lab03-challenge2-before.json new file mode 100644 index 0000000..12962a1 --- /dev/null +++ b/Last3Mon/76/answers/23-lab03-challenge2-before.json @@ -0,0 +1,161 @@ +{ + "cases": "lab03-challenge2.json", + "webhook": "my-ops-status", + "passed": 3, + "total": 7, + "results": [ + { + "case": "1 · 아무 일 없음 → ok", + "webhook": "my-ops-status", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "status = null (expected ok)", + "dlq = null (expected 0)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "2 · (접수 실패 2건)", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.9, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "3 · DLQ가 있으면 warn", + "webhook": "my-ops-status", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "status = null (expected warn)", + "dlq = null (expected 2)", + "reasons has none of ['재처리']" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "4 · (보고서 버그)", + "webhook": "nightly-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 500, + "request": {}, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "5 · critical 알림이 있으면 critical", + "webhook": "my-ops-status", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "status = null (expected critical)", + "alerts.critical = null (expected 1)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "6 · (재처리 · 알림 확인 뒤 비움)", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.1, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "7 · 고친 뒤 → ok", + "webhook": "my-ops-status", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "status = null (expected ok)", + "dlq = null (expected 0)", + "created = null (expected 2)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/23-lab03-challenge2-before.txt b/Last3Mon/76/answers/23-lab03-challenge2-before.txt new file mode 100644 index 0000000..c0d3d28 --- /dev/null +++ b/Last3Mon/76/answers/23-lab03-challenge2-before.txt @@ -0,0 +1,194 @@ +$ uv run tools/flow.py test cases/lab03-challenge2.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-ops-status · 7 case(s) × 1 +✘ 1 · 아무 일 없음 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0) + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · (접수 실패 2건) · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 3 · DLQ가 있으면 warn · 0.0s · status 404 (expected 200); status = null (expected warn); dlq = null (expected 2); reasons has none of ['재처리'] + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✔ 4 · (보고서 버그) · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✘ 5 · critical 알림이 있으면 critical · 0.0s · status 404 (expected 200); status = null (expected critical); alerts.critical = null (expected 1) + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ after: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✘ 7 · 고친 뒤 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0); created = null (expected 2) + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +Passed 3/7 +report: runs/lab03-challenge2-20261029-141901.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } diff --git a/Last3Mon/76/answers/24-lab03-challenge2.json b/Last3Mon/76/answers/24-lab03-challenge2.json new file mode 100644 index 0000000..4d78585 --- /dev/null +++ b/Last3Mon/76/answers/24-lab03-challenge2.json @@ -0,0 +1,174 @@ +{ + "cases": "lab03-challenge2.json", + "webhook": "my-ops-status", + "passed": 7, + "total": 7, + "results": [ + { + "case": "1 · 아무 일 없음 → ok", + "webhook": "my-ops-status", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.9, + "status": 200, + "request": {}, + "response": { + "status": "ok", + "dlq": 0, + "parked": 0, + "created": 0, + "alerts": { + "critical": 0, + "high": 0 + }, + "reasons": [] + } + }, + { + "case": "2 · (접수 실패 2건)", + "webhook": "my-intake", + "run": 1, + "passed": true, + "problems": [], + "seconds": 3.7, + "status": 200, + "request": { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + }, + "response": { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } + }, + { + "case": "3 · DLQ가 있으면 warn", + "webhook": "my-ops-status", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": {}, + "response": { + "status": "warn", + "dlq": 2, + "parked": 0, + "created": 0, + "alerts": { + "critical": 0, + "high": 1 + }, + "reasons": [ + "DLQ 2건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] + } + }, + { + "case": "4 · (보고서 버그)", + "webhook": "nightly-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 500, + "request": {}, + "response": { + "message": "Error in workflow" + } + }, + { + "case": "5 · critical 알림이 있으면 critical", + "webhook": "my-ops-status", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": {}, + "response": { + "status": "critical", + "dlq": 2, + "parked": 0, + "created": 0, + "alerts": { + "critical": 1, + "high": 1 + }, + "reasons": [ + "critical 알림 1건 (10분)", + "DLQ 2건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] + } + }, + { + "case": "6 · (재처리 · 알림 확인 뒤 비움)", + "webhook": "my-replay", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.2, + "status": 200, + "request": {}, + "response": { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } + }, + { + "case": "7 · 고친 뒤 → ok", + "webhook": "my-ops-status", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "status": "ok", + "dlq": 0, + "parked": 0, + "created": 2, + "alerts": { + "critical": 0, + "high": 0 + }, + "reasons": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/76/answers/24-lab03-challenge2.txt b/Last3Mon/76/answers/24-lab03-challenge2.txt new file mode 100644 index 0000000..debf6c8 --- /dev/null +++ b/Last3Mon/76/answers/24-lab03-challenge2.txt @@ -0,0 +1,301 @@ +$ uv run tools/flow.py push answers/76-lab03-challenge2-ops-status.json +✔ pushed '76 Ops status (answer)' (id w07b5812eaa88ef9) + POST http://localhost:5678/webhook/my-ops-status + +$ uv run tools/flow.py test cases/lab03-challenge2.json -v +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-ops-status · 7 case(s) × 1 +✔ 1 · 아무 일 없음 → ok · 0.9s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "ok", + "dlq": 0, + "parked": 0, + "created": 0, + "alerts": { + "critical": 0, + "high": 0 + }, + "reasons": [] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · (접수 실패 2건) · 3.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 3 · DLQ가 있으면 warn · 0.1s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "warn", + "dlq": 2, + "parked": 0, + "created": 0, + "alerts": { + "critical": 0, + "high": 1 + }, + "reasons": [ + "DLQ 2건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] + } +✔ 4 · (보고서 버그) · 0.1s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 5 · critical 알림이 있으면 critical · 0.1s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "critical", + "dlq": 2, + "parked": 0, + "created": 0, + "alerts": { + "critical": 1, + "high": 1 + }, + "reasons": [ + "critical 알림 1건 (10분)", + "DLQ 2건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] + } +✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.2s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ after: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ 7 · 고친 뒤 → ok · 0.0s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "ok", + "dlq": 0, + "parked": 0, + "created": 2, + "alerts": { + "critical": 0, + "high": 0 + }, + "reasons": [] + } +Passed 7/7 +report: runs/lab03-challenge2-20261029-141921.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +$ uv run tools/flow.py test cases/lab03-challenge.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ API가 오래 멈춤 · 처음 실패 · 2.8s +✔ 재처리 1 · 또 실패 · 1.9s +✔ 재처리 2 · 또 실패 · 2.5s +✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s +✔ parked로 남음 · 0.0s +✔ 멈춘 것은 알림 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s +Passed 7/7 +report: runs/lab03-challenge-20261029-141929.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py test cases/lab02-challenge2.json +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 첫 실패 → 알림 보냄 · 2.9s +✔ 10분 안에 또 실패 → 보내지 않음 · 3.4s +✔ dlq 알림은 1개 · 0.0s +✔ 그래도 DLQ에는 5건 모두 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.8s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 3.4s +Passed 6/6 +report: runs/lab02-challenge2-20261029-141941.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +$ uv run tools/flow.py call my-ops-status +HTTP 200 · 0.1s +{ + "status": "critical", + "dlq": 5, + "parked": 0, + "created": 2, + "alerts": { + "critical": 1, + "high": 1 + }, + "reasons": [ + "critical 알림 1건 (10분)", + "DLQ 5건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] +} + +$ uv run tools/flow.py ops --reset +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +$ uv run tools/flow.py doctor +! n8n install folder /Users/mcchae/day65-n8n + → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더> +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready diff --git a/Last3Mon/76/answers/76-lab01-challenge-fallback.json b/Last3Mon/76/answers/76-lab01-challenge-fallback.json new file mode 100644 index 0000000..d68aed8 --- /dev/null +++ b/Last3Mon/76/answers/76-lab01-challenge-fallback.json @@ -0,0 +1,428 @@ +{ + "name": "76 Intake · LLM fallback (answer, Lab 01 task 1)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-intake", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.requests) }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"requests must be a list\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 600 + ] + }, + { + "name": "Check items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});" + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Valid?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 880, + 200 + ], + "onError": "continueRegularOutput" + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "position": [ + 880, + 420 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});" + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1320, + 200 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 300, + "onError": "continueRegularOutput" + }, + { + "name": "Mark results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});" + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Mark rejected", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));" + }, + "position": [ + 880, + 640 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1760, + 300 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];" + }, + "position": [ + 1980, + 300 + ] + }, + { + "name": "Public result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const { failed_requests, ...rest } = $('Summarize').first().json;\nreturn [{ json: rest }];" + }, + "position": [ + 3300, + 300 + ] + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3520, + 300 + ] + } + ], + "connections": { + "Intake webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is a list?": { + "main": [ + [ + { + "node": "Check items", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check items": { + "main": [ + [ + { + "node": "Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid?": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mark rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Mark results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark results": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark rejected": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Public result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/answers/76-lab01-challenge2-rules.json b/Last3Mon/76/answers/76-lab01-challenge2-rules.json new file mode 100644 index 0000000..76dc797 --- /dev/null +++ b/Last3Mon/76/answers/76-lab01-challenge2-rules.json @@ -0,0 +1,428 @@ +{ + "name": "76 Intake · request rules (answer, Lab 01 task 2)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-intake", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 600 + ] + }, + { + "name": "Check items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});" + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Valid?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 880, + 200 + ], + "onError": "continueRegularOutput" + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "position": [ + 880, + 420 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});" + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1320, + 200 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 300, + "onError": "continueRegularOutput" + }, + { + "name": "Mark results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});" + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Mark rejected", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));" + }, + "position": [ + 880, + 640 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1760, + 300 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];" + }, + "position": [ + 1980, + 300 + ] + }, + { + "name": "Public result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const { failed_requests, ...rest } = $('Summarize').first().json;\nreturn [{ json: rest }];" + }, + "position": [ + 3300, + 300 + ] + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3520, + 300 + ] + } + ], + "connections": { + "Intake webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is a list?": { + "main": [ + [ + { + "node": "Check items", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check items": { + "main": [ + [ + { + "node": "Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid?": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mark rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Mark results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark results": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark rejected": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Public result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/answers/76-lab02-challenge-alert.json b/Last3Mon/76/answers/76-lab02-challenge-alert.json new file mode 100644 index 0000000..4018390 --- /dev/null +++ b/Last3Mon/76/answers/76-lab02-challenge-alert.json @@ -0,0 +1,571 @@ +{ + "name": "76 Intake · alert on handled failures (answer, Lab 02 task 1)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-intake", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 600 + ] + }, + { + "name": "Check items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});" + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Valid?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 880, + 200 + ], + "onError": "continueRegularOutput" + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "position": [ + 880, + 420 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});" + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1320, + 200 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 300, + "onError": "continueRegularOutput" + }, + { + "name": "Mark results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});" + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Mark rejected", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));" + }, + "position": [ + 880, + 640 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1760, + 300 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];" + }, + "position": [ + 1980, + 300 + ] + }, + { + "name": "Public result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst { failed_requests, ...rest } = $('Summarize').first().json;\nconst out = { ...rest };\nif (true) {\n if (ran('Send to DLQ')) out.dlq_saved = Number($('Send to DLQ').first().json.stored ?? 0) > 0;\n out.alert = ran('Send alert') ? 'sent' : (ran('Decide alert') && $('Decide alert').first().json.suppressed ? 'suppressed' : 'none');\n}\nreturn [{ json: out }];" + }, + "position": [ + 3300, + 300 + ] + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3520, + 300 + ] + }, + { + "name": "Any failed?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.failed > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2200, + 300 + ] + }, + { + "name": "Send to DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/dlq", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: $json.failed_requests.map((r, i) => ({ item: r, reason: $json.results.filter((x) => x.status === 'failed')[i]?.reason ?? 'failed', workflow: 'intake' })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 2420, + 140 + ] + }, + { + "name": "Make alert", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const s = $('Summarize').first().json;\nconst saved = Number($json.stored ?? 0) > 0;\nreturn [{ json: saved\n ? { workflow: 'intake', node: 'Send to DLQ', severity: 'high', reason: 'dlq', count: s.failed,\n message: s.failed + ' request(s) could not be created and wait in the DLQ', execution_id: $execution.id }\n : { workflow: 'intake', node: 'Send to DLQ', severity: 'critical', reason: 'dlq_down', count: s.failed,\n message: 'DLQ save failed: ' + String($json.error ?? JSON.stringify($json)).slice(0, 150) + ' · lost ids ' + s.failed_requests.map((r) => r.id).join(', '),\n execution_id: $execution.id, ids: s.failed_requests.map((r) => r.id) } }];" + }, + "position": [ + 2640, + 140 + ] + }, + { + "name": "Send alert", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/alerts", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify((({ suppressed, recent, ...a }) => a)($json)) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 3080, + 60 + ] + } + ], + "connections": { + "Intake webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is a list?": { + "main": [ + [ + { + "node": "Check items", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check items": { + "main": [ + [ + { + "node": "Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid?": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mark rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Mark results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark results": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark rejected": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Public result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Any failed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any failed?": { + "main": [ + [ + { + "node": "Send to DLQ", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send to DLQ": { + "main": [ + [ + { + "node": "Make alert", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make alert": { + "main": [ + [ + { + "node": "Send alert", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send alert": { + "main": [ + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "errorWorkflow": "errHandler76001" + } +} diff --git a/Last3Mon/76/answers/76-lab02-challenge2-throttle.json b/Last3Mon/76/answers/76-lab02-challenge2-throttle.json new file mode 100644 index 0000000..165bfcf --- /dev/null +++ b/Last3Mon/76/answers/76-lab02-challenge2-throttle.json @@ -0,0 +1,679 @@ +{ + "name": "76 Intake · alert once per 10 minutes (answer, Lab 02 task 2)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-intake", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 600 + ] + }, + { + "name": "Check items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});" + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Valid?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 880, + 200 + ], + "onError": "continueRegularOutput" + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "position": [ + 880, + 420 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});" + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1320, + 200 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 300, + "onError": "continueRegularOutput" + }, + { + "name": "Mark results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});" + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Mark rejected", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));" + }, + "position": [ + 880, + 640 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1760, + 300 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];" + }, + "position": [ + 1980, + 300 + ] + }, + { + "name": "Public result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst { failed_requests, ...rest } = $('Summarize').first().json;\nconst out = { ...rest };\nif (true) {\n if (ran('Send to DLQ')) out.dlq_saved = Number($('Send to DLQ').first().json.stored ?? 0) > 0;\n out.alert = ran('Send alert') ? 'sent' : (ran('Decide alert') && $('Decide alert').first().json.suppressed ? 'suppressed' : 'none');\n}\nreturn [{ json: out }];" + }, + "position": [ + 3740, + 300 + ] + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3960, + 300 + ] + }, + { + "name": "Any failed?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.failed > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2200, + 300 + ] + }, + { + "name": "Send to DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/dlq", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: $json.failed_requests.map((r, i) => ({ item: r, reason: $json.results.filter((x) => x.status === 'failed')[i]?.reason ?? 'failed', workflow: 'intake' })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 2420, + 140 + ] + }, + { + "name": "Make alert", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const s = $('Summarize').first().json;\nconst saved = Number($json.stored ?? 0) > 0;\nreturn [{ json: saved\n ? { workflow: 'intake', node: 'Send to DLQ', severity: 'high', reason: 'dlq', count: s.failed,\n message: s.failed + ' request(s) could not be created and wait in the DLQ', execution_id: $execution.id }\n : { workflow: 'intake', node: 'Send to DLQ', severity: 'critical', reason: 'dlq_down', count: s.failed,\n message: 'DLQ save failed: ' + String($json.error ?? JSON.stringify($json)).slice(0, 150) + ' · lost ids ' + s.failed_requests.map((r) => r.id).join(', '),\n execution_id: $execution.id, ids: s.failed_requests.map((r) => r.id) } }];" + }, + "position": [ + 2640, + 140 + ] + }, + { + "name": "Send alert", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/alerts", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify((({ suppressed, recent, ...a }) => a)($json)) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 3520, + 60 + ] + }, + { + "name": "Recent alerts", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/alerts/list", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ workflow: 'intake', reason: $json.reason, within: 600 }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 2860, + 140 + ] + }, + { + "name": "Decide alert", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const alert = $('Make alert').first().json;\nconst recent = Number($json.count ?? 0);\n// critical is never held back; a high alert with the same reason in the last 10 minutes is enough\nconst suppressed = alert.severity === 'high' && recent > 0;\nreturn [{ json: { ...alert, suppressed, recent } }];" + }, + "position": [ + 3080, + 140 + ] + }, + { + "name": "Send it?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ !$json.suppressed }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 3300, + 140 + ] + } + ], + "connections": { + "Intake webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is a list?": { + "main": [ + [ + { + "node": "Check items", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check items": { + "main": [ + [ + { + "node": "Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid?": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mark rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Mark results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark results": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark rejected": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Public result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Any failed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any failed?": { + "main": [ + [ + { + "node": "Send to DLQ", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send to DLQ": { + "main": [ + [ + { + "node": "Make alert", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make alert": { + "main": [ + [ + { + "node": "Recent alerts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Recent alerts": { + "main": [ + [ + { + "node": "Decide alert", + "type": "main", + "index": 0 + } + ] + ] + }, + "Decide alert": { + "main": [ + [ + { + "node": "Send it?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send it?": { + "main": [ + [ + { + "node": "Send alert", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send alert": { + "main": [ + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "errorWorkflow": "errHandler76001" + } +} diff --git a/Last3Mon/76/answers/76-lab03-challenge-park.json b/Last3Mon/76/answers/76-lab03-challenge-park.json new file mode 100644 index 0000000..630a59f --- /dev/null +++ b/Last3Mon/76/answers/76-lab03-challenge-park.json @@ -0,0 +1,419 @@ +{ + "name": "76 Replay · park after 3 attempts (answer, Lab 03 task 1)", + "nodes": [ + { + "name": "Replay webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-replay", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Get DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/dlq", + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Pick items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const max = Number($('Replay webhook').first().json.body?.max_attempts ?? 3);\nconst items = ($json.items ?? []).filter((e) => !e.parked);\nconst ready = items.filter((e) => e.attempts < max);\nconst tooMany = items.filter((e) => e.attempts >= max);\nreturn [...ready.map((e) => ({ json: { ...e, action: 'replay' } })), ...tooMany.map((e) => ({ json: { ...e, action: 'park' } }))];" + }, + "position": [ + 440, + 300 + ], + "alwaysOutputData": true + }, + { + "name": "Has items?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.dlq_id }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Nothing to do", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ replayed: 0, ok: 0, still_failed: 0, parked: 0 }) }}", + "options": {} + }, + "position": [ + 880, + 520 + ] + }, + { + "name": "Run intake again", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Outcome", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const picked = $('Replay it?').all().filter((item) => item.json.action === 'replay');\nreturn $input.all().map((item, i) => ({ json: { dlq_id: picked[i].json.dlq_id, id: picked[i].json.item?.id, ok: item.json.created === 1, duplicate: item.json.duplicates === 1 } }));" + }, + "position": [ + 1320, + 200 + ] + }, + { + "name": "Remove done", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/dlq/update", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ dlq_id: $json.dlq_id, remove: $json.ok }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst outcomes = ran('Outcome') ? $('Outcome').all().map((i) => i.json) : [];\nconst parked = ran('Park it') ? $('Park it').all().length : 0;\nconst ok = outcomes.filter((o) => o.ok).length;\nreturn [{ json: { replayed: outcomes.length, ok, still_failed: outcomes.length - ok, parked, duplicates: outcomes.filter((o) => o.duplicate).length } }];" + }, + "position": [ + 2200, + 300 + ] + }, + { + "name": "Send summary", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 2420, + 300 + ] + }, + { + "name": "Replay it?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.action }}", + "rightValue": "replay", + "operator": { + "type": "string", + "operation": "equals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 880, + 300 + ] + }, + { + "name": "Park it", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/dlq/update", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ dlq_id: $json.dlq_id, parked: true, reason: 'gave up after ' + $json.attempts + ' attempts: ' + $json.reason }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 1100, + 420 + ] + }, + { + "name": "Alert parked", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/alerts", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ workflow: 'replay', node: 'Park it', severity: 'high', reason: 'parked', dlq_id: $json.entry?.dlq_id, message: $json.entry?.dlq_id + ' (' + $json.entry?.item?.id + ') parked after ' + $json.entry?.attempts + ' attempts' }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 1320, + 420 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1980, + 300 + ] + } + ], + "connections": { + "Replay webhook": { + "main": [ + [ + { + "node": "Get DLQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get DLQ": { + "main": [ + [ + { + "node": "Pick items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Pick items": { + "main": [ + [ + { + "node": "Has items?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has items?": { + "main": [ + [ + { + "node": "Replay it?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Nothing to do", + "type": "main", + "index": 0 + } + ] + ] + }, + "Run intake again": { + "main": [ + [ + { + "node": "Outcome", + "type": "main", + "index": 0 + } + ] + ] + }, + "Outcome": { + "main": [ + [ + { + "node": "Remove done", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Send summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Replay it?": { + "main": [ + [ + { + "node": "Run intake again", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Park it", + "type": "main", + "index": 0 + } + ] + ] + }, + "Park it": { + "main": [ + [ + { + "node": "Alert parked", + "type": "main", + "index": 0 + } + ] + ] + }, + "Remove done": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alert parked": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/answers/76-lab03-challenge2-ops-status.json b/Last3Mon/76/answers/76-lab03-challenge2-ops-status.json new file mode 100644 index 0000000..a6dd78e --- /dev/null +++ b/Last3Mon/76/answers/76-lab03-challenge2-ops-status.json @@ -0,0 +1,149 @@ +{ + "name": "76 Ops status (answer)", + "nodes": [ + { + "name": "Status webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-ops-status", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Get DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/dlq", + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Recent alerts", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/alerts/list", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ within: 600 }) }}", + "options": {} + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Get created", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/tickets-created", + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Decide status", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const dlq = $('Get DLQ').first().json;\nconst alerts = $('Recent alerts').first().json.alerts ?? [];\nconst created = $('Get created').first().json.count ?? 0;\nconst parked = (dlq.items ?? []).filter((e) => e.parked).length;\nconst waiting = (dlq.items ?? []).length - parked;\nconst critical = alerts.filter((a) => a.severity === 'critical').length;\nconst high = alerts.filter((a) => a.severity === 'high').length;\nconst reasons = [];\nif (parked) reasons.push(`parked ${parked}건: 사람이 보고 결정`);\nif (critical) reasons.push(`critical 알림 ${critical}건 (10분)`);\nif (waiting) reasons.push(`DLQ ${waiting}건: 원인을 고친 뒤 재처리`);\nif (high) reasons.push(`high 알림 ${high}건 (10분)`);\nconst status = parked || critical ? 'critical' : (waiting || high ? 'warn' : 'ok');\nreturn [{ json: { status, dlq: waiting, parked, created, alerts: { critical, high }, reasons } }];" + }, + "position": [ + 880, + 300 + ] + }, + { + "name": "Send status", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1100, + 300 + ] + } + ], + "connections": { + "Status webhook": { + "main": [ + [ + { + "node": "Get DLQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get DLQ": { + "main": [ + [ + { + "node": "Recent alerts", + "type": "main", + "index": 0 + } + ] + ] + }, + "Recent alerts": { + "main": [ + [ + { + "node": "Get created", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get created": { + "main": [ + [ + { + "node": "Decide status", + "type": "main", + "index": 0 + } + ] + ] + }, + "Decide status": { + "main": [ + [ + { + "node": "Send status", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/answers/LAB-TEST-RESULT.md b/Last3Mon/76/answers/LAB-TEST-RESULT.md new file mode 100644 index 0000000..eb93889 --- /dev/null +++ b/Last3Mon/76/answers/LAB-TEST-RESULT.md @@ -0,0 +1,7303 @@ +# 76일차 Lab 테스트 결과 · 실제로 만든 시나리오와 `test -v` 결과 + +> **정답 자료** · 76일차 n8n 워크플로우를 **만드는 프롬프트(codex) → push → `call` · `test -v` · `ops` 결과**를 학생이 가는 순서대로 한 문서에 모았습니다. 수업이 끝난 뒤 공개합니다. + +- 실행: 2026-10-29, 실습을 Lab마다 5개(예제 3 + 과제 2)로 늘린 뒤 강사 노트북의 65일차 n8n 2.42.6 + 76일차 판 데이터 API(75일차 + 접수 건 만들기 · DLQ · 알림 · 고장 스위치 `dlq`), OpenAI `gpt-4.1-mini`(분류), 케이스 파일마다 **한 번씩**, 모두 25단계 (00~24) +- 명령: `~/aio-01d4-stu/Last3Mon/76`에서 `uv run tools/flow.py ...`. 아래 단계마다 **실행한 명령**과 **화면에 나온 출력 전부**를 그대로 붙이고, 그 아래 **쉽게 말하면**으로 결과를 풀어 썼습니다 (특히 ✘가 나온 까닭과 그것이 예상한 것인지). 같은 출력이 `NN-<이름>.txt`에, 그 단계 첫 `test`의 보고서가 `NN-<이름>.json`에 있습니다 (00 · 01단계는 보고서 없음). +- LLM 분류 · 시간 · 접수 건 번호의 순서는 **실행마다 조금씩 다릅니다.** 이번 실행에서 정답의 잘못으로 실패한 케이스는 없습니다. ✘는 모두 "고치기 전" 단계에서 본 것입니다. +- "고치기 전" 상태: 00단계에서 `my-intake` · `my-replay` · `my-ops-status`와 에러 처리기를 **게시 해제**(`off`)해 학생이 처음 여는 n8n과 같게 했습니다. 게시를 내린 에러 처리기는 n8n이 부르지 않습니다 (수업 준비 때 확인). + +## `-v`가 보여 주는 것 + +```text +✔ 잘못된 항목은 그 항목만 거절 · 1.7s ← 케이스 · 시간 + → request POST /webhook/my-intake ← 보낸 요청 JSON + { "requests": [ { "id": "R-201", … }, { "id": "R-202", "text": "" }, … ] } + ← response HTTP 200 ← 받은 응답 JSON + { "total": 3, "created": 2, "failed": 0, "rejected": 1, "duplicates": 0, "unclassified": 0, + "results": [ { "id": "R-201", "status": "created", "ticket_id": "T-2004", … }, { "id": "R-202", "status": "rejected", "reason": "text is required" }, … ] } +``` + +오늘은 응답의 **항목마다 상태**(`created` · `failed` · `rejected`)와, 테스트 사이의 `ops`(접수 건 · DLQ · 알림 · 고장 스위치)를 봅니다. + +## 이 문서 하나로 따라가기 + +단계마다 **① codex에 넣는 프롬프트 → ② codex가 `my-work/*.json`을 쓰고 `check` → ③ 내가 `push` → ④ 내가 `test -v` · `ops`** 순서입니다. 에이전트 없이 하는 예제(Lab 01 예제 1 · 3, Lab 02 예제 3, Lab 03 예제 2 · 3)와 고치기 전 단계는 프롬프트가 없습니다. + +```mermaid +flowchart LR + P["① 프롬프트
터미널 ① codex"] --> C["② codex
my-work/*.json · check"] --> U["③ push
터미널 ②"] --> T["④ test -v · ops
항목마다 상태 · DLQ · 알림"] + T -->|✘| X["실패 줄 · 보고서 경로를
codex에 그대로"] --> C +``` + +- **프롬프트는 정답본(`LAB-0x-A-*.md`)의 요청문 예**를 그대로 썼습니다 (실습 문서 빈칸 채우기 뼈대의 `____`를 채운 글자라 빈칸 자리에 띄어쓰기가 남아 있습니다). +- **이 기록의 워크플로우는 수업 준비 때 만든 정답 파일**입니다. 프롬프트를 codex에 보내 다시 만들어 재지는 않았습니다 (68일차부터 수업 준비는 정답 워크플로우만 실행). +- 학생은 `push my-work/intake.json`, 이 기록은 같은 웹훅(`my-intake` · `my-replay` · `my-ops-status`)의 정답 파일을 push했습니다. 그래서 `test` 명령과 결과 모양은 같습니다. + +## 시나리오 한눈에 + +```mermaid +flowchart LR + L1["Lab 01 (00~08)
500인데 2건 접수 · 중복
→ 부분 성공 · 키
slow 12.4초 · 늦게 생긴 3건"] --> L2["Lab 02 (09~16)
사라진 2건 → DLQ
버그 → critical 알림
DLQ도 멈춤 → dlq_down
알림 폭주 → suppressed"] + L2 --> L3["Lab 03 (17~24)
재처리 ok 2 · 중복 0
반쯤 처리 → duplicates 2
세 번 실패 → parked
운영 상태 ok → warn → critical"] +``` + +| 단계 | 내용 | 결과 | +| --- | --- | --- | +| 00 | 수업 준비 · 처음 상태 · 기준 게시 · doctor | - | +| 01 | Lab 01 예제 1 · 기준을 망가뜨려 보기 | - | +| 02 | Lab 01 예제 2 하기 전 | `lab01` 0/6 (기준에) | +| 03 | Lab 01 예제 2 · 항목 확인 · 재시도 · 멱등 키 | 6/6 | +| 04 | Lab 01 예제 3 · 고장 종류마다 재시도가 하는 일 | 5/5 | +| 05 | Lab 01 과제 1 하기 전 | `lab01-challenge` 1/3 | +| 06 | Lab 01 과제 1 · LLM이 멈춰도 미분류로 접수 | 3/3 | +| 07 | Lab 01 과제 2 하기 전 | `lab01-challenge2` 1/4 | +| 08 | Lab 01 과제 2 · 요청 안의 규칙 | 4/4 | +| 09 | Lab 02 하기 전 · 실패한 2건은 어디로? | `lab02` 2/4 | +| 10 | Lab 02 예제 1 · 실패한 요청은 DLQ에 | `lab02` 3/4 | +| 11 | Lab 02 예제 2 · Error Workflow | `lab02` 4/4 | +| 12 | Lab 02 예제 3 · 어느 실패가 어느 그물에 | `lab02-nets` 5/7 | +| 13 | Lab 02 과제 1 하기 전 | `lab02-challenge` 0/6 | +| 14 | Lab 02 과제 1 · 처리한 실패도 알리기 | 6/6 | +| 15 | Lab 02 과제 2 하기 전 | `lab02-challenge2` 4/6 | +| 16 | Lab 02 과제 2 · 알림 폭주 막기 | 6/6 | +| 17 | Lab 03 예제 1 하기 전 | `lab03` 1/5 | +| 18 | Lab 03 예제 1 · 재처리 | 5/5 | +| 19 | Lab 03 예제 2 · 런북 · 장애 연습 | `lab03-drill` 7/7 | +| 20 | Lab 03 예제 3 · 반쯤 처리된 요청 | `lab03-half` 6/6 | +| 21 | Lab 03 과제 1 하기 전 | `lab03-challenge` 3/7 | +| 22 | Lab 03 과제 1 · 세 번 실패하면 멈추기 | 7/7 | +| 23 | Lab 03 과제 2 하기 전 | `lab03-challenge2` 3/7 | +| 24 | Lab 03 과제 2 · 운영 상태 한눈에 | 7/7 | + +## 준비 + +### 00 · 수업 준비 · 처음 상태 · 기준 게시 · doctor + +실행한 명령: + +```bash +uv run tools/flow.py ops --reset +uv run tools/flow.py budget --reset +uv run tools/flow.py usage --clear +uv run tools/flow.py off my-intake my-replay my-ops-status reference/lab02-example2-error-handler.json +uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json +uv run tools/flow.py doctor +``` + +출력 (그대로): + +```text +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +✔ budget back to data/budget.json +budget $1.0 / used $0.000000 · remaining $1.000000 · over False · per user 20/min · question 500 chars + +✔ usage cleared: {"cleared": 0} + +✔ unpublished my-intake (id w75a95815facf2d6) +✔ unpublished my-replay (id w7d8de63c811909b) +✔ unpublished my-ops-status (id w07b5812eaa88ef9) +✔ n8n restarted. Turn it back on with: uv run tools/flow.py push + +✔ pushed '76 Intake baseline (provided)' (id w4ef8dc8a25b82e3) + POST http://localhost:5678/webhook/base-intake +✔ pushed '76 Nightly report (provided, has a bug)' (id wb9ce32c18d7df56) + POST http://localhost:5678/webhook/nightly-report + +! n8n install folder /Users/mcchae/day65-n8n + → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더> +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready +``` + +**쉽게 말하면** + +- 접수 건 · DLQ · 알림을 비우고 고장 스위치를 모두 `ok`로 돌렸습니다. 예산 · 사용량 기록(75일차)도 처음 상태입니다. +- 앞서 게시한 `my-intake` · `my-replay` · `my-ops-status`와 에러 처리기를 내려 학생이 처음 여는 상태를 만들고, 오늘 고칠 기준 `base-intake`와 버그가 있는 `nightly-report`를 게시했습니다. +- `dead letter queue /dlq · alerts · fault switch dlq (day 76 data API)` ✔는 데이터 API가 76일차 판이라는 뜻입니다. `ready`. + +## Lab 01 · 실패를 겪어 보고, 항목마다 처리하고, 중복 없이 다시 해 보기 + +### 01 · Lab 01 예제 1 · 기준을 망가뜨려 보기 + +실행한 명령: + +```bash +uv run tools/flow.py ops --reset +uv run tools/flow.py call base-intake requests/batch-ok.json +uv run tools/flow.py call base-intake requests/batch-bad-item.json +uv run tools/flow.py ops +uv run tools/flow.py fault tickets flaky +uv run tools/flow.py call base-intake requests/batch-two.json +uv run tools/flow.py fault tickets ok +uv run tools/flow.py call base-intake requests/batch-ok.json +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +HTTP 200 · 5.2s +{ + "processed": 3, + "tickets": [ + "T-2001", + "T-2002", + "T-2003" + ] +} + +HTTP 500 · 3.2s +{ + "message": "Error in workflow" +} + +fault switch: all ok +created tickets: 5 + T-2001 key 10057-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 + T-2002 key 10057-1 장비/낮음 새 모니터를 하나 더 받을 수 있을까요? + T-2003 key 10057-2 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요 + T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요 +DLQ: 0 (parked 0) +alerts: 0 + +✔ fault switch: {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +HTTP 500 · 2.5s +{ + "message": "Error in workflow" +} + +✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +HTTP 200 · 3.0s +{ + "processed": 3, + "tickets": [ + "T-2006", + "T-2008", + "T-2007" + ] +} + +fault switch: all ok +created tickets: 8 + T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요 + T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요 + T-2006 key 10060-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 + T-2007 key 10060-2 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2008 key 10060-1 장비/낮음 새 모니터를 하나 더 받을 수 있을까요? +DLQ: 0 (parked 0) +alerts: 0 +``` + +**쉽게 말하면** + +- 정상 3건: 200 · `processed: 3`. +- 빈 글이 섞인 3건: **500인데 접수 건 2개(1번째 · 3번째)가 이미 생겼습니다.** 2번째의 빈 제목에 데이터 API가 400을 주자 노드가 멈췄습니다. 사용자는 실패로 알고 다시 보낼 것입니다. +- `flaky`(키마다 첫 시도만 실패): 재시도가 없어 바로 500. +- 정상 3건을 다시: 새 번호 3개 → 접수 건 8개, VPN 문의가 T-2001 · T-2006 둘. 키가 실행 번호(`10057-0` → `10060-0`)라서입니다. 예상한 결과. + +### 02 · Lab 01 예제 2 하기 전 → `lab01` 0/6 (기준에) + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab01.json --webhook base-intake -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/base-intake · 6 case(s) × 1 +✘ 정상 3건 · 2.7s · created = null (expected 3); failed = null (expected 0); rejected = null (expected 0) + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "processed": 3, + "tickets": [ + "T-2001", + "T-2002", + "T-2003" + ] + } +✘ 같은 요청을 다시 보내도 중복 없음 · 3.1s · created = null (expected 3); duplicates = null (expected 3) + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "processed": 3, + "tickets": [ + "T-2004", + "T-2005", + "T-2006" + ] + } +✘ 만들어진 접수 건은 3개 · 0.0s · count = 6 (expected 3) + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 6, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "10061-0", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "10061-1", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2003", + "key": "10061-2", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "10062-0", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2005", + "key": "10062-1", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2006", + "key": "10062-2", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✘ 잘못된 항목은 그 항목만 거절 · 3.4s · status 500 (expected 200); created = null (expected 2); rejected = null (expected 1); failed = null (expected 0); results has none of ['text is required'] + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 500 + { + "message": "Error in workflow" + } +✘ 목록이 아니면 400 · 0.1s · status 500 (expected 400) + → request POST /webhook/base-intake + { + "requests": "VPN이 끊겨요" + } + ← response HTTP 500 + { + "message": "Error in workflow" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "flaky" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "flaky", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.0s · status 500 (expected 200); created = null (expected 2); failed = null (expected 0) + → request POST /webhook/base-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 500 + { + "message": "Error in workflow" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +Passed 0/6 +report: runs/lab01-20261029-141145.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- 기준의 결과는 `{processed, tickets}` 모양이라 `created` 등 계약의 칸이 모두 `null`입니다. +- 같은 3건을 두 번 보내 접수 건이 6개(3이 기대) — 중복. 빈 글 · 목록이 아닌 본문 · `flaky`는 모두 500. 예상한 결과. + +### 03 · Lab 01 예제 2 · 항목 확인 · 재시도 · 멱등 키 → 6/6 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/intake.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab01-example2-intake.json`): + +```text +my-work/intake.json (baseline 을 복사한 것) 의 웹훅 경로를 my-intake 로, 이름을 "76 Intake (my)" 로 바꿔 줘. +requests 가 목록이 아니면 400 {"error": "requests must be a list"}. +항목마다 id · user · text 를 확인해서, 빠진 것이 있으면 LLM 과 API 로 보내지 말고 status rejected 와 이유("text is required" 등)로. +Create ticket 노드에 timeout 3000ms, 재시도 3 번, 간격 300ms, 그리고 실패해도 다음으로 가게 onError continueRegularOutput . +접수 건의 key 는 요청의 id (실행 번호가 아님). +처리한 갈래와 거절한 갈래를 Merge 로 모아 AGENTS.md 3절 결과 모양으로, HTTP 200 . +기대 결과 cases/lab01.json. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab01-example2-intake.json +uv run tools/flow.py test cases/lab01.json -v +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +✔ pushed '76 Intake · retry and idempotency key (reference, Lab 01 example 2)' (id w8399f905b79a073) + POST http://localhost:5678/webhook/my-intake + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 3.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 3, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": true, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": true, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✔ 만들어진 접수 건은 3개 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 3, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✔ 잘못된 항목은 그 항목만 거절 · 1.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-203", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "소프트웨어", + "priority": "보통" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } +✔ 목록이 아니면 400 · 0.0s + → request POST /webhook/my-intake + { + "requests": "VPN이 끊겨요" + } + ← response HTTP 400 + { + "error": "requests must be a list" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "flaky" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "flaky", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2006", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2007", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +Passed 6/6 +report: runs/lab01-20261029-141207.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +fault switch: all ok +created tickets: 7 + T-2003 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2004 key R-201 장비/보통 프린터 용지가 걸려요 + T-2005 key R-203 소프트웨어/보통 메일 첨부가 안 열려요 + T-2006 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2007 key R-302 장비/보통 노트북 배터리가 금방 닳아요 +DLQ: 0 (parked 0) +alerts: 0 +``` + +**쉽게 말하면** + +- 다시 보낸 `batch-ok`가 `created: 3, duplicates: 3`: 키가 요청 `id`라 데이터 API가 처음 번호를 돌려줬습니다. 접수 건은 3개 그대로. +- 빈 글 항목만 `rejected`(`text is required`), 나머지 2건은 접수. 목록이 아니면 400. +- `flaky`에서도 `created: 2`: 첫 시도 500 → 0.3초 뒤 두 번째 성공. 재시도가 두 건을 모두 다시 보냈지만 키 덕분에 중복 없음. 6/6, 예상한 결과. + +### 04 · Lab 01 예제 3 · 고장 종류마다 재시도가 하는 일 → 5/5 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab01-faults.json -v +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 7 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "flaky" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "flaky", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ flaky · 다시 해 보면 됨 · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ error · 세 번 해도 실패 · 3.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "slow", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "slow" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "slow", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ slow · 3초씩 세 번 기다리다 실패 · 12.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-102", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-103", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + } + ] + } +… before: wait 8s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 실패라던 3건이 늦게 만들어짐 (2 + 3) · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "낮음" + }, + { + "ticket_id": "T-2005", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + } + ] + } +✔ 같은 요청을 다시 · 멱등 키로 중복 없음 · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 3, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2005", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2004", + "duplicate": true, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": true, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +Passed 5/5 +report: runs/lab01-faults-20261029-141237.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +fault switch: all ok +created tickets: 5 + T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요 + T-2003 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 + T-2004 key R-102 장비/낮음 새 모니터를 하나 더 받을 수 있을까요? + T-2005 key R-101 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 +DLQ: 0 (parked 0) +alerts: 0 +``` + +**쉽게 말하면** + +- `flaky` 2.4초 · 성공, `error` 3.4초 · `failed: 3` (재시도가 시간만 더함), `slow` **12.4초** · `failed: 3` (`timeout of 3000ms exceeded` 세 번). +- 8초 뒤 접수 건 **5개**: 실패라던 R-101~103이 늦게 만들어졌습니다. 시간 초과는 "안 됨"이 아니라 "모름"입니다. 시도 9번이 갔지만 키가 같아 3건만. +- 같은 3건을 다시 보내자 `duplicates: 3`. 5/5, 예상한 결과. + +### 05 · Lab 01 과제 1 하기 전 → `lab01-challenge` 1/3 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab01-challenge.json -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1 +✔ 평소에는 분류됨 · 2.1s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up +✘ LLM이 멈추면 미분류로 접수 · 0.8s · status 500 (expected 200); created = null (expected 3); unclassified = null (expected 3); failed = null (expected 0) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 500 + { + "message": "Error in workflow" + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✘ 미분류도 접수 건으로 · 0.0s · tickets has none of ['미분류'] + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } +Passed 1/3 +report: runs/lab01-challenge-20261029-141245.json + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- 평소 분류는 ✔. `llm down`(틀린 키)에서 분류 노드가 멈춰 **0.8초에 500**, 접수 0건. 미분류 접수 건도 없어 ✘. 예상한 결과. + +### 06 · Lab 01 과제 1 · LLM이 멈춰도 미분류로 접수 → 3/3 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/intake.json` · 이 기록에서 push한 같은 역할의 정답 `answers/76-lab01-challenge-fallback.json`): + +```text +my-work/intake.json 에서 Classify(AI Agent) 노드가 실패해도 워크플로우가 멈추지 않게 onError continueRegularOutput 로 해 줘. +Parse 에서 그 항목에 error 가 있거나 output 이 JSON 이 아니면 category "미분류", priority "보통" 으로. +결과의 unclassified 에 미분류로 접수한 수를 세어 줘. 나머지는 그대로. +기대 결과 cases/lab01-challenge.json, cases/lab01.json 도 그대로 통과. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/76-lab01-challenge-fallback.json +uv run tools/flow.py test cases/lab01-challenge.json -v +uv run tools/flow.py test cases/lab01.json +``` + +출력 (그대로): + +```text +! unpublished '76 Intake · retry and idempotency key (reference, Lab 01 example 2)' (w8399f905b79a073): it used the same webhook path +✔ pushed '76 Intake · LLM fallback (answer, Lab 01 task 1)' (id w0f5e1ef11aa4c14) + POST http://localhost:5678/webhook/my-intake + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1 +✔ 평소에는 분류됨 · 3.5s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 2, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "계정", + "priority": "높음" + }, + { + "id": "R-302", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + } + ] + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up +✔ LLM이 멈추면 미분류로 접수 · 0.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 3, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "미분류", + "priority": "보통" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "미분류", + "priority": "보통" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "미분류", + "priority": "보통" + } + ] + } + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✔ 미분류도 접수 건으로 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "미분류", + "priority": "보통" + }, + { + "ticket_id": "T-2004", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "미분류", + "priority": "보통" + }, + { + "ticket_id": "T-2005", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "미분류", + "priority": "보통" + } + ] + } +Passed 3/3 +report: runs/lab01-challenge-20261029-141310.json + $ bash 65/n8n/register-credentials.sh → exit 0 + Successfully imported 2 credentials. + Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env +✔ OpenAI credential: real key from .env restored +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 3.1s +✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s +✔ 만들어진 접수 건은 3개 · 0.0s +✔ 잘못된 항목은 그 항목만 거절 · 1.8s +✔ 목록이 아니면 400 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +Passed 6/6 +report: runs/lab01-20261029-141323.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +``` + +**쉽게 말하면** + +- LLM이 멈춰도 `created: 3, unclassified: 3` (0.9초). 분류 노드가 `error`를 넘기고 `Parse`가 미분류 · 보통으로 정했습니다. +- `llm up` 뒤 `lab01` 6/6: 평소 분류는 그대로. 예상한 결과. + +### 07 · Lab 01 과제 2 하기 전 → `lab01-challenge2` 1/4 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab01-challenge2.json -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 7 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✘ 같은 id가 두 번이면 두 번째는 거절 · 2.8s · created = 3 (expected 2); rejected = 0 (expected 1); duplicates = 1 (expected 0); results has none of ['duplicate id'] + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 1, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": true, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + } + ] + } +✘ 11건은 400 (한 번에 10건까지) · 11.0s · status 200 (expected 400); error has none of ['too many'] + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-501", + "user": "박서연", + "text": "모니터 1번이 켜지지 않아요" + }, + { + "id": "R-502", + "user": "최유진", + "text": "모니터 2번이 켜지지 않아요" + }, + { + "id": "R-503", + "user": "정우성", + "text": "모니터 3번이 켜지지 않아요" + }, + { + "id": "R-504", + "user": "한지훈", + "text": "모니터 4번이 켜지지 않아요" + }, + { + "id": "R-505", + "user": "김민지", + "text": "모니터 5번이 켜지지 않아요" + }, + { + "id": "R-506", + "user": "이준호", + "text": "모니터 6번이 켜지지 않아요" + }, + { + "id": "R-507", + "user": "박서연", + "text": "모니터 7번이 켜지지 않아요" + }, + { + "id": "R-508", + "user": "최유진", + "text": "모니터 8번이 켜지지 않아요" + }, + { + "id": "R-509", + "user": "정우성", + "text": "모니터 9번이 켜지지 않아요" + }, + { + "id": "R-510", + "user": "한지훈", + "text": "모니터 10번이 켜지지 않아요" + }, + { + "id": "R-511", + "user": "김민지", + "text": "모니터 11번이 켜지지 않아요" + } + ] + } + ← response HTTP 200 + { + "total": 11, + "created": 11, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-501", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-502", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-503", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-504", + "status": "created", + "ticket_id": "T-2006", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-505", + "status": "created", + "ticket_id": "T-2007", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-506", + "status": "created", + "ticket_id": "T-2008", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-507", + "status": "created", + "ticket_id": "T-2013", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-508", + "status": "created", + "ticket_id": "T-2012", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-509", + "status": "created", + "ticket_id": "T-2010", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-510", + "status": "created", + "ticket_id": "T-2011", + "duplicate": false, + "category": "장비", + "priority": "높음" + }, + { + "id": "R-511", + "status": "created", + "ticket_id": "T-2009", + "duplicate": false, + "category": "장비", + "priority": "높음" + } + ] + } +✘ 400이면 하나도 만들지 않음 · 0.0s · count = 13 (expected 2) + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 13, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-401", + "title": "회의실 화면 공유가 안 돼요", + "requester": "김민지", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-402", + "title": "비밀번호를 바꾸라는 창이 계속 떠요", + "requester": "최유진", + "category": "계정", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-502", + "title": "모니터 2번이 켜지지 않아요", + "requester": "최유진", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-501", + "title": "모니터 1번이 켜지지 않아요", + "requester": "박서연", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2005", + "key": "R-503", + "title": "모니터 3번이 켜지지 않아요", + "requester": "정우성", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2006", + "key": "R-504", + "title": "모니터 4번이 켜지지 않아요", + "requester": "한지훈", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2007", + "key": "R-505", + "title": "모니터 5번이 켜지지 않아요", + "requester": "김민지", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2008", + "key": "R-506", + "title": "모니터 6번이 켜지지 않아요", + "requester": "이준호", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2009", + "key": "R-511", + "title": "모니터 11번이 켜지지 않아요", + "requester": "김민지", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2010", + "key": "R-509", + "title": "모니터 9번이 켜지지 않아요", + "requester": "정우성", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2011", + "key": "R-510", + "title": "모니터 10번이 켜지지 않아요", + "requester": "한지훈", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2012", + "key": "R-508", + "title": "모니터 8번이 켜지지 않아요", + "requester": "최유진", + "category": "장비", + "priority": "높음" + }, + { + "ticket_id": "T-2013", + "key": "R-507", + "title": "모니터 7번이 켜지지 않아요", + "requester": "박서연", + "category": "장비", + "priority": "높음" + } + ] + } +✔ 평소 요청은 그대로 · 3.2s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2014", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2015", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2016", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +Passed 1/4 +report: runs/lab01-challenge2-20261029-141341.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- 같은 id 두 번: `created: 3, duplicates: 1` — 접수 건은 둘이지만 결과는 셋을 접수했다고 말하고 분류도 세 번 했습니다. +- 11건: 상한이 없어 **11.0초** 동안 모두 처리, 접수 건 13개. 평소 요청만 ✔. 예상한 결과. + +### 08 · Lab 01 과제 2 · 요청 안의 규칙 → 4/4 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/intake.json` · 이 기록에서 push한 같은 역할의 정답 `answers/76-lab01-challenge2-rules.json`): + +```text +my-work/intake.json 에서 requests 가 목록이어도 10 건이 넘으면 HTTP 400 {"error": "too many requests (max 10)"} 로, 하나도 처리하지 말아 줘. +목록이 아닐 때의 400 글은 그대로 "requests must be a list". +Check items 에서 같은 요청 안에 같은 id 가 또 나오면 두 번째부터 rejected, 이유 "duplicate id in this request". +앞에서 본 id 는 Set 에 기억해 둔다. 나머지 규칙(빠진 칸 거절 · 재시도 · 키 · 미분류)은 그대로. +기대 결과 cases/lab01-challenge2.json, cases/lab01.json · lab01-challenge.json 도 통과. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/76-lab01-challenge2-rules.json +uv run tools/flow.py test cases/lab01-challenge2.json -v +uv run tools/flow.py test cases/lab01.json +uv run tools/flow.py test cases/lab01-challenge.json +``` + +출력 (그대로): + +```text +! unpublished '76 Intake · LLM fallback (answer, Lab 01 task 1)' (w0f5e1ef11aa4c14): it used the same webhook path +✔ pushed '76 Intake · request rules (answer, Lab 01 task 2)' (id w4bb85fc171aea9d) + POST http://localhost:5678/webhook/my-intake + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 16} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 16 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ 같은 id가 두 번이면 두 번째는 거절 · 3.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ] + } +✔ 11건은 400 (한 번에 10건까지) · 0.2s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-501", + "user": "박서연", + "text": "모니터 1번이 켜지지 않아요" + }, + { + "id": "R-502", + "user": "최유진", + "text": "모니터 2번이 켜지지 않아요" + }, + { + "id": "R-503", + "user": "정우성", + "text": "모니터 3번이 켜지지 않아요" + }, + { + "id": "R-504", + "user": "한지훈", + "text": "모니터 4번이 켜지지 않아요" + }, + { + "id": "R-505", + "user": "김민지", + "text": "모니터 5번이 켜지지 않아요" + }, + { + "id": "R-506", + "user": "이준호", + "text": "모니터 6번이 켜지지 않아요" + }, + { + "id": "R-507", + "user": "박서연", + "text": "모니터 7번이 켜지지 않아요" + }, + { + "id": "R-508", + "user": "최유진", + "text": "모니터 8번이 켜지지 않아요" + }, + { + "id": "R-509", + "user": "정우성", + "text": "모니터 9번이 켜지지 않아요" + }, + { + "id": "R-510", + "user": "한지훈", + "text": "모니터 10번이 켜지지 않아요" + }, + { + "id": "R-511", + "user": "김민지", + "text": "모니터 11번이 켜지지 않아요" + } + ] + } + ← response HTTP 400 + { + "error": "too many requests (max 10)" + } +✔ 400이면 하나도 만들지 않음 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-401", + "title": "회의실 화면 공유가 안 돼요", + "requester": "김민지", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-402", + "title": "비밀번호를 바꾸라는 창이 계속 떠요", + "requester": "최유진", + "category": "계정", + "priority": "보통" + } + ] + } +✔ 평소 요청은 그대로 · 3.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2004", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2005", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +Passed 4/4 +report: runs/lab01-challenge2-20261029-141401.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 2.8s +✔ 같은 요청을 다시 보내도 중복 없음 · 2.8s +✔ 만들어진 접수 건은 3개 · 0.0s +✔ 잘못된 항목은 그 항목만 거절 · 1.8s +✔ 목록이 아니면 400 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +Passed 6/6 +report: runs/lab01-20261029-141411.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1 +✔ 평소에는 분류됨 · 2.0s +✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up +✔ LLM이 멈추면 미분류로 접수 · 1.4s +✔ OpenAI credential: real key from .env restored +✔ 미분류도 접수 건으로 · 0.0s +Passed 3/3 +report: runs/lab01-challenge-20261029-141421.json +✔ OpenAI credential: real key from .env restored +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +``` + +**쉽게 말하면** + +- 같은 id의 두 번째는 `rejected`(`duplicate id in this request`), `created: 2, duplicates: 0`. +- 11건은 **0.2초에 400** (`too many requests (max 10)`), 접수 건 2개 그대로. LLM · API를 부르기 전에 입구에서 막았습니다. +- `lab01` 6/6 · `lab01-challenge` 3/3. 예상한 결과. + +## Lab 02 · 실패를 모으고(DLQ), 알리기 (Error Workflow) + +### 09 · Lab 02 하기 전 · 실패한 2건은 어디로? → `lab02` 2/4 + +실행한 명령: + +```bash +uv run tools/flow.py ops --reset +uv run tools/flow.py fault tickets error +uv run tools/flow.py call my-intake requests/batch-two.json +uv run tools/flow.py fault tickets ok +uv run tools/flow.py ops +uv run tools/flow.py call nightly-report +uv run tools/flow.py test cases/lab02.json -v +``` + +출력 (그대로): + +```text +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +✔ fault switch: {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +HTTP 200 · 2.8s +{ + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] +} + +✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +fault switch: all ok +created tickets: 0 +DLQ: 0 (parked 0) +alerts: 0 + +HTTP 500 · 0.1s +{ + "message": "Error in workflow" +} + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 예제 1 · 실패한 2건은 DLQ에 · 0.0s · size = 0 (expected 2); items has none of ['R-301', 'R-302'] + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 0, + "parked": 0, + "items": [] + } +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); reason = null (expected unexpected); workflow = null (expected 76 Nightly report (provided, has a bug)); node has none of ['Count by category'] + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +Passed 2/4 +report: runs/lab02-20261029-141433.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- `fault tickets error` 동안 `my-intake`: 200 · `failed: 2`. 그런데 `ops`의 DLQ 0 · 알림 0 — 결과 화면을 닫으면 **두 문의가 사라집니다.** +- `nightly-report`: 500. 에러 처리기가 아직 없어 알림 0 — 아무도 모릅니다. +- DLQ 케이스 · 알림 케이스 ✘, 나머지 ✔. 예상한 결과. + +### 10 · Lab 02 예제 1 · 실패한 요청은 DLQ에 → `lab02` 3/4 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/intake.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab02-example1-dlq.json`): + +```text +my-work/intake.json 의 Summarize 뒤에 Any failed?(If) 를 넣어 failed 가 1 이상일 때만 Send to DLQ(HTTP POST http://data-api:8800/ dlq ) 로. +본문은 {items: [...]}, 실패한 항목마다 { item: 원래 요청 {id, user, text}, reason, workflow: "intake" }. rejected 는 넣지 않는다 . +DLQ 저장이 실패해도 응답은 나가게 options.response 에 neverError: true , 두 갈래 모두 Public result 로 가서 같은 결과를 응답. +기대 결과 cases/lab02.json 앞 두 케이스. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab02-example1-dlq.json +uv run tools/flow.py test cases/lab02.json -v +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +! unpublished '76 Intake · request rules (answer, Lab 01 task 2)' (w4bb85fc171aea9d): it used the same webhook path +✔ pushed '76 Intake · DLQ (reference, Lab 02 example 1, includes Lab 01 tasks)' (id w92de92194fc1657) + POST http://localhost:5678/webhook/my-intake + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.1s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250889.918716 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250889.918718 + } + ] + } +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); reason = null (expected unexpected); workflow = null (expected 76 Nightly report (provided, has a bug)); node has none of ['Count by category'] + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +Passed 3/4 +report: runs/lab02-20261029-141452.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +fault switch: all ok +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 0 +``` + +**쉽게 말하면** + +- DLQ에 R-301 · R-302가 원래 요청 그대로, 이유(`500 - tickets service is failing`) · `attempts 1`과 함께. +- 남은 ✘ 하나는 예제 2(Error Workflow)의 알림 케이스. 예상한 결과. + +### 11 · Lab 02 예제 2 · Error Workflow → `lab02` 4/4 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/error-handler.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab02-example2-error-handler.json (+ reference/lab02-example2-intake.json)`): + +```text +my-work/error-handler.json 을 새로 만들어 줘. 파일 맨 위에 "id": "errHandler76001", 첫 노드는 errorTrigger . +Make alert(Code) 가 workflow.name · workflow.id · execution.lastNodeExecuted · execution.error.message · execution.id 를 +{workflow, workflow_id, node, message, execution_id, severity: "critical", reason: "unexpected"} 로 만들어 POST http://data-api:8800/alerts. +my-work/intake.json 의 settings 에 errorWorkflow: "errHandler76001" 을 넣어 줘. +기대 결과 cases/lab02.json 전체. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab02-example2-error-handler.json reference/lab02-example2-intake.json +uv run tools/flow.py test cases/lab02.json -v +uv run tools/flow.py ops +uv run tools/flow.py test cases/lab01.json +``` + +출력 (그대로): + +```text +! unpublished '76 Intake · DLQ (reference, Lab 02 example 1, includes Lab 01 tasks)' (w92de92194fc1657): it used the same webhook path +✔ pushed '76 Error handler (reference)' (id errHandler76001) +✔ pushed '76 Intake · DLQ + error workflow (reference, Lab 02 example 2)' (id w01376519d41c1ce) + POST http://localhost:5678/webhook/my-intake + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250914.161773 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250914.161775 + } + ] + } +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10104", + "severity": "critical", + "reason": "unexpected", + "at": 1793250914.256605 + } +Passed 4/4 +report: runs/lab02-20261029-141517.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +fault switch: all ok +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 1 + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ 정상 3건 · 2.9s +✔ 같은 요청을 다시 보내도 중복 없음 · 2.6s +✔ 만들어진 접수 건은 3개 · 0.0s +✔ 잘못된 항목은 그 항목만 거절 · 1.9s +✔ 목록이 아니면 400 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.5s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +Passed 6/6 +report: runs/lab01-20261029-141527.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +``` + +**쉽게 말하면** + +- `nightly-report`의 500 뒤 알림 `[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]`: 어느 워크플로우 · 어느 노드 · 무슨 오류인지 바로 압니다. +- 알림은 응답이 나간 뒤에 생깁니다 (테스트는 3초 기다림). `lab01` 6/6. 예상한 결과. + +### 12 · Lab 02 예제 3 · 어느 실패가 어느 그물에 → `lab02-nets` 5/7 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab02-nets.json -v +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 7 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 1.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-203", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "소프트웨어", + "priority": "보통" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · API 오류 → failed · 2.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ 3 · failed는 DLQ에 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250932.1935513 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250932.1935537 + } + ] + } +✔ 4 · 버그 → 500 · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 5 · Error Workflow → critical 알림 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10114", + "severity": "critical", + "reason": "unexpected", + "at": 1793250932.293378 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✘ 6 · DLQ마저 멈추면 결과가 알려야 · 4.1s · dlq_saved = null (expected false) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✘ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s · reason = unexpected (expected dlq_down) + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10114", + "severity": "critical", + "reason": "unexpected", + "at": 1793250932.293378 + } +Passed 5/7 +report: runs/lab02-nets-20261029-141539.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +fault switch: all ok +created tickets: 2 + T-2001 key R-201 장비/보통 프린터 용지가 걸려요 + T-2002 key R-203 소프트웨어/보통 메일 첨부가 안 열려요 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 1 + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] +``` + +**쉽게 말하면** + +- 잘못된 항목 → 결과의 `rejected`에만 · API 오류 → `failed` + DLQ 2건 · 버그 → 500 + critical 알림: 그물 셋이 각자 일했습니다. +- **DLQ마저 멈추면**(`fault dlq error`): 200 · `failed: 3`인데 DLQ는 그대로 2건 — 세 문의가 **아무 데도 남지 않았고**, 결과에 그 사실(`dlq_saved`)도 없고, 알림도 없습니다. 그물 사이의 구멍입니다. +- 6 · 7번 ✘는 과제 1에서 고칩니다. 예상한 결과. + +### 13 · Lab 02 과제 1 하기 전 → `lab02-challenge` 0/6 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab02-challenge.json -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ API가 멈춤 → DLQ · 2.7s · dlq_saved = null (expected true); alert = null (expected sent) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 처리한 실패도 알림 (high · 개수) · 0.0s · severity = null (expected high); reason = null (expected dlq); count = null (expected 2); workflow = null (expected intake) + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +✘ 실패가 없으면 알림 없음 · 2.8s · alert = null (expected none) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ] + } +✘ 알림은 그대로 1개 · 0.0s · count = 0 (expected 1) + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 0, + "alerts": [] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✘ DLQ도 멈추면 critical · 2.4s · dlq_saved = null (expected false) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ] + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 잃어버린 id를 알림에 · 0.0s · severity = null (expected critical); reason = null (expected dlq_down); count = null (expected 2); message has none of ['R-201'] + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + {} +Passed 0/6 +report: runs/lab02-challenge-20261029-141547.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- DLQ에는 넣지만 결과에 `dlq_saved` · `alert`가 없고, high 알림도 없고, DLQ가 멈춰도 critical 알림이 없습니다. 모두 ✘. 예상한 결과. + +### 14 · Lab 02 과제 1 · 처리한 실패도 알리기 → 6/6 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/intake.json` · 이 기록에서 push한 같은 역할의 정답 `answers/76-lab02-challenge-alert.json`): + +```text +my-work/intake.json 의 Send to DLQ 뒤에 Make alert(Code) 와 Send alert(HTTP POST http://data-api:8800/alerts) 를 넣어 줘. +DLQ 응답에 stored 가 있으면 {workflow: "intake", severity: "high", reason: "dlq", count: failed 수, message}, +없으면(DLQ 저장 실패) severity "critical", reason " dlq_down ", message 에 잃을 뻔한 요청 id 들. +알림은 요청 하나에 하나 (항목마다가 아님). 실패가 없으면 알림을 보내지 않는다. +결과에 dlq_saved(실패가 있을 때 true/false) 와 alert("sent" / "none") 를 더해 줘. +기대 결과 cases/lab02-challenge.json, cases/lab02-nets.json, cases/lab02.json. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/76-lab02-challenge-alert.json +uv run tools/flow.py test cases/lab02-challenge.json -v +uv run tools/flow.py test cases/lab02-nets.json +uv run tools/flow.py test cases/lab02.json +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +! unpublished '76 Intake · DLQ + error workflow (reference, Lab 02 example 2)' (w01376519d41c1ce): it used the same webhook path +✔ pushed '76 Intake · alert on handled failures (answer, Lab 02 task 1)' (id w82959c505ef75f7) + POST http://localhost:5678/webhook/my-intake + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 멈춤 → DLQ · 4.3s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 처리한 실패도 알림 (high · 개수) · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10120", + "at": 1793250965.2341518 + } +✔ 실패가 없으면 알림 없음 · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "낮음" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ], + "alert": "none" + } +✔ 알림은 그대로 1개 · 0.0s + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 1, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10120", + "at": 1793250965.2341518 + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✔ DLQ도 멈추면 critical · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 잃어버린 id를 알림에 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "critical", + "reason": "dlq_down", + "count": 2, + "message": "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203", + "execution_id": "10122", + "ids": [ + "R-201", + "R-203" + ], + "at": 1793250970.5394928 + } +Passed 6/6 +report: runs/lab02-challenge-20261029-141610.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 2.1s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 2 · API 오류 → failed · 2.9s +✔ 3 · failed는 DLQ에 · 0.0s +✔ 4 · 버그 → 500 · 0.1s +… before: wait 3s +✔ 5 · Error Workflow → critical 알림 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} +✔ 6 · DLQ마저 멈추면 결과가 알려야 · 3.7s +✔ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s +Passed 7/7 +report: runs/lab02-nets-20261029-141622.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.7s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s +✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s +… before: wait 3s +✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s +Passed 4/4 +report: runs/lab02-20261029-141628.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +fault switch: all ok +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 2 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] +``` + +**쉽게 말하면** + +- 실패 2건 → `dlq_saved: true, alert: "sent"`, 알림 `[high] intake · dlq · count 2 · 2 request(s) could not be created and wait in the DLQ`. +- 실패가 없으면 `alert: "none"`, 알림은 그대로 1개. +- DLQ도 멈추면 `dlq_saved: false` + `[critical] dlq_down` 알림, 메시지에 `lost ids R-201, R-203` — 사람이 손으로라도 살릴 수 있습니다. +- `lab02-nets` 7/7 (구멍이 막힘) · `lab02` 4/4. 예상한 결과. + +### 15 · Lab 02 과제 2 하기 전 → `lab02-challenge2` 4/6 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab02-challenge2.json -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 첫 실패 → 알림 보냄 · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✘ 10분 안에 또 실패 → 보내지 않음 · 3.7s · alert = sent (expected suppressed) + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✘ dlq 알림은 1개 · 0.0s · count = 2 (expected 1) + → request POST http://localhost:8800/alerts/list + { + "reason": "dlq" + } + ← response HTTP 200 + { + "count": 2, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10131", + "at": 1793250991.527381 + }, + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 3, + "message": "3 request(s) could not be created and wait in the DLQ", + "execution_id": "10132", + "at": 1793250995.206434 + } + ] + } +✔ 그래도 DLQ에는 5건 모두 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 5, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250991.5196192 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250991.5196211 + }, + { + "dlq_id": "D-3", + "attempts": 1, + "parked": false, + "item": { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972318 + }, + { + "dlq_id": "D-4", + "attempts": 1, + "parked": false, + "item": { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972342 + }, + { + "dlq_id": "D-5", + "attempts": 1, + "parked": false, + "item": { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793250995.1972353 + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.5s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.0s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ], + "alert": "none" + } +Passed 4/6 +report: runs/lab02-challenge2-20261029-141639.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- 10분 안에 두 번째 실패에도 알림을 또 보내 `alert: "sent"`, dlq 알림 2개 ✘. 장애가 한 시간 이어지면 요청마다 알림이 쌓입니다 (알림 피로). +- DLQ에는 5건 모두 · critical은 보냄 · 고친 뒤 알림 없음은 이미 ✔. 예상한 결과. + +### 16 · Lab 02 과제 2 · 알림 폭주 막기 → 6/6 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/intake.json` · 이 기록에서 push한 같은 역할의 정답 `answers/76-lab02-challenge2-throttle.json`): + +```text +my-work/intake.json 의 Make alert 뒤에 Recent alerts(HTTP POST http://data-api:8800/alerts/list) 를 넣어 +본문 {workflow: "intake", reason: 이번 알림의 reason, within: 600 } 로 10분 안의 같은 알림 수를 받아 줘. +Decide alert(Code): severity 가 high 이고 그 수가 1 이상이면 suppressed true. critical 은 늘 보낸다 . +Send it?(If) 로 suppressed 가 아닐 때만 Send alert, 결과의 alert 는 "sent" / "suppressed" / "none". +기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/76-lab02-challenge2-throttle.json +uv run tools/flow.py test cases/lab02-challenge2.json -v +uv run tools/flow.py test cases/lab02-challenge.json +uv run tools/flow.py test cases/lab01-challenge2.json +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +! unpublished '76 Intake · alert on handled failures (answer, Lab 02 task 1)' (w82959c505ef75f7): it used the same webhook path +✔ pushed '76 Intake · alert once per 10 minutes (answer, Lab 02 task 2)' (id w75a95815facf2d6) + POST http://localhost:5678/webhook/my-intake + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 첫 실패 → 알림 보냄 · 4.5s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 10분 안에 또 실패 → 보내지 않음 · 3.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 3, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-102", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-103", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "suppressed" + } +✔ dlq 알림은 1개 · 0.0s + → request POST http://localhost:8800/alerts/list + { + "reason": "dlq" + } + ← response HTTP 200 + { + "count": 1, + "alerts": [ + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10135", + "at": 1793251017.2530532 + } + ] + } +✔ 그래도 DLQ에는 5건 모두 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 5, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251017.2315063 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251017.2315085 + }, + { + "dlq_id": "D-3", + "attempts": 1, + "parked": false, + "item": { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.9757996 + }, + { + "dlq_id": "D-4", + "attempts": 1, + "parked": false, + "item": { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.9758017 + }, + { + "dlq_id": "D-5", + "attempts": 1, + "parked": false, + "item": { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251020.975803 + } + ] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error", + "dlq": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "error", + "slow_seconds": 8 + } +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 0, + "failed": 2, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-201", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-203", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-202", + "status": "rejected", + "reason": "text is required" + } + ], + "dlq_saved": false, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.8s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 2, + "failed": 0, + "rejected": 1, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-401", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "보통" + }, + { + "id": "R-402", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "계정", + "priority": "보통" + }, + { + "id": "R-401", + "status": "rejected", + "reason": "duplicate id in this request" + } + ], + "alert": "none" + } +Passed 6/6 +report: runs/lab02-challenge2-20261029-141706.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 5} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ API가 멈춤 → DLQ · 2.7s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 처리한 실패도 알림 (high · 개수) · 0.0s +✔ 실패가 없으면 알림 없음 · 2.9s +✔ 알림은 그대로 1개 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} +✔ DLQ도 멈추면 critical · 2.3s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 잃어버린 id를 알림에 · 0.0s +Passed 6/6 +report: runs/lab02-challenge-20261029-141714.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1 +✔ 같은 id가 두 번이면 두 번째는 거절 · 2.1s +✔ 11건은 400 (한 번에 10건까지) · 0.0s +✔ 400이면 하나도 만들지 않음 · 0.0s +✔ 평소 요청은 그대로 · 2.7s +Passed 4/4 +report: runs/lab01-challenge2-20261029-141719.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +fault switch: all ok +created tickets: 5 + T-2001 key R-401 네트워크/높음 회의실 화면 공유가 안 돼요 + T-2002 key R-402 계정/보통 비밀번호를 바꾸라는 창이 계속 떠요 + T-2003 key R-101 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있 + T-2004 key R-102 장비/보통 새 모니터를 하나 더 받을 수 있을까요? + T-2005 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요 +DLQ: 0 (parked 0) +alerts: 0 +``` + +**쉽게 말하면** + +- 두 번째 실패는 `alert: "suppressed"`, dlq 알림은 1개 — 그래도 **DLQ에는 5건 모두** 있습니다. 알림을 줄인 것이지 실패를 숨긴 것이 아닙니다. +- DLQ가 멈춘 critical은 10분 안이라도 보냄. `lab02-challenge` 6/6 · `lab01-challenge2` 4/4. 예상한 결과. + +## Lab 03 · 다시 돌리기, 런북과 장애 연습, 끝없이 돌리지 않기 + +### 17 · Lab 03 예제 1 하기 전 → `lab03` 1/5 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03.json -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 멈춘 동안 2건 → DLQ · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 고친 뒤 다시 돌리기 · 0.0s · status 404 (expected 200); replayed = null (expected 2); ok = null (expected 2); still_failed = null (expected 0) + → request POST /webhook/my-replay + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-replay\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ DLQ가 비었음 · 0.0s · size = 2 (expected 0) + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251042.6309726 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251042.630976 + } + ] + } +✘ 접수 건은 2개 (중복 없음) · 0.0s · count = 0 (expected 2) + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 0, + "tickets": [] + } +✘ 한 번 더 돌려도 할 일 없음 · 0.0s · status 404 (expected 200); replayed = null (expected 0) + → request POST /webhook/my-replay + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-replay\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +Passed 1/5 +report: runs/lab03-20261029-141722.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- DLQ에 2건이 쌓였지만 `my-replay`가 없어 404. DLQ 2건 · 접수 건 0개 그대로. 예상한 결과. + +### 18 · Lab 03 예제 1 · 재처리 → 5/5 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/replay.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab03-example1-replay.json`): + +```text +my-work/replay.json 을 새로 만들어 줘. 웹훅 POST my-replay . +Get DLQ(HTTP GET http://data-api:8800/dlq) → Pick items(Code): parked 가 아닌 것만, 비어도 다음으로 가게 alwaysOutputData true. +Has items? 가 없으면 바로 {replayed: 0, ok: 0, still_failed: 0, parked: 0}. +있으면 항목마다 Run intake again: POST http://localhost:5678/webhook/my-intake 본문 {requests: [ item ]} (DLQ 의 원래 요청). +my-intake 결과의 created 가 1 이면 성공 → POST /dlq/update {dlq_id, remove: true}. 실패면 지우지 않는다. +끝에 AGENTS.md 3절 {replayed, ok, still_failed, parked, duplicates}. 기대 결과 cases/lab03.json. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push reference/lab03-example1-replay.json +uv run tools/flow.py test cases/lab03.json -v +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +✔ pushed '76 Replay (reference, Lab 03 example 1)' (id we4c5fdffca93da1) + POST http://localhost:5678/webhook/my-replay + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 멈춘 동안 2건 → DLQ · 4.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤 다시 돌리기 · 1.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ DLQ가 비었음 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 0, + "parked": 0, + "items": [] + } +✔ 접수 건은 2개 (중복 없음) · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2002", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + } + ] + } +✔ 한 번 더 돌려도 할 일 없음 · 0.0s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 0 + } +Passed 5/5 +report: runs/lab03-20261029-141738.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +fault switch: all ok +created tickets: 2 + T-2001 key R-302 장비/보통 노트북 배터리가 금방 닳아요 + T-2002 key R-301 계정/높음 사내 위키 로그인이 안 돼요 +DLQ: 0 (parked 0) +alerts: 1 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ +``` + +**쉽게 말하면** + +- 고친 뒤 `my-replay`: `replayed: 2, ok: 2` (1.1초), DLQ 0, 접수 건 2개 — 중복 없음. 한 번 더 돌리면 `replayed: 0`. +- 재처리는 접수 건을 직접 만들지 않고 `my-intake`를 다시 불렀습니다. 같은 확인 · 분류 · 키 규칙이 그대로 적용됩니다. 예상한 결과. + +### 19 · Lab 03 예제 2 · 런북 · 장애 연습 → `lab03-drill` 7/7 + +실행한 명령: + +```bash +uv run tools/flow.py ops --reset +uv run tools/flow.py fault tickets error +uv run tools/flow.py call my-intake requests/batch-two.json +uv run tools/flow.py call nightly-report +uv run tools/flow.py ops +uv run tools/flow.py fault tickets ok +uv run tools/flow.py call my-replay +uv run tools/flow.py ops +uv run tools/flow.py test cases/lab03-drill.json -v +``` + +출력 (그대로): + +```text +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +✔ fault switch: {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +HTTP 200 · 2.8s +{ + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" +} + +HTTP 500 · 0.1s +{ + "message": "Error in workflow" +} + +fault switch: {"tickets": "error"} +created tickets: 0 +DLQ: 2 (parked 0) + D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f + D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f +alerts: 2 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] + +✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +HTTP 200 · 1.4s +{ + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 +} + +fault switch: all ok +created tickets: 2 + T-2001 key R-302 장비/보통 노트북 배터리가 금방 닳아요 + T-2002 key R-301 계정/높음 사내 위키 로그인이 안 돼요 +DLQ: 0 (parked 0) +alerts: 2 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ + [critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ 1 · 평소 · 2.6s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] + } + ← response HTTP 200 + { + "total": 3, + "created": 3, + "failed": 0, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-101", + "status": "created", + "ticket_id": "T-2001", + "duplicate": false, + "category": "네트워크", + "priority": "높음" + }, + { + "id": "R-102", + "status": "created", + "ticket_id": "T-2002", + "duplicate": false, + "category": "장비", + "priority": "보통" + }, + { + "id": "R-103", + "status": "created", + "ticket_id": "T-2003", + "duplicate": false, + "category": "소프트웨어", + "priority": "높음" + } + ], + "alert": "none" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · 장애 시작 · API 오류 · 3.0s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 3 · DLQ 확인 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251069.7296107 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251069.7296135 + } + ] + } +✔ 4 · 보고서 작업도 실패 · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 5 · 알림 확인 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "76 Nightly report (provided, has a bug)", + "workflow_id": "wb9ce32c18d7df56", + "node": "Count by category", + "message": "rows is not iterable [line 4]", + "execution_id": "10159", + "severity": "critical", + "reason": "unexpected", + "at": 1793251069.8482687 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 6 · 고친 뒤 재처리 · 1.4s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ 7 · 결과 확인 · 중복 없이 5건 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 5, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-101", + "title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요", + "requester": "이준호", + "category": "네트워크", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-102", + "title": "새 모니터를 하나 더 받을 수 있을까요?", + "requester": "박서연", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2003", + "key": "R-103", + "title": "엑셀 라이선스가 만료됐다고 나와요", + "requester": "최유진", + "category": "소프트웨어", + "priority": "높음" + }, + { + "ticket_id": "T-2004", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + }, + { + "ticket_id": "T-2005", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + } + ] + } +Passed 7/7 +report: runs/lab03-drill-20261029-141754.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- 장애를 내고(`fault tickets error` → `my-intake` 실패 2건 → `nightly-report` 500), `ops`로 보면 DLQ 2 · 알림 2(high dlq · critical unexpected). +- 원인을 고치고(`fault tickets ok`) `call my-replay` → DLQ 0 · 접수 건 2. 같은 연습을 테스트로 7/7, 접수 건 5건 (평소 3 + 재처리 2), 중복 없음. 예상한 결과. + +### 20 · Lab 03 예제 3 · 반쯤 처리된 요청 → `lab03-half` 6/6 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03-half.json -v +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "slow", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "slow" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "slow", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 1 · API가 느림 → 기다리다 실패 · 11.6s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + }, + { + "id": "R-302", + "status": "failed", + "reason": "timeout of 3000ms exceeded" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 2 · DLQ에 2건 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 1, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "timeout of 3000ms exceeded", + "workflow": "intake", + "at": 1793251086.0805922 + }, + { + "dlq_id": "D-2", + "attempts": 1, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "timeout of 3000ms exceeded", + "workflow": "intake", + "at": 1793251086.080595 + } + ] + } +… before: wait 8s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 3 · 그런데 접수 건은 이미 2개 · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } +✔ 4 · 재처리 · 이미 있는 건이라 duplicate · 1.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 2 + } +✔ 5 · 그래도 2개 (중복 없음) · 0.0s + → request POST http://localhost:8800/tickets-created/list + {} + ← response HTTP 200 + { + "count": 2, + "tickets": [ + { + "ticket_id": "T-2001", + "key": "R-301", + "title": "사내 위키 로그인이 안 돼요", + "requester": "정우성", + "category": "계정", + "priority": "높음" + }, + { + "ticket_id": "T-2002", + "key": "R-302", + "title": "노트북 배터리가 금방 닳아요", + "requester": "이준호", + "category": "장비", + "priority": "보통" + } + ] + } +✔ 6 · DLQ가 비었음 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 0, + "parked": 0, + "items": [] + } +Passed 6/6 +report: runs/lab03-half-20261029-141815.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +fault switch: all ok +created tickets: 2 + T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요 +DLQ: 0 (parked 0) +alerts: 1 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ +``` + +**쉽게 말하면** + +- `slow`에서 11.6초 뒤 `failed: 2`, DLQ 2건. 그런데 8초 뒤 접수 건 **2개가 이미 있습니다** (데이터 API가 늦게 만듦). +- 재처리 `replayed: 2, ok: 2, duplicates: 2`: 다시 보낸 두 건 모두 "이미 있는 건". 접수 건은 2개 그대로, DLQ 0. +- DLQ의 요청이 사실은 반쯤 처리된 것이어도 재처리가 안전한 것은 **멱등 키** 덕분입니다. 예상한 결과. + +### 21 · Lab 03 과제 1 하기 전 → `lab03-challenge` 3/7 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03-challenge.json -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 오래 멈춤 · 처음 실패 · 3.4s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 재처리 1 · 또 실패 · 2.2s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✔ 재처리 2 · 또 실패 · 2.4s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✘ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 2.0s · replayed = 2 (expected 0); parked = 0 (expected 2) + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✘ parked로 남음 · 0.0s · parked = 0 (expected 2) + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 0, + "items": [ + { + "dlq_id": "D-1", + "attempts": 4, + "parked": false, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251105.2922482 + }, + { + "dlq_id": "D-2", + "attempts": 4, + "parked": false, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251105.5231082 + } + ] + } +✘ 멈춘 것은 알림 · 0.0s · reason = dlq (expected parked) + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "intake", + "node": "Send to DLQ", + "severity": "high", + "reason": "dlq", + "count": 2, + "message": "2 request(s) could not be created and wait in the DLQ", + "execution_id": "10168", + "at": 1793251099.0033967 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 1.6s · replayed = 2 (expected 0) + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +Passed 3/7 +report: runs/lab03-challenge-20261029-141827.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- 재처리 1 · 2는 또 실패(기대대로). 재처리 3도 다시 보내 `replayed: 2, parked: 0` — **세 번 실패해도 끝없이 다시 보냅니다.** parked 알림도 없고, 고친 뒤 다시 돌리면 자동으로 보내 버립니다. 예상한 결과. + +### 22 · Lab 03 과제 1 · 세 번 실패하면 멈추기 → 7/7 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/replay.json` · 이 기록에서 push한 같은 역할의 정답 `answers/76-lab03-challenge-park.json`): + +```text +my-work/replay.json 의 Pick items 에서 body.max_attempts(없으면 3 ) 를 읽어, +attempts 가 그 수 이상 인 항목은 action "park", 나머지는 "replay" 로 나눠 줘. +Replay it?(If): replay 는 지금처럼 다시 보내고, park 는 POST /dlq/update {dlq_id, parked: true , reason} 뒤 +POST /alerts {workflow: "replay", severity: "high", reason: "parked", dlq_id, message}. +두 갈래를 Merge 로 모아 요약의 parked 에 수를. parked 항목은 지우지 않는다. +기대 결과 cases/lab03-challenge.json, cases/lab03.json. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/76-lab03-challenge-park.json +uv run tools/flow.py test cases/lab03-challenge.json -v +uv run tools/flow.py test cases/lab03.json +uv run tools/flow.py ops +``` + +출력 (그대로): + +```text +! unpublished '76 Replay (reference, Lab 03 example 1)' (we4c5fdffca93da1): it used the same webhook path +✔ pushed '76 Replay · park after 3 attempts (answer, Lab 03 task 1)' (id w7d8de63c811909b) + POST http://localhost:5678/webhook/my-replay + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ API가 오래 멈춤 · 처음 실패 · 4.3s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ 재처리 1 · 또 실패 · 1.9s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✔ 재처리 2 · 또 실패 · 2.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 0, + "still_failed": 2, + "parked": 0, + "duplicates": 0 + } +✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 2, + "duplicates": 0 + } +✔ parked로 남음 · 0.0s + → request POST http://localhost:8800/dlq/list + {} + ← response HTTP 200 + { + "size": 2, + "parked": 2, + "items": [ + { + "dlq_id": "D-1", + "attempts": 3, + "parked": true, + "item": { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + "reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251128.6803358 + }, + { + "dlq_id": "D-2", + "attempts": 3, + "parked": true, + "item": { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + }, + "reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"", + "workflow": "intake", + "at": 1793251128.9017823 + } + ] + } +✔ 멈춘 것은 알림 · 0.0s + → request POST http://localhost:8800/alerts/latest + {} + ← response HTTP 200 + { + "workflow": "replay", + "node": "Park it", + "severity": "high", + "reason": "parked", + "dlq_id": "D-2", + "message": "D-2 (R-302) parked after 3 attempts", + "at": 1793251129.0694785 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 0, + "ok": 0, + "still_failed": 0, + "parked": 0 + } +Passed 7/7 +report: runs/lab03-challenge-20261029-141849.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ API가 멈춘 동안 2건 → DLQ · 3.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 고친 뒤 다시 돌리기 · 1.4s +✔ DLQ가 비었음 · 0.0s +✔ 접수 건은 2개 (중복 없음) · 0.0s +✔ 한 번 더 돌려도 할 일 없음 · 0.0s +Passed 5/5 +report: runs/lab03-20261029-141853.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +fault switch: all ok +created tickets: 2 + T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요 + T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요 +DLQ: 0 (parked 0) +alerts: 1 + [high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ +``` + +**쉽게 말하면** + +- 재처리 3에서 `replayed: 0, parked: 2` (0.1초, 다시 보내지 않음), DLQ에 parked 2건이 남고 `[high] parked` 알림. +- 고친 뒤에도 parked는 자동으로 돌리지 않음 (`replayed: 0`) — 사람이 보고 정합니다. `lab03` 5/5. 예상한 결과. + +### 23 · Lab 03 과제 2 하기 전 → `lab03-challenge2` 3/7 + +실행한 명령: + +```bash +uv run tools/flow.py test cases/lab03-challenge2.json -v +``` + +출력 (그대로): + +```text +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-ops-status · 7 case(s) × 1 +✘ 1 · 아무 일 없음 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0) + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · (접수 실패 2건) · 2.9s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✘ 3 · DLQ가 있으면 warn · 0.0s · status 404 (expected 200); status = null (expected warn); dlq = null (expected 2); reasons has none of ['재처리'] + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✔ 4 · (보고서 버그) · 0.0s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✘ 5 · critical 알림이 있으면 critical · 0.0s · status 404 (expected 200); status = null (expected critical); alerts.critical = null (expected 1) + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.1s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ after: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✘ 7 · 고친 뒤 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0); created = null (expected 2) + → request POST /webhook/my-ops-status + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-ops-status\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +Passed 3/7 +report: runs/lab03-challenge2-20261029-141901.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +``` + +**쉽게 말하면** + +- `my-ops-status`가 없어 상태 케이스 넷이 404. 상태를 바꾸는 케이스(접수 실패 · 보고서 버그 · 재처리) 셋만 ✔. 예상한 결과. + +### 24 · Lab 03 과제 2 · 운영 상태 한눈에 → 7/7 + +codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/ops-status.json` · 이 기록에서 push한 같은 역할의 정답 `answers/76-lab03-challenge2-ops-status.json`): + +```text +my-work/ops-status.json 을 새로 만들어 줘. 웹훅 POST my-ops-status . +Get DLQ(GET /dlq) → Recent alerts(POST /alerts/list {within: 600}) → Get created(GET /tickets-created) → Decide status(Code). +parked 가 1 이상이거나 10분 안 critical 알림이 있으면 "critical", parked 가 아닌 DLQ 가 있거나 high 알림이 있으면 " warn ", 아니면 "ok". +결과 {status, dlq(parked 가 아닌 수), parked, created, alerts: {critical, high}, reasons: [한국어로 까닭]}. +읽기만 한다: 이 워크플로우에서 DLQ · 알림을 바꾸지 않는다 . 기대 결과 cases/lab03-challenge2.json. +``` + +실행한 명령: + +```bash +uv run tools/flow.py push answers/76-lab03-challenge2-ops-status.json +uv run tools/flow.py test cases/lab03-challenge2.json -v +uv run tools/flow.py test cases/lab03-challenge.json +uv run tools/flow.py test cases/lab02-challenge2.json +uv run tools/flow.py call my-ops-status +uv run tools/flow.py ops --reset +uv run tools/flow.py doctor +``` + +출력 (그대로): + +```text +✔ pushed '76 Ops status (answer)' (id w07b5812eaa88ef9) + POST http://localhost:5678/webhook/my-ops-status + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/tickets-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/dlq-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +POST http://localhost:5678/webhook/my-ops-status · 7 case(s) × 1 +✔ 1 · 아무 일 없음 → ok · 0.9s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "ok", + "dlq": 0, + "parked": 0, + "created": 0, + "alerts": { + "critical": 0, + "high": 0 + }, + "reasons": [] + } +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "tickets": "error" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "error", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 2 · (접수 실패 2건) · 3.7s + → request POST /webhook/my-intake + { + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] + } + ← response HTTP 200 + { + "total": 2, + "created": 0, + "failed": 2, + "rejected": 0, + "duplicates": 0, + "unclassified": 0, + "results": [ + { + "id": "R-301", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + }, + { + "id": "R-302", + "status": "failed", + "reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"" + } + ], + "dlq_saved": true, + "alert": "sent" + } +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } +✔ 3 · DLQ가 있으면 warn · 0.1s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "warn", + "dlq": 2, + "parked": 0, + "created": 0, + "alerts": { + "critical": 0, + "high": 1 + }, + "reasons": [ + "DLQ 2건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] + } +✔ 4 · (보고서 버그) · 0.1s + → request POST /webhook/nightly-report + {} + ← response HTTP 500 + { + "message": "Error in workflow" + } +… before: wait 3s +✔ 5 · critical 알림이 있으면 critical · 0.1s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "critical", + "dlq": 2, + "parked": 0, + "created": 0, + "alerts": { + "critical": 1, + "high": 1 + }, + "reasons": [ + "critical 알림 1건 (10분)", + "DLQ 2건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] + } +✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.2s + → request POST /webhook/my-replay + {} + ← response HTTP 200 + { + "replayed": 2, + "ok": 2, + "still_failed": 0, + "parked": 0, + "duplicates": 0 + } +✔ after: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ 7 · 고친 뒤 → ok · 0.0s + → request POST /webhook/my-ops-status + {} + ← response HTTP 200 + { + "status": "ok", + "dlq": 0, + "parked": 0, + "created": 2, + "alerts": { + "critical": 0, + "high": 0 + }, + "reasons": [] + } +Passed 7/7 +report: runs/lab03-challenge2-20261029-141921.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + → request POST http://localhost:8800/admin/fault + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + ← response HTTP 200 + { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok", + "slow_seconds": 8 + } + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ API가 오래 멈춤 · 처음 실패 · 2.8s +✔ 재처리 1 · 또 실패 · 1.9s +✔ 재처리 2 · 또 실패 · 2.5s +✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s +✔ parked로 남음 · 0.0s +✔ 멈춘 것은 알림 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s +Passed 7/7 +report: runs/lab03-challenge-20261029-141929.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1 +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 첫 실패 → 알림 보냄 · 2.9s +✔ 10분 안에 또 실패 → 보내지 않음 · 3.4s +✔ dlq 알림은 1개 · 0.0s +✔ 그래도 DLQ에는 5건 모두 · 0.0s +✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8} +✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.8s +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +✔ 고친 뒤 · 실패 없음 · 알림 없음 · 3.4s +Passed 6/6 +report: runs/lab02-challenge2-20261029-141941.json +✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} + +HTTP 200 · 0.1s +{ + "status": "critical", + "dlq": 5, + "parked": 0, + "created": 2, + "alerts": { + "critical": 1, + "high": 1 + }, + "reasons": [ + "critical 알림 1건 (10분)", + "DLQ 5건: 원인을 고친 뒤 재처리", + "high 알림 1건 (10분)" + ] +} + +✔ created tickets, DLQ and alerts emptied, fault switch all ok + +! n8n install folder /Users/mcchae/day65-n8n + → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더> +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready +``` + +**쉽게 말하면** + +- 아무 일 없음 `ok` → 접수 실패 2건 뒤 `warn`(DLQ 2, "원인을 고친 뒤 재처리") → 보고서 버그 뒤 `critical`(critical 알림 1) → 재처리 · 알림 확인 뒤 `ok`. +- `lab03-challenge` 7/7 · `lab02-challenge2` 6/6. 마지막 `call my-ops-status`는 `critical`: 바로 앞 테스트의 DLQ 5건과 `dlq_down` critical 알림이 남아 있어서입니다 (`ops --reset`으로 비움). 예상한 결과. + +## 다시 돌려 보기 + +강사 노트북에서 같은 순서를 다시 돌리려면 (강사 저장소 `~/work/AIO-01D4/Last3Mon/76`, 정답 파일이 있는 곳): + +```bash +cd ~/work/AIO-01D4/Last3Mon/76 +uv run tools/flow.py ops --reset +uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json reference/lab02-example2-error-handler.json +uv run tools/flow.py push answers/76-lab02-challenge2-throttle.json && uv run tools/flow.py test cases/lab02-challenge2.json -v +uv run tools/flow.py test cases/lab01-faults.json -v && uv run tools/flow.py test cases/lab02-nets.json -v +uv run tools/flow.py push answers/76-lab03-challenge-park.json && uv run tools/flow.py test cases/lab03-challenge.json -v +uv run tools/flow.py push reference/lab03-example1-replay.json && uv run tools/flow.py test cases/lab03-half.json -v +uv run tools/flow.py push answers/76-lab03-challenge2-ops-status.json && uv run tools/flow.py test cases/lab03-challenge2.json -v +uv run tools/flow.py ops --reset && uv run tools/flow.py doctor +``` + +- 결과의 ✔ · ✘(판정)는 같게 나와야 합니다. LLM 분류 · 시간 · 접수 건 번호의 순서는 다를 수 있습니다. `slow` 케이스는 약 12초씩 걸립니다. diff --git a/Last3Mon/76/cases/lab01-challenge.json b/Last3Mon/76/cases/lab01-challenge.json new file mode 100644 index 0000000..bbe6663 --- /dev/null +++ b/Last3Mon/76/cases/lab01-challenge.json @@ -0,0 +1,87 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "llm": "up" + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "평소에는 분류됨", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "created": 2, + "unclassified": 0 + } + } + }, + { + "name": "LLM이 멈추면 미분류로 접수", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "created": 3, + "unclassified": 3, + "failed": 0 + } + }, + "before": [ + { + "llm": "down" + } + ], + "after": [ + { + "llm": "up" + } + ] + }, + { + "name": "미분류도 접수 건으로", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "contains": { + "tickets": [ + "미분류" + ] + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab01-challenge2.json b/Last3Mon/76/cases/lab01-challenge2.json new file mode 100644 index 0000000..cd1fe22 --- /dev/null +++ b/Last3Mon/76/cases/lab01-challenge2.json @@ -0,0 +1,89 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "같은 id가 두 번이면 두 번째는 거절", + "request": "requests/batch-dup-id.json", + "expect": { + "equals": { + "created": 2, + "rejected": 1, + "duplicates": 0 + }, + "contains": { + "results": [ + "duplicate id" + ] + } + } + }, + { + "name": "11건은 400 (한 번에 10건까지)", + "request": "requests/batch-eleven.json", + "expect": { + "status": 400, + "contains": { + "error": [ + "too many" + ] + } + } + }, + { + "name": "400이면 하나도 만들지 않음", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "equals": { + "count": 2 + } + } + }, + { + "name": "평소 요청은 그대로", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "created": 3, + "rejected": 0 + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab01-faults.json b/Last3Mon/76/cases/lab01-faults.json new file mode 100644 index 0000000..ddb0e80 --- /dev/null +++ b/Last3Mon/76/cases/lab01-faults.json @@ -0,0 +1,129 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "flaky · 다시 해 보면 됨", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "created": 2, + "failed": 0 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "flaky" + } + } + ] + }, + { + "name": "error · 세 번 해도 실패", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "created": 0, + "failed": 3 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ] + }, + { + "name": "slow · 3초씩 세 번 기다리다 실패", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "failed": 3 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "slow" + } + } + ] + }, + { + "name": "실패라던 3건이 늦게 만들어짐 (2 + 3)", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "equals": { + "count": 5 + } + }, + "before": [ + { + "sleep": 8 + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "같은 요청을 다시 · 멱등 키로 중복 없음", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "created": 3, + "duplicates": 3 + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab01.json b/Last3Mon/76/cases/lab01.json new file mode 100644 index 0000000..1a95334 --- /dev/null +++ b/Last3Mon/76/cases/lab01.json @@ -0,0 +1,125 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "정상 3건", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "created": 3, + "failed": 0, + "rejected": 0 + } + } + }, + { + "name": "같은 요청을 다시 보내도 중복 없음", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "created": 3, + "duplicates": 3 + } + } + }, + { + "name": "만들어진 접수 건은 3개", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "equals": { + "count": 3 + } + } + }, + { + "name": "잘못된 항목은 그 항목만 거절", + "request": "requests/batch-bad-item.json", + "expect": { + "equals": { + "created": 2, + "rejected": 1, + "failed": 0 + }, + "contains": { + "results": [ + "text is required" + ] + } + } + }, + { + "name": "목록이 아니면 400", + "request": "requests/not-a-list.json", + "expect": { + "status": 400 + } + }, + { + "name": "가끔 실패하는 API · 다시 해 보기로 성공", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "created": 2, + "failed": 0 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "flaky" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + } + ] +} diff --git a/Last3Mon/76/cases/lab02-challenge.json b/Last3Mon/76/cases/lab02-challenge.json new file mode 100644 index 0000000..949ad37 --- /dev/null +++ b/Last3Mon/76/cases/lab02-challenge.json @@ -0,0 +1,153 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "API가 멈춤 → DLQ", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2, + "dlq_saved": true, + "alert": "sent" + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "처리한 실패도 알림 (high · 개수)", + "url": "http://localhost:8800/alerts/latest", + "body": {}, + "expect": { + "equals": { + "severity": "high", + "reason": "dlq", + "count": 2, + "workflow": "intake" + } + } + }, + { + "name": "실패가 없으면 알림 없음", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "failed": 0, + "alert": "none" + } + } + }, + { + "name": "알림은 그대로 1개", + "url": "http://localhost:8800/alerts/list", + "body": {}, + "expect": { + "equals": { + "count": 1 + } + } + }, + { + "name": "DLQ도 멈추면 critical", + "request": "requests/batch-bad-item.json", + "expect": { + "equals": { + "failed": 2, + "dlq_saved": false + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error", + "dlq": "error" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "잃어버린 id를 알림에", + "url": "http://localhost:8800/alerts/latest", + "body": {}, + "expect": { + "equals": { + "severity": "critical", + "reason": "dlq_down", + "count": 2 + }, + "contains": { + "message": [ + "R-201" + ] + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab02-challenge2.json b/Last3Mon/76/cases/lab02-challenge2.json new file mode 100644 index 0000000..583a3e5 --- /dev/null +++ b/Last3Mon/76/cases/lab02-challenge2.json @@ -0,0 +1,132 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "첫 실패 → 알림 보냄", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2, + "alert": "sent" + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ] + }, + { + "name": "10분 안에 또 실패 → 보내지 않음", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "failed": 3, + "alert": "suppressed" + } + } + }, + { + "name": "dlq 알림은 1개", + "url": "http://localhost:8800/alerts/list", + "body": { + "reason": "dlq" + }, + "expect": { + "equals": { + "count": 1 + } + } + }, + { + "name": "그래도 DLQ에는 5건 모두", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 5 + } + } + }, + { + "name": "DLQ가 멈춘 것은 늘 보냄 (critical)", + "request": "requests/batch-bad-item.json", + "expect": { + "equals": { + "dlq_saved": false, + "alert": "sent" + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error", + "dlq": "error" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "고친 뒤 · 실패 없음 · 알림 없음", + "request": "requests/batch-dup-id.json", + "expect": { + "equals": { + "failed": 0, + "alert": "none" + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab02-nets.json b/Last3Mon/76/cases/lab02-nets.json new file mode 100644 index 0000000..6b5a3d3 --- /dev/null +++ b/Last3Mon/76/cases/lab02-nets.json @@ -0,0 +1,132 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "1 · 잘못된 항목 → 결과에만 (rejected)", + "request": "requests/batch-bad-item.json", + "expect": { + "equals": { + "rejected": 1, + "created": 2 + } + } + }, + { + "name": "2 · API 오류 → failed", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ] + }, + { + "name": "3 · failed는 DLQ에", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 2 + } + } + }, + { + "name": "4 · 버그 → 500", + "webhook": "nightly-report", + "body": {}, + "expect": { + "status": 500 + } + }, + { + "name": "5 · Error Workflow → critical 알림", + "url": "http://localhost:8800/alerts/latest", + "body": {}, + "expect": { + "equals": { + "severity": "critical", + "reason": "unexpected" + } + }, + "before": [ + { + "sleep": 3 + } + ] + }, + { + "name": "6 · DLQ마저 멈추면 결과가 알려야", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "failed": 3, + "dlq_saved": false + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error", + "dlq": "error" + } + } + ] + }, + { + "name": "7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down)", + "url": "http://localhost:8800/alerts/latest", + "body": {}, + "expect": { + "equals": { + "severity": "critical", + "reason": "dlq_down" + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab02.json b/Last3Mon/76/cases/lab02.json new file mode 100644 index 0000000..d4361e9 --- /dev/null +++ b/Last3Mon/76/cases/lab02.json @@ -0,0 +1,118 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "예제 1 · API가 멈추면 실패로 표시 · 200", + "request": "requests/batch-two.json", + "expect": { + "status": 200, + "equals": { + "created": 0, + "failed": 2 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "예제 1 · 실패한 2건은 DLQ에", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 2 + }, + "contains": { + "items": [ + "R-301", + "R-302" + ] + } + } + }, + { + "name": "예제 2 · 예상 못 한 오류 (nightly-report)", + "webhook": "nightly-report", + "body": {}, + "expect": { + "status": 500 + } + }, + { + "name": "예제 2 · Error Workflow가 알림을 남김", + "url": "http://localhost:8800/alerts/latest", + "body": {}, + "expect": { + "equals": { + "severity": "critical", + "reason": "unexpected", + "workflow": "76 Nightly report (provided, has a bug)" + }, + "contains": { + "node": [ + "Count by category" + ] + } + }, + "before": [ + { + "sleep": 3 + } + ] + } + ] +} diff --git a/Last3Mon/76/cases/lab03-challenge.json b/Last3Mon/76/cases/lab03-challenge.json new file mode 100644 index 0000000..747beb7 --- /dev/null +++ b/Last3Mon/76/cases/lab03-challenge.json @@ -0,0 +1,137 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "API가 오래 멈춤 · 처음 실패", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ] + }, + { + "name": "재처리 1 · 또 실패", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "replayed": 2, + "still_failed": 2, + "parked": 0 + } + } + }, + { + "name": "재처리 2 · 또 실패", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "replayed": 2, + "still_failed": 2, + "parked": 0 + } + } + }, + { + "name": "재처리 3 · 세 번 실패한 것은 멈춤 (parked)", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "replayed": 0, + "parked": 2 + } + } + }, + { + "name": "parked로 남음", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 2, + "parked": 2 + } + } + }, + { + "name": "멈춘 것은 알림", + "url": "http://localhost:8800/alerts/latest", + "body": {}, + "expect": { + "equals": { + "reason": "parked", + "severity": "high" + } + } + }, + { + "name": "고친 뒤에도 parked는 자동으로 돌리지 않음", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "replayed": 0 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + } + ] +} diff --git a/Last3Mon/76/cases/lab03-challenge2.json b/Last3Mon/76/cases/lab03-challenge2.json new file mode 100644 index 0000000..4cb9b7c --- /dev/null +++ b/Last3Mon/76/cases/lab03-challenge2.json @@ -0,0 +1,146 @@ +{ + "webhook": "my-ops-status", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "1 · 아무 일 없음 → ok", + "body": {}, + "expect": { + "equals": { + "status": "ok", + "dlq": 0 + } + } + }, + { + "name": "2 · (접수 실패 2건)", + "webhook": "my-intake", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "3 · DLQ가 있으면 warn", + "body": {}, + "expect": { + "equals": { + "status": "warn", + "dlq": 2 + }, + "contains": { + "reasons": [ + "재처리" + ] + } + } + }, + { + "name": "4 · (보고서 버그)", + "webhook": "nightly-report", + "body": {}, + "expect": { + "status": 500 + } + }, + { + "name": "5 · critical 알림이 있으면 critical", + "body": {}, + "expect": { + "equals": { + "status": "critical", + "alerts.critical": 1 + } + }, + "before": [ + { + "sleep": 3 + } + ] + }, + { + "name": "6 · (재처리 · 알림 확인 뒤 비움)", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "ok": 2 + } + }, + "after": [ + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + } + ] + }, + { + "name": "7 · 고친 뒤 → ok", + "body": {}, + "expect": { + "equals": { + "status": "ok", + "dlq": 0, + "created": 2 + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab03-drill.json b/Last3Mon/76/cases/lab03-drill.json new file mode 100644 index 0000000..e593a9a --- /dev/null +++ b/Last3Mon/76/cases/lab03-drill.json @@ -0,0 +1,133 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "1 · 평소", + "request": "requests/batch-ok.json", + "expect": { + "equals": { + "created": 3 + } + } + }, + { + "name": "2 · 장애 시작 · API 오류", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2, + "created": 0 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ] + }, + { + "name": "3 · DLQ 확인", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 2 + } + } + }, + { + "name": "4 · 보고서 작업도 실패", + "webhook": "nightly-report", + "body": {}, + "expect": { + "status": 500 + } + }, + { + "name": "5 · 알림 확인", + "url": "http://localhost:8800/alerts/latest", + "body": {}, + "expect": { + "equals": { + "severity": "critical" + } + }, + "before": [ + { + "sleep": 3 + } + ] + }, + { + "name": "6 · 고친 뒤 재처리", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "ok": 2 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "7 · 결과 확인 · 중복 없이 5건", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "equals": { + "count": 5 + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab03-half.json b/Last3Mon/76/cases/lab03-half.json new file mode 100644 index 0000000..60991ad --- /dev/null +++ b/Last3Mon/76/cases/lab03-half.json @@ -0,0 +1,125 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "1 · API가 느림 → 기다리다 실패", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "slow" + } + } + ] + }, + { + "name": "2 · DLQ에 2건", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 2 + } + } + }, + { + "name": "3 · 그런데 접수 건은 이미 2개", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "equals": { + "count": 2 + } + }, + "before": [ + { + "sleep": 8 + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "4 · 재처리 · 이미 있는 건이라 duplicate", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "replayed": 2, + "ok": 2, + "duplicates": 2 + } + } + }, + { + "name": "5 · 그래도 2개 (중복 없음)", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "equals": { + "count": 2 + } + } + }, + { + "name": "6 · DLQ가 비었음", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 0 + } + } + } + ] +} diff --git a/Last3Mon/76/cases/lab03.json b/Last3Mon/76/cases/lab03.json new file mode 100644 index 0000000..c88362b --- /dev/null +++ b/Last3Mon/76/cases/lab03.json @@ -0,0 +1,112 @@ +{ + "webhook": "my-intake", + "before": [ + { + "url": "http://localhost:8800/tickets-reset", + "body": {} + }, + { + "url": "http://localhost:8800/dlq-clear", + "body": {} + }, + { + "url": "http://localhost:8800/alerts-clear", + "body": {} + }, + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "after": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ], + "cases": [ + { + "name": "API가 멈춘 동안 2건 → DLQ", + "request": "requests/batch-two.json", + "expect": { + "equals": { + "failed": 2 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "tickets": "error" + } + } + ] + }, + { + "name": "고친 뒤 다시 돌리기", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "replayed": 2, + "ok": 2, + "still_failed": 0 + } + }, + "before": [ + { + "url": "http://localhost:8800/admin/fault", + "body": { + "faq": "ok", + "tickets": "ok", + "prompts": "ok", + "usage": "ok", + "dlq": "ok" + } + } + ] + }, + { + "name": "DLQ가 비었음", + "url": "http://localhost:8800/dlq/list", + "body": {}, + "expect": { + "equals": { + "size": 0 + } + } + }, + { + "name": "접수 건은 2개 (중복 없음)", + "url": "http://localhost:8800/tickets-created/list", + "body": {}, + "expect": { + "equals": { + "count": 2 + } + } + }, + { + "name": "한 번 더 돌려도 할 일 없음", + "webhook": "my-replay", + "body": {}, + "expect": { + "equals": { + "replayed": 0 + } + } + } + ] +} diff --git a/Last3Mon/76/data-api/compose.yml b/Last3Mon/76/data-api/compose.yml new file mode 100644 index 0000000..d46ed9b --- /dev/null +++ b/Last3Mon/76/data-api/compose.yml @@ -0,0 +1,27 @@ +# Sample company data API for day 76 (day 75 API + ticket creation + DLQ + alerts), attached to the day 65 n8n network +# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default). +# docker compose up -d (run from this folder, after day 65 n8n is running) +# from n8n : http://data-api:8800 +# from WSL : http://localhost:8800 +name: day67-data + +services: + data-api: + image: python:3.12-slim + command: python /app/server.py + environment: + - DATA_DIR=/data + - PYTHONUNBUFFERED=1 + ports: + - "8800:8800" + volumes: + - ./server.py:/app/server.py:ro + - ./data:/data:ro + networks: + - n8n + restart: unless-stopped + +networks: + n8n: + external: true + name: day65-n8n_default diff --git a/Last3Mon/76/data-api/data/budget.json b/Last3Mon/76/data-api/data/budget.json new file mode 100644 index 0000000..6c9b313 --- /dev/null +++ b/Last3Mon/76/data-api/data/budget.json @@ -0,0 +1 @@ +{"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500} diff --git a/Last3Mon/76/data-api/data/docs/device-issue-return.md b/Last3Mon/76/data-api/data/docs/device-issue-return.md new file mode 100644 index 0000000..31f66bf --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/device-issue-return.md @@ -0,0 +1,17 @@ +# 업무용 장비 지급·반납 규정 + +## 1. 지급과 교체 +- 업무용 노트북은 입사일에 지급한다. +- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다. + +## 2. 분실과 도난 +- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다. +- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다. + +## 3. 반납 +- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. +- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다. + +## 4. 개인 장비 사용 +- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다. +- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다. diff --git a/Last3Mon/76/data-api/data/docs/email-and-documents.md b/Last3Mon/76/data-api/data/docs/email-and-documents.md new file mode 100644 index 0000000..ca38d0d --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/email-and-documents.md @@ -0,0 +1,14 @@ +# 메일·문서 보안 규정 + +## 1. 메일함 +- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다. +- 오래된 메일은 보관함으로 옮겨 용량을 확보한다. + +## 2. 첨부 파일 +- 메일 첨부 파일은 25MB까지 보낼 수 있다. +- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다. +- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다. + +## 3. 문서 등급과 보관 +- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다. +- 업무 문서는 3년 동안 보관한 뒤 정리한다. diff --git a/Last3Mon/76/data-api/data/docs/meeting-room.md b/Last3Mon/76/data-api/data/docs/meeting-room.md new file mode 100644 index 0000000..4aed1c6 --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/meeting-room.md @@ -0,0 +1,12 @@ +# 회의실 이용 규정 + +## 1. 예약 +- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다. +- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다. + +## 2. 장비 +- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다. +- 프로젝터는 B회의실과 대회의실에 있다. + +## 3. 사용 후 +- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다. diff --git a/Last3Mon/76/data-api/data/docs/password-policy.md b/Last3Mon/76/data-api/data/docs/password-policy.md new file mode 100644 index 0000000..c069749 --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/password-policy.md @@ -0,0 +1,18 @@ +# 비밀번호·계정 보안 규정 + +## 1. 비밀번호 만들기 +- 비밀번호는 12자 이상으로 만든다. +- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다. +- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다. + +## 2. 변경 주기 +- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다. +- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다. + +## 3. 로그인 실패와 잠금 +- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다. +- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다. + +## 4. 다단계 인증 +- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다. +- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다. diff --git a/Last3Mon/76/data-api/data/docs/privacy.md b/Last3Mon/76/data-api/data/docs/privacy.md new file mode 100644 index 0000000..a5ba949 --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/privacy.md @@ -0,0 +1,13 @@ +# 개인정보 처리 지침 + +## 1. 수집 +- 업무에 꼭 필요한 개인정보만 수집한다. +- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다. + +## 2. 이용과 보관 +- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다. +- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다. + +## 3. 유출 대응 +- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다. +- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다. diff --git a/Last3Mon/76/data-api/data/docs/remote-work-vpn.md b/Last3Mon/76/data-api/data/docs/remote-work-vpn.md new file mode 100644 index 0000000..ae0c917 --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/remote-work-vpn.md @@ -0,0 +1,15 @@ +# 원격근무·VPN 이용 규정 + +## 1. 접속 원칙 +- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다. +- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다. + +## 2. VPN 인증서 +- VPN 인증서의 유효 기간은 1년이다. +- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다. +- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다. + +## 3. 재택근무 중 보안 +- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다. +- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다. +- VPN 접속 기록은 1년 동안 보관된다. diff --git a/Last3Mon/76/data-api/data/docs/security-incident.md b/Last3Mon/76/data-api/data/docs/security-incident.md new file mode 100644 index 0000000..a6b514e --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/security-incident.md @@ -0,0 +1,11 @@ +# 보안 사고 대응 절차 + +## 1. 피싱 메일 +- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다. +- 메일 화면의 '피싱 신고' 버튼으로 신고한다. +- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다. + +## 2. 악성코드 감염 의심 +- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다. +- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다. +- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다. diff --git a/Last3Mon/76/data-api/data/docs/software-usage.md b/Last3Mon/76/data-api/data/docs/software-usage.md new file mode 100644 index 0000000..edabe96 --- /dev/null +++ b/Last3Mon/76/data-api/data/docs/software-usage.md @@ -0,0 +1,14 @@ +# 소프트웨어 사용 규정 + +## 1. 설치와 승인 +- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다. +- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다. +- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다. + +## 2. 금지 사항 +- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다. + +## 3. 생성형 AI 서비스 +- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다. +- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다. +- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다. diff --git a/Last3Mon/76/data-api/data/employees.json b/Last3Mon/76/data-api/data/employees.json new file mode 100644 index 0000000..7b4a980 --- /dev/null +++ b/Last3Mon/76/data-api/data/employees.json @@ -0,0 +1,26 @@ +[ + { + "name": "이준호", + "dept": "영업팀" + }, + { + "name": "박서연", + "dept": "재무팀" + }, + { + "name": "최유진", + "dept": "인사팀" + }, + { + "name": "한지훈", + "dept": "개발팀" + }, + { + "name": "김민지", + "dept": "IT팀" + }, + { + "name": "정우성", + "dept": "IT팀" + } +] diff --git a/Last3Mon/76/data-api/data/faq.json b/Last3Mon/76/data-api/data/faq.json new file mode 100644 index 0000000..10eb7f1 --- /dev/null +++ b/Last3Mon/76/data-api/data/faq.json @@ -0,0 +1,14 @@ +[ + {"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."}, + {"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."}, + {"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."}, + {"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."}, + {"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."}, + {"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."} +] diff --git a/Last3Mon/76/data-api/data/prices.json b/Last3Mon/76/data-api/data/prices.json new file mode 100644 index 0000000..9068a2c --- /dev/null +++ b/Last3Mon/76/data-api/data/prices.json @@ -0,0 +1,9 @@ +{ + "currency": "USD", + "unit": "per 1M tokens", + "note": "OpenAI 모델 문서의 공개 단가 (2026-10-28 확인). 수업 전에 가격표를 확인해 바꾸세요.", + "models": { + "gpt-4.1-mini": {"input": 0.40, "output": 1.60}, + "gpt-5.4-nano": {"input": 0.20, "output": 1.25} + } +} diff --git a/Last3Mon/76/data-api/data/prompts/helpdesk-p1.md b/Last3Mon/76/data-api/data/prompts/helpdesk-p1.md new file mode 100644 index 0000000..72a2224 --- /dev/null +++ b/Last3Mon/76/data-api/data/prompts/helpdesk-p1.md @@ -0,0 +1 @@ +You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체. diff --git a/Last3Mon/76/data-api/data/prompts/helpdesk-p2.md b/Last3Mon/76/data-api/data/prompts/helpdesk-p2.md new file mode 100644 index 0000000..a76b826 --- /dev/null +++ b/Last3Mon/76/data-api/data/prompts/helpdesk-p2.md @@ -0,0 +1,2 @@ +You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체. +When the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003]. diff --git a/Last3Mon/76/data-api/data/prompts/helpdesk-p3.md b/Last3Mon/76/data-api/data/prompts/helpdesk-p3.md new file mode 100644 index 0000000..25cbf94 --- /dev/null +++ b/Last3Mon/76/data-api/data/prompts/helpdesk-p3.md @@ -0,0 +1 @@ +You are the company IT help desk. Keep every answer to one short Korean sentence. Do not repeat the steps from the data; tell the employee to check the IT portal instead. diff --git a/Last3Mon/76/data-api/data/releases.json b/Last3Mon/76/data-api/data/releases.json new file mode 100644 index 0000000..be2b51b --- /dev/null +++ b/Last3Mon/76/data-api/data/releases.json @@ -0,0 +1,12 @@ +{ + "helpdesk": { + "active": "1.0.0", + "releases": { + "1.0.0": {"prompt_version": "p1", "model": "gpt-4.1-mini", "note": "첫 릴리스"}, + "1.1.0": {"prompt_version": "p2", "model": "gpt-4.1-mini", "note": "FAQ 답 끝에 출처 번호"}, + "1.2.0": {"prompt_version": "p3", "model": "gpt-4.1-mini", "note": "답을 한 문장으로 짧게"}, + "2.0.0": {"prompt_version": "p2", "model": "gpt-5.4-nano", "note": "더 작은 모델 (비용)"}, + "2.1.0": {"prompt_version": "p9", "model": "gpt-4.1-mini", "note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"} + } + } +} diff --git a/Last3Mon/76/data-api/data/tickets.json b/Last3Mon/76/data-api/data/tickets.json new file mode 100644 index 0000000..9d9d3ea --- /dev/null +++ b/Last3Mon/76/data-api/data/tickets.json @@ -0,0 +1,6 @@ +{ + "T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다.", "contact": {"phone": "010-3141-5926", "email": "junho.lee@example.com"}}, + "T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.", "contact": {"phone": "010-2718-2818", "email": "seoyeon.park@example.com"}}, + "T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다.", "contact": {"phone": "010-1414-2135", "email": "yujin.choi@example.com"}}, + "T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요.", "contact": {"phone": "010-1732-0508", "email": "jihoon.han@example.com"}} +} diff --git a/Last3Mon/76/data-api/server.py b/Last3Mon/76/data-api/server.py new file mode 100644 index 0000000..d973ea3 --- /dev/null +++ b/Last3Mon/76/data-api/server.py @@ -0,0 +1,536 @@ +"""Sample company data API for the day 76 labs (day 75 API + ticket creation + dead letter queue + alerts; +Python standard library only). + +GET /health -> {"status": "ok"} (never affected by the fault switch) +GET /faq?q= -> {"query": ..., "results": [FAQ entries]} +GET /tickets/ -> ticket record, or 404 {"error": "ticket not found"} +GET /tickets/?requester= + -> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else +GET /employees -> {"employees": [{"name", "dept"}]} +GET /employees/ -> {"name", "dept"}, or 404 {"error": "unknown employee"} +GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown) +GET /docs/ -> {"name", "title", "text"}, or 404 {"error": "doc not found"} + +Day 72 additions (state lives in memory; restarting the container resets it): +GET /admin/fault -> {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +POST /admin/fault body {"faq": "ok"|"slow"|"error"|"flaky", "tickets": ..., "prompts": ..., "usage": ..., "dlq": ..., "slow_seconds": 8} + "slow" waits slow_seconds before answering, "error" answers HTTP 500, + "flaky" fails every second call; for POST /tickets the first try of each key fails + and the next one works (a short hiccup). +GET /cache/ -> {"key", "value", "hits"} or 404 {"error": "not cached"} +POST /cache/ body {"value": , "ttl": 600} (PUT works too) +POST /cache-clear -> {"cleared": } +GET /cache-stats -> {"entries", "hits", "misses"} + +Day 73 addition (in memory, newest 1000 kept): +POST /audit body {"route", "blocked_by", "reason", "masked", ...} -> {"stored": true, "count"} +GET /audit -> {"events": [{"t", ...the body}]} +POST /audit-clear -> {"cleared": } + +Day 74 additions (prompt registry from data/prompts, releases from data/releases.json, state in memory): +GET /prompts/ -> {"name", "versions": ["p1", ...]} +GET /prompts// -> {"name", "version", "text"}, or 404 {"error": "prompt not found"} (fault switch "prompts") +GET /releases/ -> {"name", "active", "canary": null | {"release", "percent"}, "require_gate": false, "releases": {...}} +POST /releases/ body {"active": "1.1.0"} and/or {"canary": {"release": "1.2.0", "percent": 50}} or {"canary": null} + When require_gate is on, a release can become active or canary only if its latest gate + record says "promote"; otherwise 409 {"error": "not gated", "release", "last_gate"}. +POST /releases//policy body {"require_gate": true|false} +POST /releases//reset -> back to data/releases.json (active, no canary, require_gate off; gate records stay) +POST /gates/ body {"release", "verdict": "promote"|"rollback", "passed", "total", "source", ...} + -> {"stored": true, "count"} +GET /gates/ -> {"name", "gates": [{"t", ...the body}]} (newest last, 200 kept) +POST /gates//clear -> {"cleared": } + +Day 75 additions (usage and budget in memory): +GET /prices -> {"currency", "unit", "models": {"gpt-4.1-mini": {"input", "output"}, ...}} (USD per 1M tokens) +POST /usage body {"items": [{"request_id", "user", "step", "model", "prompt_tokens", + "completion_tokens", "cost_usd", "route"}]} (one item per LLM call, or one item + without "items") -> {"stored": n, "total"} (fault switch "usage": error -> 500, + slow -> waits slow_seconds, then 503 and nothing is stored) +GET /usage/summary -> {"requests" (distinct request_id), "calls", "prompt_tokens", "completion_tokens", + "cost_usd", "by_model", "by_user", "by_route"} (POST works too) +GET /usage/user/?window=60 -> {"user", "window", "count"} distinct requests of that user in the last N seconds +POST /usage-clear -> {"cleared": n} +GET /budget -> {"daily_usd", "used_usd", "remaining_usd", "over", "user_limit_per_min", "max_question_chars"} +POST /budget body {"daily_usd": 0.5} and/or {"user_limit_per_min": 3} and/or {"max_question_chars": 500} +POST /budget/reset -> back to data/budget.json + +Day 76 additions (ticket creation, dead letter queue and alerts in memory): +POST /tickets body {"key", "title", "requester", "category", "priority"} -> 201 {"ticket_id", "duplicate": false} + same "key" again -> 200 {"ticket_id" (the first one), "duplicate": true} (idempotency) + fault switch "tickets": slow waits slow_seconds and then still creates the ticket + (a caller that gave up does not know it was made), error -> 500, flaky -> see above +GET /tickets-created -> {"count", "tickets": [...]} (POST /tickets-created/list too); POST /tickets-reset +POST /dlq body {"item", "reason", "workflow"} or {"items": [...]} -> {"stored", "size"} + the same item.id again does not add a row: its "attempts" goes up by 1 (fault switch "dlq") +GET /dlq -> {"size", "parked", "items": [{"dlq_id", "item", "reason", "attempts", "parked", ...}]} (POST /dlq/list too) +POST /dlq/update body {"dlq_id", "remove": true} or {"dlq_id", "attempts": n, "parked": true, "reason"} +POST /dlq-clear +POST /alerts body {"workflow", "node", "message", "severity", "reason", "execution_id", ...} -> {"stored", "count"} +GET /alerts -> {"count", "alerts": [...]} +POST /alerts/list body {} or filters {"workflow", "reason", "severity", "within": seconds} -> {"count", "alerts"} +POST /alerts/latest -> the newest alert or {}; POST /alerts-clear +""" + +import json +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlparse + +DATA_DIR = Path(os.getenv("DATA_DIR", Path(__file__).resolve().parent / "data")) +PORT = int(os.getenv("PORT", "8800")) + +LOCK = threading.Lock() +FAULT = {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8} +FLAKY: dict = {} +CACHE: dict[str, dict] = {} +STATS = {"hits": 0, "misses": 0} +AUDIT: list[dict] = [] +RELEASES: dict = {} +GATES: dict[str, list[dict]] = {} +USAGE: list[dict] = [] +BUDGET: dict = {} +BUDGET_KEYS = {"daily_usd": float, "user_limit_per_min": int, "max_question_chars": int} +CREATED: dict = {} +DLQ: list[dict] = [] +ALERTS: list[dict] = [] +NEXT = {"ticket": 2001, "dlq": 1} + + +def dlq_view(): + return {"size": len(DLQ), "parked": sum(1 for e in DLQ if e.get("parked")), "items": DLQ} + + +def alerts_view(body=None): + body = body or {} + now = time.time() + rows = [a for a in ALERTS + if all(not body.get(k) or a.get(k) == body[k] for k in ("workflow", "reason", "severity")) + and (not body.get("within") or now - a["at"] <= float(body["within"]))] + return {"count": len(rows), "alerts": rows} + + +def budget(): + if not BUDGET: + BUDGET.update(load("budget.json")) + used = round(sum(float(item.get("cost_usd") or 0) for item in USAGE), 8) + return {**BUDGET, "used_usd": used, "remaining_usd": round(BUDGET["daily_usd"] - used, 8), "over": used >= BUDGET["daily_usd"]} + + +def summary(): + def add(table, key, item): + row = table.setdefault(str(key), {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0}) + row["calls"] += 1 + row["prompt_tokens"] += int(item.get("prompt_tokens") or 0) + row["completion_tokens"] += int(item.get("completion_tokens") or 0) + row["cost_usd"] = round(row["cost_usd"] + float(item.get("cost_usd") or 0), 8) + total = {"requests": 0, "calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0} + by_model, by_user, by_route = {}, {}, {} + seen = set() + for index, item in enumerate(USAGE): + key = item.get("request_id") or f"item-{index}" + if key not in seen: + seen.add(key) + total["requests"] += 1 + total["calls"] += 1 + total["prompt_tokens"] += int(item.get("prompt_tokens") or 0) + total["completion_tokens"] += int(item.get("completion_tokens") or 0) + total["cost_usd"] = round(total["cost_usd"] + float(item.get("cost_usd") or 0), 8) + add(by_model, item.get("model", "?"), item) + add(by_user, item.get("user", "?"), item) + add(by_route, item.get("route", "?"), item) + return {**total, "by_model": by_model, "by_user": by_user, "by_route": by_route} + + +def load(name): + return json.loads((DATA_DIR / name).read_text(encoding="utf-8")) + + +def releases(): + if not RELEASES: + for name, item in load("releases.json").items(): + RELEASES[name] = {"active": item["active"], "canary": None, "require_gate": False, "releases": item["releases"]} + return RELEASES + + +def last_gate(name, release): + for record in reversed(GATES.get(name, [])): + if record.get("release") == release: + return record + return None + + +def search_faq(query, top_k=2): + words = {word for word in query.lower().split() if len(word) > 1} + scored = [] + for item in load("faq.json"): + keywords = {keyword.lower() for keyword in item["keywords"]} + score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords)) + if score: + scored.append({"score": score, **item}) + scored.sort(key=lambda item: item["score"], reverse=True) + return scored[:top_k] + + +class Handler(BaseHTTPRequestHandler): + def send_json(self, status, body): + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def url(self): + # Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged. + return urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace")) + + def body(self): + length = int(self.headers.get("Content-Length") or 0) + if not length: + return {} + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + + def faulty(self, target, key=""): + """Apply the fault switch to one endpoint group. Returns True when the response was already sent.""" + mode = FAULT.get(target, "ok") + if mode == "flaky": + with LOCK: + slot = f"{target}:{key}" if key else target + FLAKY[slot] = FLAKY.get(slot, 0) + 1 + fail = FLAKY[slot] == 1 if key else FLAKY[slot] % 2 == 0 + if fail: + self.send_json(500, {"error": f"{target} service failed this time (flaky fault switch)"}) + return True + return False + if mode == "slow": + time.sleep(float(FAULT.get("slow_seconds", 8))) + elif mode == "error": + self.send_json(500, {"error": f"{target} service is failing (fault switch)"}) + return True + return False + + def do_GET(self): + url = self.url() + if url.path == "/health": + return self.send_json(200, {"status": "ok"}) + if url.path == "/admin/fault": + return self.send_json(200, FAULT) + if url.path == "/audit": + with LOCK: + return self.send_json(200, {"events": list(AUDIT)}) + if url.path == "/tickets-created": + with LOCK: + return self.send_json(200, {"count": len(CREATED), "tickets": list(CREATED.values())}) + if url.path == "/dlq": + with LOCK: + return self.send_json(200, dlq_view()) + if url.path == "/alerts": + with LOCK: + return self.send_json(200, alerts_view()) + if url.path == "/prices": + return self.send_json(200, load("prices.json")) + if url.path == "/usage/summary": + with LOCK: + return self.send_json(200, summary()) + if url.path.startswith("/usage/user/"): + name = unquote(url.path.removeprefix("/usage/user/")) + window = float(parse_qs(url.query).get("window", ["60"])[0]) + now = time.time() + with LOCK: + count = len({item.get("request_id") or f"item-{i}" for i, item in enumerate(USAGE) + if item.get("user") == name and now - item["at"] <= window}) + return self.send_json(200, {"user": name, "window": window, "count": count}) + if url.path == "/budget": + with LOCK: + return self.send_json(200, budget()) + if url.path == "/cache-stats": + with LOCK: + return self.send_json(200, {"entries": len(CACHE), **STATS}) + if url.path.startswith("/cache/"): + key = unquote(url.path.removeprefix("/cache/")) + with LOCK: + entry = CACHE.get(key) + if entry and entry["expires"] > time.time(): + entry["hits"] += 1 + STATS["hits"] += 1 + return self.send_json(200, {"key": key, "value": entry["value"], "hits": entry["hits"]}) + CACHE.pop(key, None) + STATS["misses"] += 1 + return self.send_json(404, {"error": "not cached", "key": key}) + if url.path == "/faq": + if self.faulty("faq"): + return None + query = parse_qs(url.query).get("q", [""])[0] + return self.send_json(200, {"query": query, "results": search_faq(query)}) + if url.path == "/employees": + return self.send_json(200, {"employees": load("employees.json")}) + if url.path.startswith("/employees/"): + name = unquote(url.path.removeprefix("/employees/")).strip() + for person in load("employees.json"): + if person["name"] == name: + return self.send_json(200, person) + return self.send_json(404, {"error": "unknown employee", "name": name}) + if url.path == "/docs": + docs = [{"name": f.stem, "title": f.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()} + for f in sorted((DATA_DIR / "docs").glob("*.md"))] + return self.send_json(200, {"docs": docs}) + if url.path.startswith("/docs/"): + name = unquote(url.path.removeprefix("/docs/")).strip() + path = DATA_DIR / "docs" / f"{name}.md" + if "/" in name or not path.exists(): + return self.send_json(404, {"error": "doc not found", "name": name}) + text = path.read_text(encoding="utf-8") + return self.send_json(200, {"name": name, "title": text.splitlines()[0].lstrip("# ").strip(), "text": text}) + if url.path.startswith("/tickets/"): + if self.faulty("tickets"): + return None + ticket_id = unquote(url.path.removeprefix("/tickets/")).strip().upper() + ticket = load("tickets.json").get(ticket_id) + if ticket is None: + return self.send_json(404, {"error": "ticket not found", "ticket_id": ticket_id}) + requester = parse_qs(url.query).get("requester", [None])[0] + if requester is not None and requester.strip() != ticket["requester"]: + return self.send_json(403, {"error": "not your ticket", "ticket_id": ticket_id}) + return self.send_json(200, {"ticket_id": ticket_id, **ticket}) + if url.path.startswith("/prompts/"): + if self.faulty("prompts"): + return None + parts = [unquote(p) for p in url.path.removeprefix("/prompts/").split("/") if p] + folder = DATA_DIR / "prompts" + if len(parts) == 1: + versions = sorted(f.stem.removeprefix(parts[0] + "-") for f in folder.glob(f"{parts[0]}-*.md")) + return self.send_json(200 if versions else 404, {"name": parts[0], "versions": versions}) + if len(parts) == 2 and "/" not in parts[1] and (folder / f"{parts[0]}-{parts[1]}.md").exists(): + text = (folder / f"{parts[0]}-{parts[1]}.md").read_text(encoding="utf-8").strip() + return self.send_json(200, {"name": parts[0], "version": parts[1], "text": text}) + return self.send_json(404, {"error": "prompt not found", "path": url.path}) + if url.path.startswith("/releases/"): + name = unquote(url.path.removeprefix("/releases/")).strip("/") + with LOCK: + item = releases().get(name) + if item is None: + return self.send_json(404, {"error": "unknown release name", "name": name}) + return self.send_json(200, {"name": name, **item}) + if url.path.startswith("/gates/"): + name = unquote(url.path.removeprefix("/gates/")).strip("/") + with LOCK: + return self.send_json(200, {"name": name, "gates": list(GATES.get(name, []))}) + return self.send_json(404, {"error": "unknown path"}) + + def do_POST(self): + url = self.url() + body = self.body() + if body is None or not isinstance(body, dict): + return self.send_json(400, {"error": "body must be a JSON object"}) + if url.path == "/admin/fault": + for target in ("faq", "tickets", "prompts", "usage", "dlq"): + if target in body: + if body[target] not in ("ok", "slow", "error", "flaky"): + return self.send_json(400, {"error": f"{target} must be ok, slow, error or flaky"}) + FAULT[target] = body[target] + for slot in [s for s in FLAKY if s == target or s.startswith(target + ":")]: + del FLAKY[slot] + if "slow_seconds" in body: + FAULT["slow_seconds"] = max(0.0, min(float(body["slow_seconds"]), 60.0)) + return self.send_json(200, FAULT) + if url.path == "/tickets": + key = str(body.get("key") or "").strip() + if self.faulty("tickets", key): + return None + if not key or not str(body.get("title") or "").strip(): + return self.send_json(400, {"error": "key and title are required"}) + with LOCK: + if key in CREATED: + return self.send_json(200, {"ticket_id": CREATED[key]["ticket_id"], "duplicate": True}) + ticket_id = f"T-{NEXT['ticket']}" + NEXT["ticket"] += 1 + CREATED[key] = {"ticket_id": ticket_id, "key": key, **{k: body.get(k) for k in ("title", "requester", "category", "priority")}} + return self.send_json(201, {"ticket_id": ticket_id, "duplicate": False}) + if url.path == "/tickets-reset": + with LOCK: + count = len(CREATED) + CREATED.clear() + NEXT["ticket"] = 2001 + return self.send_json(200, {"cleared": count}) + if url.path == "/tickets-created/list": + with LOCK: + return self.send_json(200, {"count": len(CREATED), "tickets": list(CREATED.values())}) + if url.path == "/dlq": + if self.faulty("dlq"): + return None + items = body.get("items") if isinstance(body.get("items"), list) else [body] + with LOCK: + for item in items: + item_id = (item.get("item") or {}).get("id") + same = next((e for e in DLQ if item_id and (e.get("item") or {}).get("id") == item_id), None) + if same: + same["attempts"] += 1 + same["reason"] = item.get("reason", same.get("reason")) + same["at"] = time.time() + continue + DLQ.append({"dlq_id": f"D-{NEXT['dlq']}", "attempts": 1, "parked": False, **item, "at": time.time()}) + NEXT["dlq"] += 1 + return self.send_json(200, {"stored": len(items), "size": len(DLQ)}) + if url.path == "/dlq/list": + with LOCK: + return self.send_json(200, dlq_view()) + if url.path == "/dlq/update": + with LOCK: + for index, entry in enumerate(DLQ): + if entry["dlq_id"] == body.get("dlq_id"): + if body.get("remove"): + DLQ.pop(index) + return self.send_json(200, {"removed": True, "size": len(DLQ)}) + for key in ("attempts", "parked", "reason"): + if key in body: + entry[key] = body[key] + return self.send_json(200, {"updated": True, "entry": entry}) + return self.send_json(404, {"error": "dlq item not found"}) + if url.path == "/dlq-clear": + with LOCK: + count = len(DLQ) + DLQ.clear() + NEXT["dlq"] = 1 + return self.send_json(200, {"cleared": count}) + if url.path == "/alerts": + with LOCK: + ALERTS.append({**body, "at": time.time()}) + return self.send_json(200, {"stored": 1, "count": len(ALERTS)}) + if url.path == "/alerts/list": + with LOCK: + return self.send_json(200, alerts_view(body)) + if url.path == "/alerts/latest": + with LOCK: + return self.send_json(200, ALERTS[-1] if ALERTS else {}) + if url.path == "/alerts-clear": + with LOCK: + count = len(ALERTS) + ALERTS.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/audit": + with LOCK: + AUDIT.append({"t": round(time.time(), 3), **body}) + del AUDIT[:-1000] + count = len(AUDIT) + return self.send_json(200, {"stored": True, "count": count}) + if url.path == "/usage": + if FAULT.get("usage") == "slow": + # a hung record service: answers late and keeps nothing (a caller that gave up must not be counted later) + time.sleep(float(FAULT.get("slow_seconds", 8))) + return self.send_json(503, {"error": "usage service timed out (fault switch)", "stored": 0}) + if self.faulty("usage"): + return None + items = body.get("items") if isinstance(body.get("items"), list) else [body] + with LOCK: + for item in items: + USAGE.append({**item, "at": time.time()}) + return self.send_json(200, {"stored": len(items), "total": len(USAGE)}) + if url.path == "/usage/summary": + with LOCK: + return self.send_json(200, summary()) + if url.path == "/usage-clear": + with LOCK: + count = len(USAGE) + USAGE.clear() + return self.send_json(200, {"cleared": count}) + if url.path in ("/budget", "/budget/reset"): + with LOCK: + if url.path == "/budget/reset": + BUDGET.clear() + budget() + for key, kind in BUDGET_KEYS.items(): + if key in body: + BUDGET[key] = kind(body[key]) + return self.send_json(200, budget()) + if url.path == "/audit-clear": + with LOCK: + count = len(AUDIT) + AUDIT.clear() + return self.send_json(200, {"cleared": count}) + if url.path == "/cache-clear": + with LOCK: + count = len(CACHE) + CACHE.clear() + STATS.update(hits=0, misses=0) + return self.send_json(200, {"cleared": count}) + if url.path.startswith("/cache/"): + key = unquote(url.path.removeprefix("/cache/")) + if not key or "value" not in body: + return self.send_json(400, {"error": "key in the path and value in the body are required"}) + ttl = max(1.0, min(float(body.get("ttl", 600)), 86400.0)) + with LOCK: + CACHE[key] = {"value": body["value"], "expires": time.time() + ttl, "hits": 0} + return self.send_json(200, {"key": key, "stored": True, "ttl": ttl}) + if url.path.startswith("/gates/"): + name = unquote(url.path.removeprefix("/gates/")).strip("/") + clear = name.endswith("/clear") + name = name.removesuffix("/clear") + with LOCK: + if clear: + count = len(GATES.pop(name, [])) + return self.send_json(200, {"cleared": count}) + if not body.get("release") or body.get("verdict") not in ("promote", "rollback"): + return self.send_json(400, {"error": "release and verdict (promote or rollback) are required"}) + records = GATES.setdefault(name, []) + records.append({"t": round(time.time(), 3), **body}) + del records[:-200] + return self.send_json(200, {"stored": True, "count": len(records)}) + if url.path.startswith("/releases/"): + name = unquote(url.path.removeprefix("/releases/")).strip("/") + action = "" + for suffix in ("/reset", "/policy"): + if name.endswith(suffix): + action, name = suffix[1:], name.removesuffix(suffix) + with LOCK: + if action == "reset": + RELEASES.pop(name, None) + item = releases().get(name) + if item is None: + return self.send_json(404, {"error": "unknown release name", "name": name}) + if action == "reset": + return self.send_json(200, {"name": name, **item}) + if action == "policy": + if not isinstance(body.get("require_gate"), bool): + return self.send_json(400, {"error": "require_gate must be true or false"}) + item["require_gate"] = body["require_gate"] + return self.send_json(200, {"name": name, **item}) + wanted = [] + if "active" in body: + if body["active"] not in item["releases"]: + return self.send_json(400, {"error": "unknown release", "known": sorted(item["releases"])}) + wanted.append(body["active"]) + canary = body.get("canary") + if "canary" in body and canary is not None: + if not isinstance(canary, dict) or canary.get("release") not in item["releases"] \ + or not 0 <= int(canary.get("percent", -1)) <= 100: + return self.send_json(400, {"error": "canary needs a known release and percent 0..100"}) + canary = {"release": canary["release"], "percent": int(canary["percent"])} + wanted.append(canary["release"]) + if item["require_gate"]: + for release in wanted: + gate = last_gate(name, release) + if not gate or gate.get("verdict") != "promote": + return self.send_json(409, {"error": "not gated", "release": release, "last_gate": gate}) + if "active" in body: + item["active"] = body["active"] + if "canary" in body: + item["canary"] = canary + return self.send_json(200, {"name": name, **item}) + return self.send_json(404, {"error": "unknown path"}) + + do_PUT = do_POST + + def log_message(self, fmt, *args): + print(f"{self.address_string()} {fmt % args}", flush=True) + + +if __name__ == "__main__": + print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True) + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/Last3Mon/76/my-work/README.md b/Last3Mon/76/my-work/README.md new file mode 100644 index 0000000..5f3401b --- /dev/null +++ b/Last3Mon/76/my-work/README.md @@ -0,0 +1,6 @@ +# 수업 결과물 + +- CLI 코딩 에이전트가 만든 워크플로우 JSON (`intake.json`, `error-handler.json`, `replay.json`, `ops-status.json`)이 여기에 저장됩니다 (git에 올라가지 않음). 처음에는 `cp reference/baseline-intake.json my-work/intake.json`. +- `RUNBOOK.md`: [런북](../templates/RUNBOOK-TEMPLATE.md)을 복사해 채웁니다. +- `TEST-REPORT.md`: [테스트 보고서](../templates/TEST-REPORT-TEMPLATE.md)를 복사해 채웁니다 (6교시 끝). +- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다. diff --git a/Last3Mon/76/reference/baseline-intake.json b/Last3Mon/76/reference/baseline-intake.json new file mode 100644 index 0000000..19fbcab --- /dev/null +++ b/Last3Mon/76/reference/baseline-intake.json @@ -0,0 +1,221 @@ +{ + "name": "76 Intake baseline (provided)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "base-intake", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "edb7dfdf-1a51-539f-8d5c-3c4020d1591e", + "position": [ + 0, + 300 + ], + "id": "6d720b12-5983-53de-b5bb-adccbf9de50e" + }, + { + "name": "One item per request", + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "parameters": { + "fieldToSplitOut": "body.requests", + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "38e4b8e8-57ef-58c3-970c-1db6fbb0945a" + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 440, + 300 + ], + "id": "e4773f33-6534-57b1-b8a1-c18cab698b48" + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 440, + 520 + ], + "id": "c568219f-c8be-59b4-a28c-c4689db2ab9e" + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item, i) => {\n const req = $('One item per request').all()[i].json;\n let c = {};\n try { c = JSON.parse(String(item.json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { c = {}; }\n return { json: { ...req, category: c.category ?? '기타', priority: c.priority ?? '보통' } };\n});" + }, + "position": [ + 660, + 300 + ], + "id": "5ee2e1dc-dc92-5494-a906-04dbeae4e76b" + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/tickets", + "options": {}, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ key: $execution.id + '-' + $itemIndex, title: $json.text, requester: $json.user, category: $json.category, priority: $json.priority }) }}" + }, + "position": [ + 880, + 300 + ], + "id": "9bd6683a-c105-52e2-902a-3d1e8c09bd4a" + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const tickets = $input.all().map((item) => item.json.ticket_id);\nreturn [{ json: { processed: tickets.length, tickets } }];" + }, + "position": [ + 1100, + 300 + ], + "id": "d43029f1-3966-577d-87d6-b5152e764748" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1320, + 300 + ], + "id": "f423ab4b-e044-53bf-90d2-d7158256525e" + } + ], + "connections": { + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Intake webhook": { + "main": [ + [ + { + "node": "One item per request", + "type": "main", + "index": 0 + } + ] + ] + }, + "One item per request": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/reference/lab01-example2-intake.json b/Last3Mon/76/reference/lab01-example2-intake.json new file mode 100644 index 0000000..94b397f --- /dev/null +++ b/Last3Mon/76/reference/lab01-example2-intake.json @@ -0,0 +1,427 @@ +{ + "name": "76 Intake · retry and idempotency key (reference, Lab 01 example 2)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-intake", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.requests) }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"requests must be a list\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 600 + ] + }, + { + "name": "Check items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});" + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Valid?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 880, + 200 + ] + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "position": [ + 880, + 420 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {};\n try { c = JSON.parse(String(item.json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { c = {}; }\n return { json: { ...req, category: c.category ?? '기타', priority: c.priority ?? '보통', classified: true } };\n});" + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1320, + 200 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 300, + "onError": "continueRegularOutput" + }, + { + "name": "Mark results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});" + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Mark rejected", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));" + }, + "position": [ + 880, + 640 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1760, + 300 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];" + }, + "position": [ + 1980, + 300 + ] + }, + { + "name": "Public result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const { failed_requests, ...rest } = $('Summarize').first().json;\nreturn [{ json: rest }];" + }, + "position": [ + 3300, + 300 + ] + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3520, + 300 + ] + } + ], + "connections": { + "Intake webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is a list?": { + "main": [ + [ + { + "node": "Check items", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check items": { + "main": [ + [ + { + "node": "Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid?": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mark rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Mark results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark results": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark rejected": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Public result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/reference/lab02-example1-dlq.json b/Last3Mon/76/reference/lab02-example1-dlq.json new file mode 100644 index 0000000..bc1f91b --- /dev/null +++ b/Last3Mon/76/reference/lab02-example1-dlq.json @@ -0,0 +1,513 @@ +{ + "name": "76 Intake · DLQ (reference, Lab 02 example 1, includes Lab 01 tasks)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-intake", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 600 + ] + }, + { + "name": "Check items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});" + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Valid?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 880, + 200 + ], + "onError": "continueRegularOutput" + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "position": [ + 880, + 420 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});" + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1320, + 200 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 300, + "onError": "continueRegularOutput" + }, + { + "name": "Mark results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});" + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Mark rejected", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));" + }, + "position": [ + 880, + 640 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1760, + 300 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];" + }, + "position": [ + 1980, + 300 + ] + }, + { + "name": "Public result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const { failed_requests, ...rest } = $('Summarize').first().json;\nreturn [{ json: rest }];" + }, + "position": [ + 3300, + 300 + ] + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3520, + 300 + ] + }, + { + "name": "Any failed?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.failed > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2200, + 300 + ] + }, + { + "name": "Send to DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/dlq", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: $json.failed_requests.map((r, i) => ({ item: r, reason: $json.results.filter((x) => x.status === 'failed')[i]?.reason ?? 'failed', workflow: 'intake' })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 2420, + 140 + ] + } + ], + "connections": { + "Intake webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is a list?": { + "main": [ + [ + { + "node": "Check items", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check items": { + "main": [ + [ + { + "node": "Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid?": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mark rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Mark results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark results": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark rejected": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Public result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Any failed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any failed?": { + "main": [ + [ + { + "node": "Send to DLQ", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send to DLQ": { + "main": [ + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/reference/lab02-example2-error-handler.json b/Last3Mon/76/reference/lab02-example2-error-handler.json new file mode 100644 index 0000000..42dc6a3 --- /dev/null +++ b/Last3Mon/76/reference/lab02-example2-error-handler.json @@ -0,0 +1,78 @@ +{ + "id": "errHandler76001", + "name": "76 Error handler (reference)", + "nodes": [ + { + "name": "On error", + "type": "n8n-nodes-base.errorTrigger", + "typeVersion": 1, + "parameters": {}, + "position": [ + 0, + 300 + ] + }, + { + "name": "Make alert", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const e = $json;\nreturn [{ json: { workflow: e.workflow?.name ?? '?', workflow_id: e.workflow?.id ?? '', node: e.execution?.lastNodeExecuted ?? e.execution?.error?.node?.name ?? '?',\n message: String(e.execution?.error?.message ?? 'unknown error').slice(0, 300), execution_id: e.execution?.id ?? '', severity: 'critical', reason: 'unexpected' } }];" + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Send alert", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/alerts", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 440, + 300 + ] + } + ], + "connections": { + "On error": { + "main": [ + [ + { + "node": "Make alert", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make alert": { + "main": [ + [ + { + "node": "Send alert", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/reference/lab02-example2-intake.json b/Last3Mon/76/reference/lab02-example2-intake.json new file mode 100644 index 0000000..dc17e9e --- /dev/null +++ b/Last3Mon/76/reference/lab02-example2-intake.json @@ -0,0 +1,514 @@ +{ + "name": "76 Intake · DLQ + error workflow (reference, Lab 02 example 2)", + "nodes": [ + { + "name": "Intake webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-intake", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 600 + ] + }, + { + "name": "Check items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});" + }, + "position": [ + 440, + 300 + ] + }, + { + "name": "Valid?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Classify", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "={{ $json.text }}", + "options": { + "systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.", + "maxIterations": 1 + } + }, + "position": [ + 880, + 200 + ], + "onError": "continueRegularOutput" + }, + { + "name": "Classify model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "position": [ + 880, + 420 + ], + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "name": "Parse", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});" + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Create ticket", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1320, + 200 + ], + "retryOnFail": true, + "maxTries": 3, + "waitBetweenTries": 300, + "onError": "continueRegularOutput" + }, + { + "name": "Mark results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});" + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Mark rejected", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));" + }, + "position": [ + 880, + 640 + ] + }, + { + "name": "Collect", + "type": "n8n-nodes-base.merge", + "typeVersion": 3, + "parameters": { + "numberInputs": 2 + }, + "position": [ + 1760, + 300 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];" + }, + "position": [ + 1980, + 300 + ] + }, + { + "name": "Public result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const { failed_requests, ...rest } = $('Summarize').first().json;\nreturn [{ json: rest }];" + }, + "position": [ + 3300, + 300 + ] + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3520, + 300 + ] + }, + { + "name": "Any failed?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.failed > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2200, + 300 + ] + }, + { + "name": "Send to DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/dlq", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ items: $json.failed_requests.map((r, i) => ({ item: r, reason: $json.results.filter((x) => x.status === 'failed')[i]?.reason ?? 'failed', workflow: 'intake' })) }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 2420, + 140 + ] + } + ], + "connections": { + "Intake webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Is a list?": { + "main": [ + [ + { + "node": "Check items", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check items": { + "main": [ + [ + { + "node": "Valid?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid?": { + "main": [ + [ + { + "node": "Classify", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Mark rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify model": { + "ai_languageModel": [ + [ + { + "node": "Classify", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Classify": { + "main": [ + [ + { + "node": "Parse", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse": { + "main": [ + [ + { + "node": "Create ticket", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create ticket": { + "main": [ + [ + { + "node": "Mark results", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark results": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 0 + } + ] + ] + }, + "Mark rejected": { + "main": [ + [ + { + "node": "Collect", + "type": "main", + "index": 1 + } + ] + ] + }, + "Collect": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Public result": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Any failed?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Any failed?": { + "main": [ + [ + { + "node": "Send to DLQ", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send to DLQ": { + "main": [ + [ + { + "node": "Public result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "errorWorkflow": "errHandler76001" + } +} diff --git a/Last3Mon/76/reference/lab03-example1-replay.json b/Last3Mon/76/reference/lab03-example1-replay.json new file mode 100644 index 0000000..0d329ef --- /dev/null +++ b/Last3Mon/76/reference/lab03-example1-replay.json @@ -0,0 +1,278 @@ +{ + "name": "76 Replay (reference, Lab 03 example 1)", + "nodes": [ + { + "name": "Replay webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-replay", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ] + }, + { + "name": "Get DLQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/dlq", + "options": {} + }, + "position": [ + 220, + 300 + ] + }, + { + "name": "Pick items", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const items = ($json.items ?? []).filter((e) => !e.parked);\nreturn items.map((e) => ({ json: { ...e, action: 'replay' } }));" + }, + "position": [ + 440, + 300 + ], + "alwaysOutputData": true + }, + { + "name": "Has items?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.dlq_id }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "exists", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ] + }, + { + "name": "Nothing to do", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ replayed: 0, ok: 0, still_failed: 0, parked: 0 }) }}", + "options": {} + }, + "position": [ + 880, + 520 + ] + }, + { + "name": "Run intake again", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "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 + } + }, + "position": [ + 1100, + 200 + ] + }, + { + "name": "Outcome", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const picked = $('Has items?').all().filter((item) => item.json.action === 'replay');\nreturn $input.all().map((item, i) => ({ json: { dlq_id: picked[i].json.dlq_id, id: picked[i].json.item?.id, ok: item.json.created === 1, duplicate: item.json.duplicates === 1 } }));" + }, + "position": [ + 1320, + 200 + ] + }, + { + "name": "Remove done", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "method": "POST", + "url": "http://data-api:8800/dlq/update", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ dlq_id: $json.dlq_id, remove: $json.ok }) }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 1540, + 200 + ] + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst outcomes = ran('Outcome') ? $('Outcome').all().map((i) => i.json) : [];\nconst parked = 0;\nconst ok = outcomes.filter((o) => o.ok).length;\nreturn [{ json: { replayed: outcomes.length, ok, still_failed: outcomes.length - ok, parked, duplicates: outcomes.filter((o) => o.duplicate).length } }];" + }, + "position": [ + 2200, + 300 + ] + }, + { + "name": "Send summary", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 2420, + 300 + ] + } + ], + "connections": { + "Replay webhook": { + "main": [ + [ + { + "node": "Get DLQ", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get DLQ": { + "main": [ + [ + { + "node": "Pick items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Pick items": { + "main": [ + [ + { + "node": "Has items?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has items?": { + "main": [ + [ + { + "node": "Run intake again", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Nothing to do", + "type": "main", + "index": 0 + } + ] + ] + }, + "Run intake again": { + "main": [ + [ + { + "node": "Outcome", + "type": "main", + "index": 0 + } + ] + ] + }, + "Outcome": { + "main": [ + [ + { + "node": "Remove done", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Send summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Remove done": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/76/reference/nightly-report.json b/Last3Mon/76/reference/nightly-report.json new file mode 100644 index 0000000..c54991a --- /dev/null +++ b/Last3Mon/76/reference/nightly-report.json @@ -0,0 +1,103 @@ +{ + "name": "76 Nightly report (provided, has a bug)", + "nodes": [ + { + "name": "Report webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "nightly-report", + "responseMode": "responseNode", + "options": {} + }, + "webhookId": "440333b3-117e-5245-be3f-741ec769bfa5", + "position": [ + 0, + 300 + ], + "id": "168590b9-ccec-5d1c-9375-34b12cfcc2bc" + }, + { + "name": "Get created", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/tickets-created", + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "80bbb556-246d-5c36-8018-5d7e4fb65ac5" + }, + { + "name": "Count by category", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "// bug: the response has \"tickets\", not \"items\"\nconst rows = $json.items;\nconst counts = {};\nfor (const t of rows) counts[t.category] = (counts[t.category] ?? 0) + 1;\nreturn [{ json: { counts } }];" + }, + "position": [ + 440, + 300 + ], + "id": "d1f399a8-bc08-5ee4-84bc-5e2f7107d14b" + }, + { + "name": "Send report", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "52d3efaa-4a27-5d84-bb73-76af0e7c1dff" + } + ], + "connections": { + "Report webhook": { + "main": [ + [ + { + "node": "Get created", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get created": { + "main": [ + [ + { + "node": "Count by category", + "type": "main", + "index": 0 + } + ] + ] + }, + "Count by category": { + "main": [ + [ + { + "node": "Send report", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "errorWorkflow": "errHandler76001" + } +} diff --git a/Last3Mon/76/requests/batch-bad-item.json b/Last3Mon/76/requests/batch-bad-item.json new file mode 100644 index 0000000..e5d9eaa --- /dev/null +++ b/Last3Mon/76/requests/batch-bad-item.json @@ -0,0 +1,19 @@ +{ + "requests": [ + { + "id": "R-201", + "user": "한지훈", + "text": "프린터 용지가 걸려요" + }, + { + "id": "R-202", + "user": "한지훈", + "text": "" + }, + { + "id": "R-203", + "user": "김민지", + "text": "메일 첨부가 안 열려요" + } + ] +} diff --git a/Last3Mon/76/requests/batch-dup-id.json b/Last3Mon/76/requests/batch-dup-id.json new file mode 100644 index 0000000..dbd7750 --- /dev/null +++ b/Last3Mon/76/requests/batch-dup-id.json @@ -0,0 +1,19 @@ +{ + "requests": [ + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-401", + "user": "김민지", + "text": "회의실 화면 공유가 안 돼요" + }, + { + "id": "R-402", + "user": "최유진", + "text": "비밀번호를 바꾸라는 창이 계속 떠요" + } + ] +} diff --git a/Last3Mon/76/requests/batch-eleven.json b/Last3Mon/76/requests/batch-eleven.json new file mode 100644 index 0000000..e9248e8 --- /dev/null +++ b/Last3Mon/76/requests/batch-eleven.json @@ -0,0 +1,59 @@ +{ + "requests": [ + { + "id": "R-501", + "user": "박서연", + "text": "모니터 1번이 켜지지 않아요" + }, + { + "id": "R-502", + "user": "최유진", + "text": "모니터 2번이 켜지지 않아요" + }, + { + "id": "R-503", + "user": "정우성", + "text": "모니터 3번이 켜지지 않아요" + }, + { + "id": "R-504", + "user": "한지훈", + "text": "모니터 4번이 켜지지 않아요" + }, + { + "id": "R-505", + "user": "김민지", + "text": "모니터 5번이 켜지지 않아요" + }, + { + "id": "R-506", + "user": "이준호", + "text": "모니터 6번이 켜지지 않아요" + }, + { + "id": "R-507", + "user": "박서연", + "text": "모니터 7번이 켜지지 않아요" + }, + { + "id": "R-508", + "user": "최유진", + "text": "모니터 8번이 켜지지 않아요" + }, + { + "id": "R-509", + "user": "정우성", + "text": "모니터 9번이 켜지지 않아요" + }, + { + "id": "R-510", + "user": "한지훈", + "text": "모니터 10번이 켜지지 않아요" + }, + { + "id": "R-511", + "user": "김민지", + "text": "모니터 11번이 켜지지 않아요" + } + ] +} diff --git a/Last3Mon/76/requests/batch-ok.json b/Last3Mon/76/requests/batch-ok.json new file mode 100644 index 0000000..e5e38fd --- /dev/null +++ b/Last3Mon/76/requests/batch-ok.json @@ -0,0 +1,19 @@ +{ + "requests": [ + { + "id": "R-101", + "user": "이준호", + "text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요" + }, + { + "id": "R-102", + "user": "박서연", + "text": "새 모니터를 하나 더 받을 수 있을까요?" + }, + { + "id": "R-103", + "user": "최유진", + "text": "엑셀 라이선스가 만료됐다고 나와요" + } + ] +} diff --git a/Last3Mon/76/requests/batch-two.json b/Last3Mon/76/requests/batch-two.json new file mode 100644 index 0000000..3719a88 --- /dev/null +++ b/Last3Mon/76/requests/batch-two.json @@ -0,0 +1,14 @@ +{ + "requests": [ + { + "id": "R-301", + "user": "정우성", + "text": "사내 위키 로그인이 안 돼요" + }, + { + "id": "R-302", + "user": "이준호", + "text": "노트북 배터리가 금방 닳아요" + } + ] +} diff --git a/Last3Mon/76/requests/not-a-list.json b/Last3Mon/76/requests/not-a-list.json new file mode 100644 index 0000000..51d8077 --- /dev/null +++ b/Last3Mon/76/requests/not-a-list.json @@ -0,0 +1,3 @@ +{ + "requests": "VPN이 끊겨요" +} diff --git a/Last3Mon/76/templates/RUNBOOK-TEMPLATE.md b/Last3Mon/76/templates/RUNBOOK-TEMPLATE.md new file mode 100644 index 0000000..55f00bc --- /dev/null +++ b/Last3Mon/76/templates/RUNBOOK-TEMPLATE.md @@ -0,0 +1,73 @@ +# 런북 (대응 절차) · 문의 일괄 접수 + +- 이름: +- 이 파일을 `my-work/RUNBOOK.md`로 복사해 채웁니다. **새벽 3시에 처음 보는 사람도 따라 할 수 있게** 씁니다. + +## 1. 실패의 종류 (Lab 01 · 02) + +### 1.1 기준 워크플로우에서 본 것 (Lab 01 예제 1) + +| 한 일 | 사용자가 본 것 | 실제로 생긴 것 | +| --- | --- | --- | +| 빈 글이 섞인 3건 | | | +| `flaky`에서 2건 | | | +| 정상 3건을 다시 | | | + +### 1.2 고장 종류마다 (Lab 01 예제 3 · `cases/lab01-faults.json`) + +| 고장 | 걸린 시간 | 결과 | 실제로 생긴 접수 건 | +| --- | --- | --- | --- | +| `flaky` | | | | +| `error` | | | | +| `slow` | | | | + +### 1.3 그물 지도 (Lab 02 예제 3 · `cases/lab02-nets.json`) + +| 실패 | 어떻게 알아채나 | 자동으로 하는 일 | 사람이 할 일 | +| --- | --- | --- | --- | +| 잘못된 항목 · 같은 id · 10건 넘음 | 결과의 `rejected` · 400 | 그 항목만 거절 · 전체 거절, 이유 | 요청한 사람에게 이유 전달 | +| API가 잠깐 실패 | (없음) | 재시도 3번 | | +| API가 멈춤 | 알림 `high · dlq` (10분에 한 번) | DLQ에 넣음 | | +| DLQ도 멈춤 | 알림 `critical · dlq_down` (잃을 뻔한 id) | 결과 `dlq_saved: false` | | +| LLM이 멈춤 | 결과의 `unclassified` | 미분류로 접수 | | +| 예상 못 한 오류 | 알림 `critical · unexpected` | Error Workflow | | +| 세 번 넘게 실패 | 알림 `high · parked` | 재처리에서 뺌 | | + +### 1.4 알림 등급 + +| 등급 | 무엇 | 받으면 몇 분 안에 | 누가 | +| --- | --- | --- | --- | +| `critical` | | | | +| `high` | | | | + +## 2. 알림을 받으면 (순서대로) + +1. `uv run tools/flow.py ops`로 고장 스위치 · DLQ · 알림 확인 (또는 `curl -s -XPOST localhost:5678/webhook/my-ops-status -d '{}'`) +2. +3. +4. 고친 뒤 `curl -s -XPOST localhost:5678/webhook/my-replay -d '{}'` (재처리) +5. `ops`로 DLQ가 비었는지, 접수 건이 **중복 없이** 생겼는지 + +**하지 말 것**: + +- + +## 3. 장애 연습 기록 (Lab 03 예제 2 · `cases/lab03-drill.json`) + +| 단계 | 한 일 | 본 것 | 걸린 시간 | +| --- | --- | --- | --- | +| 장애 시작 | | | | +| 알아챔 | | | | +| 원인 | | | | +| 고침 | | | | +| 재처리 · 확인 | | | | + +런북에서 빠졌던 단계: + +## 4. 연락처 · 책임 + +| 무엇 | 누구 | 언제까지 | +| --- | --- | --- | +| 1차 대응 | | | +| 데이터 API 담당 | | | +| parked 건 확인 · 결정 | | | diff --git a/Last3Mon/76/templates/TEST-REPORT-TEMPLATE.md b/Last3Mon/76/templates/TEST-REPORT-TEMPLATE.md new file mode 100644 index 0000000..075ce77 --- /dev/null +++ b/Last3Mon/76/templates/TEST-REPORT-TEMPLATE.md @@ -0,0 +1,33 @@ +# 테스트 보고서 · 문의 일괄 접수 (에러 처리) + +- 이름: +- 사용한 CLI 에이전트: codex / claude / agy +- 워크플로우 파일: `my-work/intake.json`, `my-work/error-handler.json`, `my-work/replay.json`, `my-work/ops-status.json` + +## 1. 통과한 테스트 + +| 테스트 파일 | 결과 | +| --- | --- | +| `cases/lab01.json` (항목 확인 · 재시도 · 중복 없음) | Passed /6 | +| `cases/lab01-faults.json` (고장 종류마다, 재기만) | Passed /5 | +| `cases/lab01-challenge.json` (LLM 멈춤) | Passed /3 | +| `cases/lab01-challenge2.json` (같은 id · 10건 상한) | Passed /4 | +| `cases/lab02.json` (DLQ · Error Workflow) | Passed /4 | +| `cases/lab02-nets.json` (그물 지도) | Passed /7 | +| `cases/lab02-challenge.json` (처리한 실패 알림 · DLQ가 멈추면) | Passed /6 | +| `cases/lab02-challenge2.json` (알림 폭주 막기) | Passed /6 | +| `cases/lab03.json` (재처리) | Passed /5 | +| `cases/lab03-drill.json` (장애 연습) | Passed /7 | +| `cases/lab03-half.json` (반쯤 처리된 요청, 재기만) | Passed /6 | +| `cases/lab03-challenge.json` (세 번 실패하면 멈춤) | Passed /7 | +| `cases/lab03-challenge2.json` (운영 상태) | Passed /7 | + +## 2. 실패와 고친 내용 + +| 실패한 케이스 | 처음 결과 | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 | +| --- | --- | --- | --- | --- | +| | | | | | + +## 3. 아직 잃을 수 있는 것 + +- diff --git a/Last3Mon/76/tools/flow.py b/Last3Mon/76/tools/flow.py new file mode 100644 index 0000000..d1cf82f --- /dev/null +++ b/Last3Mon/76/tools/flow.py @@ -0,0 +1,1205 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote. + + uv run tools/flow.py doctor # is everything ready? + uv run tools/flow.py check my-work/helpdesk.json # find mistakes before n8n sees the file + uv run tools/flow.py push my-work/helpdesk.json # import + publish (one n8n restart) + uv run tools/flow.py off my-cost # unpublish by file or webhook path + uv run tools/flow.py diff reference/baseline-cost.json my-work/cost.json [--full] # what changed, node by node + uv run tools/flow.py call base-cost requests/faq-vpn.json [-v] # look at usage.by_step and cost_usd + uv run tools/flow.py test cases/lab02.json [-v] [--repeat 3] [--set user=이준호] # "range": {"cost_usd": [0, 0.0002]} + uv run tools/flow.py bench my-cost requests/faq-vpn.json -n 10 # p50 · p95 · max · failures + uv run tools/flow.py usage [--clear] # recorded calls, tokens and cost by model / user / route + uv run tools/flow.py budget [--daily 0.001] [--per-min 2] [--reset] # the daily budget and per-user limit + uv run tools/flow.py cache [--clear] # the answer cache (Lab 02 task 2) + uv run tools/flow.py fault usage slow|error|ok # make the data API slow or failing (drills) + uv run tools/flow.py llm down|up # outage drill: a broken OpenAI key in n8n, then restore + +Every command takes -v (--verbose) for details: the request and response JSON of each call (call, test, bench, +fault, doctor), the nodes and links of a workflow (check, push), and the docker / n8n CLI commands that ran. + +Run it from the day folder (Last3Mon/76). n8n is the day 65 install (~/day65-n8n, Docker project +day65-n8n), started from Last3Mon/65/n8n. Environment overrides: + N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1) + N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678) +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import math +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CACHE = Path(__file__).resolve().parent / ".cache" +def n8n_home() -> Path: + """Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n.""" + if os.getenv("N8N_HOME"): + return Path(os.environ["N8N_HOME"]).expanduser() + env_file = ROOT.parent / "65" / "n8n" / ".env" + if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + if line.startswith("N8N_HOME="): + value = line.split("=", 1)[1].strip().strip("'\"") + if value: + return (env_file.parent / Path(value).expanduser()).resolve() + return Path.home() / "day65-n8n" + + +N8N_HOME = n8n_home() +START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)" + + +def find_container() -> str: + """The n8n container of the day 65 project, whichever folder it was started from.""" + if os.getenv("N8N_CONTAINER"): + return os.environ["N8N_CONTAINER"] + if shutil.which("docker"): + result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n", + "--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"], + capture_output=True, text=True) + names = result.stdout.split() + if names: + return names[0] + return "day65-n8n-n8n-1" + + +CONTAINER = find_container() +N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/") + +# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only). +RECOMMENDED = { + "n8n-nodes-base.webhook": 2, + "n8n-nodes-base.respondToWebhook": 1.1, + "n8n-nodes-base.if": 2.2, + "n8n-nodes-base.switch": 3.2, + "n8n-nodes-base.set": 3.4, + "n8n-nodes-base.code": 2, + "n8n-nodes-base.httpRequest": 4.2, + "n8n-nodes-base.httpRequestTool": 4.2, + "@n8n/n8n-nodes-langchain.agent": 3.1, + "@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2, + "@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3, + "@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2, + "@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1, +} +FAULT_TARGETS = ("faq", "tickets", "prompts", "usage", "dlq") +NODE_SETTINGS = ("retryOnFail", "maxTries", "waitBetweenTries", "onError", "alwaysOutputData", "executeOnce") +USES_DATA_API = True # day 76: FAQ, tickets, ticket creation, dead letter queue, alerts (and day 75 usage) come from the data API +AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 76 (base-intake and nightly-report are pushed by hand) +COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama) +CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"} +SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}") + +OK, BAD, WARN = "✔", "✘", "!" +VERBOSE = False # -v on any command (set in main) + + +# ---------------------------------------------------------------- helpers +def die(message: str) -> None: + print(f"{BAD} {message}", file=sys.stderr) + sys.exit(1) + + +def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess: + if not shutil.which("docker"): + die("docker command not found. Start Docker first.") + result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True) + if VERBOSE: + show_command(args, result) + if check and result.returncode != 0: + die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}") + return result + + +def show_json(label: str, value: object) -> None: + """Print one JSON value under a label, indented below the result line (-v). API keys are masked.""" + text = json.dumps(value, ensure_ascii=False, indent=2) if not isinstance(value, str) else value + print(f" {label}") + for row in SECRET.sub("sk-***", text).splitlines(): + print(f" {row}") + + +def detail(label: str, value: object) -> None: + """show_json, only with -v.""" + if VERBOSE: + show_json(label, value) + + +def show_command(args: tuple[str, ...], result: subprocess.CompletedProcess) -> None: + """-v: one docker command and the end of its output. Data dumps (cat of an export) are not printed.""" + command = " ".join(arg if len(arg) <= 90 else arg[:87] + "..." for arg in args) + print(f" $ docker {command} → exit {result.returncode}") + if "cat /tmp" in " ".join(args): + return + rows = [row for row in (result.stdout + result.stderr).splitlines() if row.strip()] + for row in rows[-6:]: + print(f" {SECRET.sub('sk-***', row)[:160]}") + + +def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess: + return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check) + + +def container_running() -> bool: + if not shutil.which("docker"): + return False + result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True) + return result.stdout.strip() == "true" + + +def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]: + data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"}) + start = time.monotonic() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status, raw = response.status, response.read() + except urllib.error.HTTPError as error: + status, raw = error.code, error.read() + except (urllib.error.URLError, TimeoutError, ConnectionError) as error: + return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start + text = raw.decode("utf-8", errors="replace") + try: + payload: object = json.loads(text) if text.strip() else "" + except json.JSONDecodeError: + payload = text + return status, payload, time.monotonic() - start + + +def load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + die(f"file not found: {path}") + except json.JSONDecodeError as error: + die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}") + + +def resolve(path: str) -> Path: + candidate = Path(path) + if candidate.exists() or candidate.is_absolute(): + return candidate + return ROOT / path + + +def node_versions() -> dict[str, list[float]]: + """Node types and versions that this n8n really has (exported once, then cached).""" + cache = CACHE / "node-versions.json" + if cache.exists(): + return json.loads(cache.read_text()) + if not container_running(): + return {} + n8n("export:nodes", "--output=/tmp/course-nodes.json") + raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout + versions: dict[str, set[float]] = {} + for node in json.loads(raw): + value = node.get("version") + versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value]) + result = {name: sorted(values) for name, values in versions.items()} + CACHE.mkdir(exist_ok=True) + cache.write_text(json.dumps(result)) + return result + + +def walk_strings(value: object, where: str = ""): + if isinstance(value, str): + yield where, value + elif isinstance(value, dict): + for key, item in value.items(): + yield from walk_strings(item, f"{where}.{key}" if where else key) + elif isinstance(value, list): + for index, item in enumerate(value): + yield from walk_strings(item, f"{where}[{index}]") + + +def webhooks(workflow: dict) -> list[tuple[str, str]]: + found = [] + for node in workflow.get("nodes", []): + if node.get("type") == "n8n-nodes-base.webhook": + parameters = node.get("parameters", {}) + found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/"))) + return found + + +# ---------------------------------------------------------------- check +def check_workflow(workflow: object) -> tuple[list[str], list[str]]: + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(workflow, dict): + return ["the file must be one workflow object { name, nodes, connections }"], warnings + if not str(workflow.get("name", "")).strip(): + errors.append("missing workflow 'name'") + nodes = workflow.get("nodes") + connections = workflow.get("connections") + if not isinstance(nodes, list) or not nodes: + return errors + ["'nodes' must be a non-empty list"], warnings + if not isinstance(connections, dict): + errors.append("'connections' must be an object") + connections = {} + + known = node_versions() + names: dict[str, dict] = {} + for index, node in enumerate(nodes): + label = f"node #{index + 1}" + if not isinstance(node, dict): + errors.append(f"{label} is not an object") + continue + name = node.get("name") + if not name: + errors.append(f"{label} has no 'name'") + continue + label = f"node '{name}'" + if name in names: + errors.append(f"{label} appears twice (node names must be unique)") + names[name] = node + node_type, version = node.get("type"), node.get("typeVersion") + if not node_type or version is None: + errors.append(f"{label} needs 'type' and 'typeVersion'") + elif known and node_type not in known: + errors.append(f"{label}: type '{node_type}' does not exist in this n8n") + elif known and version not in known[node_type]: + errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})") + elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]: + warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})") + if not isinstance(node.get("parameters"), dict): + errors.append(f"{label} needs 'parameters' (use {{}} when empty)") + position = node.get("position") + if not (isinstance(position, list) and len(position) == 2): + errors.append(f"{label} needs 'position': [x, y]") + for kind, credential in (node.get("credentials") or {}).items(): + credential_id = (credential or {}).get("id") + if credential_id not in COURSE_CREDENTIALS: + errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}") + for where, text in walk_strings(node.get("parameters", {})): + if SECRET.search(text): + errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.") + if "{{" in text and "}}" in text and not text.startswith("="): + warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)") + # day 75: OpenAI no longer serves gpt-4.1-nano, and gpt-5.4-nano refuses max_tokens over HTTP + if "gpt-4.1-nano" in text: + errors.append(f"{label}: parameters.{where} uses gpt-4.1-nano (OpenAI no longer serves it). Use gpt-5.4-nano.") + if "chat/completions" in str(node.get("parameters", {}).get("url", "")) and re.search(r"\bmax_tokens\b", text): + errors.append(f"{label}: parameters.{where} has max_tokens. Use max_completion_tokens (gpt-5.4-nano refuses max_tokens).") + + # day 76: node settings live next to "parameters", not inside it (n8n ignores them there) + misplaced = [key for key in NODE_SETTINGS if key in (node.get("parameters") or {})] + if misplaced: + errors.append(f"{label}: {', '.join(misplaced)} must be at the node level next to 'parameters', not inside it") + if node.get("retryOnFail") and not 1 < int(node.get("maxTries") or 3) <= 5: + errors.append(f"{label}: maxTries must be 2..5 with retryOnFail (n8n allows up to 5)") + if "onError" in node and node["onError"] not in ("stopWorkflow", "continueRegularOutput", "continueErrorOutput"): + errors.append(f"{label}: onError must be stopWorkflow, continueRegularOutput or continueErrorOutput") + + targets_of: dict[str, set[str]] = {} + for source, outputs in connections.items(): + if source not in names: + errors.append(f"connections: source '{source}' is not a node name") + continue + if not isinstance(outputs, dict): + errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}") + continue + for kind, branches in outputs.items(): + if kind not in CONNECTION_TYPES: + errors.append(f"connections['{source}']: unknown connection type '{kind}'") + for branch in branches or []: + for link in branch or []: + target = (link or {}).get("node") + if target not in names: + errors.append(f"connections['{source}'] points to '{target}', which is not a node name") + elif link.get("type") != kind: + errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'") + else: + targets_of.setdefault(target, set()).add(kind) + if kind != "main": + targets_of.setdefault(f"{source}::from", set()).add(kind) + + has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values()) + for name, node in names.items(): + node_type = node.get("type", "") + parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {} + if node_type == "n8n-nodes-base.webhook": + if not str(parameters.get("path", "")).strip("/"): + errors.append(f"node '{name}': webhook needs a 'path'") + if parameters.get("responseMode") == "responseNode" and not has_respond: + errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node") + if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()): + errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'") + if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"): + if "ai_tool" not in targets_of.get(f"{name}::from", set()): + errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'") + if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"): + if "ai_languageModel" not in targets_of.get(f"{name}::from", set()): + errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'") + is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links + if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \ + and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \ + and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower(): + warnings.append(f"node '{name}': nothing is connected into it (it never runs)") + error_workflow = (workflow.get("settings") or {}).get("errorWorkflow") + if error_workflow and not re.fullmatch(r"[A-Za-z0-9]{10,21}", str(error_workflow)): + errors.append(f"settings.errorWorkflow must be the id of the error workflow (e.g. errHandler76001), got {error_workflow!r}") + if any(node.get("type") == "n8n-nodes-base.errorTrigger" for node in names.values()) and not workflow.get("id"): + warnings.append("an error workflow needs a fixed top-level \"id\" (other workflows name it in settings.errorWorkflow)") + if not webhooks(workflow) and not any(node.get("type") == "n8n-nodes-base.errorTrigger" for node in names.values()): + warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path") + return errors, warnings + + +def show_workflow(workflow: object) -> None: + """-v: the nodes, links and webhooks of a workflow file.""" + if not VERBOSE or not isinstance(workflow, dict): + return + print(f" workflow '{workflow.get('name')}' · {len(workflow.get('nodes') or [])} node(s)") + for node in workflow.get("nodes") or []: + if isinstance(node, dict): + extra = [key for key in ("onError", "retryOnFail") if key in node] + print(f" {node.get('name')} · {node.get('type')} v{node.get('typeVersion')}" + (f" · {', '.join(extra)}" if extra else "")) + print(" links") + for source, outputs in (workflow.get("connections") or {}).items(): + for kind, branches in (outputs or {}).items(): + for index, branch in enumerate(branches or []): + targets = ", ".join(str((link or {}).get("node")) for link in branch or []) + if targets: + print(f" {source} --{kind}[{index}]--> {targets}") + for method, hook in webhooks(workflow): + print(f" webhook {method} /webhook/{hook}") + + +def cmd_check(args: argparse.Namespace) -> int: + path = resolve(args.file) + workflow = load_json(path) + show_workflow(workflow) + errors, warnings = check_workflow(workflow) + for warning in warnings: + print(f"{WARN} {warning}") + for error in errors: + print(f"{BAD} {error}") + if errors: + print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.") + return 1 + print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else "")) + return 0 + + +# ---------------------------------------------------------------- push +def stable_id(path: Path) -> str: + return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15] + + +def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3) + if status == 200: + break + time.sleep(1) + else: + return False + for method, path in paths: + while time.monotonic() < deadline: + # A webhook that is registered for POST answers a GET with a hint, not "is not registered". + status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5) + text = json.dumps(payload, ensure_ascii=False) + if "not registered for" in text or status not in (0, 404, 500, 502, 503): + break + time.sleep(1) + time.sleep(2) # webhooks of the other published workflows finish registering right after + return time.monotonic() < deadline + + +def exported_workflows() -> list[dict]: + exported = docker("exec", CONTAINER, "sh", "-c", + "n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; " + "cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout + try: + return json.loads(exported) if exported.strip() else [] + except json.JSONDecodeError: + return [] + + +def prepare(path: Path) -> dict | None: + workflow = load_json(path) + show_workflow(workflow) + errors, warnings = check_workflow(workflow) + for warning in warnings: + print(f"{WARN} {path.name}: {warning}") + if errors: + for error in errors: + print(f"{BAD} {path.name}: {error}") + return None + workflow = dict(workflow) + if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))): + workflow["id"] = stable_id(path) + workflow["active"] = False + workflow.setdefault("settings", {}).setdefault("executionOrder", "v1") + for node in workflow["nodes"]: + node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}"))) + if node["type"] == "n8n-nodes-base.webhook": + node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}"))) + return workflow + + +def cmd_push(args: argparse.Namespace) -> int: + paths = [resolve(file) for file in args.files] + workflows = [prepare(path) for path in paths] + if None in workflows: + print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check ).") + return 1 + if not container_running(): + die(f"n8n container '{CONTAINER}' is not running. {START_HINT}") + mine_ids = {workflow["id"] for workflow in workflows} + hooks = {hook for workflow in workflows for hook in webhooks(workflow)} + + # Another published workflow with the same webhook path would block this one. + for other in exported_workflows(): + if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)): + n8n("unpublish:workflow", f"--id={other['id']}") + print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path") + + for workflow in workflows: + docker("exec", "-i", CONTAINER, "sh", "-c", + "cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; " + "status=$?; rm -f /tmp/course-workflow.json; exit $status", + stdin=json.dumps(workflow, ensure_ascii=False)) + n8n("publish:workflow", f"--id={workflow['id']}") + # The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s). + docker("restart", CONTAINER) + ready = wait_until_ready(sorted(hooks)) + for workflow in workflows: + print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})") + for method, hook in sorted(webhooks(workflow)): + print(f" {method} {N8N_URL}/webhook/{hook}") + if not ready: + print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.") + return 0 + + +def cmd_off(args: argparse.Namespace) -> int: + """Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down.""" + if not container_running(): + die(f"n8n container '{CONTAINER}' is not running. {START_HINT}") + exported = exported_workflows() + detail("published now", [f"{item.get('id')} · {item.get('name')} · " + ", ".join(f"/{hook}" for _, hook in webhooks(item)) + for item in exported if item.get("active")]) + wanted: dict[str, str] = {} + for item in args.files: + path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None + if path: + workflow = load_json(path) + workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path) + wanted[workflow_id] = item + hooks = {hook for _, hook in webhooks(workflow)} + else: + hooks = {item.strip("/")} + for other in exported: + if other.get("active") and hooks & {hook for _, hook in webhooks(other)}: + wanted[other["id"]] = item + active = {item.get("id") for item in exported if item.get("active")} + for workflow_id, item in wanted.items(): + if workflow_id in active: + n8n("unpublish:workflow", f"--id={workflow_id}") + print(f"{OK} unpublished {item} (id {workflow_id})") + docker("restart", CONTAINER) + wait_until_ready([]) + print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push ") + return 0 + + +# ---------------------------------------------------------------- call / test +def request_body(spec: str | dict, base: Path = ROOT) -> object: + if isinstance(spec, dict): + return spec + text = spec.strip() + if text.startswith("{"): + try: + return json.loads(text) + except json.JSONDecodeError as error: + die(f"request is not valid JSON: {error.msg}") + path = Path(text) + if not path.exists(): + path = base / text + return load_json(path) + + +def cmd_call(args: argparse.Namespace) -> int: + body = request_body(args.request) if args.request else {} + hook = args.path.strip('/') + status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body) + print(f"HTTP {status} · {seconds:.1f}s") + if args.verbose: + show_json(f"→ request POST /webhook/{hook}", body) + show_json(f"← response HTTP {status}", payload) + return 0 if 200 <= status < 300 else 1 + print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload) + return 0 if 200 <= status < 300 else 1 + + +def field(payload: object, dotted: str) -> object: + value = payload + for part in dotted.split("."): + if isinstance(value, dict): + value = value.get(part) + elif isinstance(value, list) and part.isdigit() and int(part) < len(value): + value = value[int(part)] + else: + return None + return value + + +def as_text(value: object) -> str: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + + +def judge(expect: dict, status: int, payload: object, seconds: float = 0.0) -> list[str]: + problems = [] + if "max_seconds" in expect and seconds > float(expect["max_seconds"]): + problems.append(f"took {seconds:.1f}s (expected at most {expect['max_seconds']}s)") + if status != expect.get("status", 200): + problems.append(f"status {status} (expected {expect.get('status', 200)})") + for name, wanted in (expect.get("equals") or {}).items(): + actual = field(payload, name) + if actual != wanted: + problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})") + for name, bounds in (expect.get("range") or {}).items(): + actual = field(payload, name) + low, high = bounds + if not isinstance(actual, (int, float)) or isinstance(actual, bool) or not low <= actual <= high: + problems.append(f"{name} = {as_text(actual)[:40]} (expected {low}..{high})") + for name, words in (expect.get("contains") or {}).items(): + words = [words] if isinstance(words, str) else words + text = as_text(field(payload, name) or "") + if not any(word in text for word in words): + problems.append(f"{name} has none of {words}") + for name, words in (expect.get("not_contains") or {}).items(): + words = [words] if isinstance(words, str) else words + text = as_text(field(payload, name) or "") + leaked = [word for word in words if word in text] + if leaked: + problems.append(f"{name} must not have {leaked}") + return problems + + +def cmd_test(args: argparse.Namespace) -> int: + path = resolve(args.cases) + suite = load_json(path) + hook = (args.webhook or suite.get("webhook", "")).strip("/") + cases = suite.get("cases", []) + if not hook or not cases: + die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list") + # "before"/"after": calls made once around the cases, e.g. turn a fault on and off again. + if not args.no_before: + for step in suite.get("before", []): + run_step("before", step) + try: + return run_cases(args, path, suite, hook, cases) + finally: + for step in suite.get("after", []): + run_step("after", step) + + +def run_step(label: str, step: dict) -> None: + """A step is {"webhook": path} (POST to n8n), {"url": ...} (POST to the data API, e.g. the fault switch) + or {"llm": "down"|"up"} (the outage drill) or {"sleep": 3} (wait, e.g. for an error workflow that runs late). + "status": 409 means that status is expected (e.g. a refused deploy).""" + if "sleep" in step: + print(f"… {label}: wait {step['sleep']}s") + time.sleep(float(step["sleep"])) + return + if step.get("llm") in ("down", "up"): + code = cmd_llm(argparse.Namespace(state=step["llm"])) + if code and label == "before": + die("could not change the OpenAI credential (llm drill)") + if step["llm"] == "down": + time.sleep(1) + return + if step.get("url"): + target = str(step["url"]) + else: + target = f"{N8N_URL}/webhook/{str(step.get('webhook', '')).strip('/')}" + body = request_body(step.get("body") or step.get("request", "{}")) + status, payload, seconds = http("POST", target, body) + shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120] + good = status == step["status"] if "status" in step else 200 <= status < 300 + print(f"{OK if good else BAD} {label}: POST {target.replace(N8N_URL, '')} · HTTP {status} · {seconds:.1f}s · {shown}") + detail(f"→ request POST {target.replace(N8N_URL, '')}", body) + detail(f"← response HTTP {status}", payload) + if not good and label == "before": + die(f"the 'before' step failed ({target}). Is it pushed / is the data API the day 76 version?") + + +def parse_sets(items: list[str] | None) -> dict: + """--set release=1.1.0 --set no_cache=true → {"release": "1.1.0", "no_cache": true}""" + out: dict = {} + for item in items or []: + key, _, value = item.partition("=") + try: + out[key] = json.loads(value) + except json.JSONDecodeError: + out[key] = value + return out + + +def run_cases(args: argparse.Namespace, path: Path, suite: dict, hook: str, cases: list) -> int: + print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}") + results, passed = [], 0 + for case in cases: + for step in case.get("before", []): + run_step("before", step) + try: + passed += run_case(args, case, hook, results) + finally: + for step in case.get("after", []): + run_step("after", step) + return finish(path, hook, results, passed) + + +def run_case(args: argparse.Namespace, case: dict, hook: str, results: list) -> int: + passed = 0 + body = request_body(case.get("body") or case.get("request", "{}")) + if getattr(args, "set", None) and isinstance(body, dict): + body = {**body, **parse_sets(args.set)} + case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook + # "url": POST to that address instead of the webhook (e.g. check /usage/summary after the questions) + target = str(case["url"]) if case.get("url") else f"{N8N_URL}/webhook/{case_hook}" + shown = target.replace(N8N_URL, "") + for run in range(1, args.repeat + 1): + status, payload, seconds = http("POST", target, body) + problems = judge(case.get("expect", {}), status, payload, seconds) + passed += not problems + mark = OK if not problems else BAD + suffix = f" #{run}" if args.repeat > 1 else "" + print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s{cost_note(payload)}" + ("" if not problems else " · " + "; ".join(problems))) + if args.verbose: + show_json(f"→ request POST {shown}", body) + show_json(f"← response HTTP {status}", payload) + results.append({"case": case.get("name"), "webhook": case_hook if not case.get("url") else shown, "run": run, "passed": not problems, "problems": problems, + "seconds": round(seconds, 1), "status": status, "request": body, "response": payload}) + return passed + + +def cost_note(payload: object) -> str: + """' · 자동 답변 · 1 call · $0.000123' when the answer says how many LLM calls it made and what they cost (day 75).""" + if not isinstance(payload, dict) or not isinstance(payload.get("cost_usd"), (int, float)): + return "" + calls = payload.get("llm_calls") + route = f" · {payload['route']}" if isinstance(payload.get("route"), str) else "" + return route + (f" · {calls} call{'s' if calls != 1 else ''}" if isinstance(calls, int) else "") + f" · ${payload['cost_usd']:.6f}" + + +LAST_RESULTS: list = [] + + +def finish(path: Path, hook: str, results: list, passed: int) -> int: + LAST_RESULTS[:] = results + total = len(results) + print(f"Passed {passed}/{total}") + runs = ROOT / "runs" + runs.mkdir(exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + report = runs / f"{path.stem}-{stamp}.json" + report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total, + "results": results}, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report: {report.relative_to(ROOT)}") + return 0 if passed == total else 1 + + +# ---------------------------------------------------------------- gate / diff / release +def cmd_gate(args: argparse.Namespace) -> int: + """A release gate: run a test file and decide PASS/FAIL from the pass rate and p95 of the case times.""" + cmd_test(args) + results = LAST_RESULTS + total = len(results) + passed = sum(1 for item in results if item["passed"]) + times = [item["seconds"] for item in results if item.get("status") == 200] + p95 = percentile(times, 0.95) if times else 0.0 + rate = passed / total if total else 0.0 + reasons = [] + if rate < args.min_pass: + reasons.append(f"pass rate {rate:.0%} < {args.min_pass:.0%}") + if args.max_p95 is not None and p95 > args.max_p95: + reasons.append(f"p95 {p95:.1f}s > {args.max_p95}s") + sets = parse_sets(args.set) + label = ", ".join(f"{k}={v}" for k, v in sets.items()) or "as is" + print(f"gate [{label}]: pass {passed}/{total} ({rate:.0%}) · p95 {p95:.1f}s → " + + ("PASS" if not reasons else "FAIL · " + "; ".join(reasons))) + if sets.get("release") and not args.no_record: + record = {"release": str(sets["release"]), "verdict": "rollback" if reasons else "promote", "passed": passed, + "total": total, "p95": round(p95, 2), "cases": Path(args.cases).name, "source": "flow.py gate"} + status, payload, _ = http("POST", f"http://localhost:8800/gates/{args.name}", record, timeout=10) + print(f"{OK if status == 200 else WARN} gate record → /gates/{args.name}: {record['release']} {record['verdict']}" + + ("" if status == 200 else f" (not stored: HTTP {status})")) + detail(f"← response HTTP {status}", payload) + return 0 if not reasons else 1 + + +def flat(value: object, prefix: str = "") -> dict: + if isinstance(value, dict): + out: dict = {} + for key, item in value.items(): + out.update(flat(item, f"{prefix}.{key}" if prefix else str(key))) + return out + if isinstance(value, list): + out = {} + for index, item in enumerate(value): + out.update(flat(item, f"{prefix}[{index}]")) + return out or {prefix: []} + return {prefix: value} + + +def cmd_diff(args: argparse.Namespace) -> int: + """Node-by-node difference of two workflow files: added/removed nodes, changed settings, links.""" + old, new = load_json(resolve(args.old)), load_json(resolve(args.new)) + old_nodes = {node["name"]: node for node in old.get("nodes", [])} + new_nodes = {node["name"]: node for node in new.get("nodes", [])} + print(f"{old.get('name', args.old)} → {new.get('name', args.new)}") + changed = 0 + for name in sorted(set(new_nodes) - set(old_nodes)): + changed += 1 + print(f"+ node '{name}' ({new_nodes[name].get('type', '?').split('.')[-1]})") + for name in sorted(set(old_nodes) - set(new_nodes)): + changed += 1 + print(f"- node '{name}' ({old_nodes[name].get('type', '?').split('.')[-1]})") + skip = ("position", "id", "webhookId") + for name in sorted(set(old_nodes) & set(new_nodes)): + a = flat({k: v for k, v in old_nodes[name].items() if k not in skip}) + b = flat({k: v for k, v in new_nodes[name].items() if k not in skip}) + for key in sorted(set(a) | set(b)): + if a.get(key) == b.get(key): + continue + changed += 1 + before, after = as_text(a.get(key, "(none)")), as_text(b.get(key, "(none)")) + if len(before) > 70 or len(after) > 70: + print(f"~ '{name}' {key}: changed ({len(before)} → {len(after)} chars)") + if args.full or args.verbose: + print(f" before: {before}\n after: {after}") + else: + print(f"~ '{name}' {key}: {before} → {after}") + + def links(workflow: dict) -> set: + return {(src, kind, dst["node"]) for src, kinds in workflow.get("connections", {}).items() + for kind, outs in kinds.items() for out in outs for dst in out} + + for src, kind, dst in sorted(links(new) - links(old)): + changed += 1 + print(f"+ link {src} → {dst} ({kind})") + for src, kind, dst in sorted(links(old) - links(new)): + changed += 1 + print(f"- link {src} → {dst} ({kind})") + print(f"{changed} change(s)") + return 0 + + +def bucket(user: str) -> int: + """Same rule as AGENTS.md: sum of the character codes of the user name, modulo 100.""" + return sum(ord(ch) for ch in user) % 100 + + +def cmd_release(args: argparse.Namespace) -> int: + url = f"http://localhost:8800/releases/{args.name}" + if args.reset: + status, payload, _ = http("POST", url + "/reset", {}, timeout=10) + elif args.require_gate: + status, payload, _ = http("POST", url + "/policy", {"require_gate": args.require_gate == "on"}, timeout=10) + elif args.version or args.canary or args.no_canary: + body: dict = {} + if args.version: + body["active"] = args.version + if args.canary: + body["canary"] = {"release": args.canary, "percent": args.percent} + if args.no_canary: + body["canary"] = None + status, payload, _ = http("POST", url, body, timeout=10) + else: + status, payload, _ = http("GET", url, timeout=10) + detail(f"← response HTTP {status}", payload) + if status == 409 and isinstance(payload, dict): + gate = payload.get("last_gate") + print(f"{BAD} refused: {payload.get('release')} has no passing gate record" + + (f" (last gate: {gate.get('verdict')} {gate.get('passed')}/{gate.get('total')} · {gate.get('source')})" if gate else " (never gated)") + + "\n → run the gate (flow.py gate ... --set release=) or my-release-check first") + return 1 + if status != 200 or not isinstance(payload, dict): + print(f"{BAD} {as_text(payload)}") + return 1 + _, gates, _ = http("GET", f"http://localhost:8800/gates/{args.name}", timeout=10) + latest = {} + for record in (gates.get("gates", []) if isinstance(gates, dict) else []): + latest[record.get("release")] = record + canary = payload.get("canary") + print(f"{OK} {args.name}: active {payload['active']}" + + (f" · canary {canary['release']} {canary['percent']}%" if canary else " · no canary") + + (" · deploy needs a passing gate" if payload.get("require_gate") else "")) + for version, info in sorted(payload.get("releases", {}).items()): + mark = "*" if version == payload["active"] else ("c" if canary and version == canary["release"] else " ") + gate = latest.get(version) + seen = f" gate {gate['verdict']} {gate.get('passed')}/{gate.get('total')}" if gate else "" + print(f" {mark} {version} prompt {info.get('prompt_version')} model {info.get('model')} {info.get('note', '')}{seen}") + if canary: + names = ["이준호", "박서연", "최유진", "한지훈", "김민지", "정우성"] + print(" canary users: " + (", ".join(f"{n}({bucket(n)})" for n in names if bucket(n) < canary["percent"]) or "none")) + return 0 + + +# ---------------------------------------------------------------- bench / fault / llm +def percentile(values: list[float], share: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + # nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th) + index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1)) + return ordered[index] + + +# ---------------------------------------------------------------- bench / fault / llm +def percentile(values: list[float], share: float) -> float: + ordered = sorted(values) + if not ordered: + return 0.0 + # nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th) + index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1)) + return ordered[index] + + +def cmd_bench(args: argparse.Namespace) -> int: + body = request_body(args.request) if args.request else {} + if args.set and isinstance(body, dict): + body = {**body, **parse_sets(args.set)} + hook = args.path.strip("/") + print(f"POST {N8N_URL}/webhook/{hook} × {args.n}") + detail(f"→ request POST /webhook/{hook} (the same body every time)", body) + seconds_list, failures, rows = [], 0, [] + for run in range(1, args.n + 1): + status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body) + good = 200 <= status < 300 + failures += not good + seconds_list.append(seconds) + extra = "" + if isinstance(payload, dict): + extra = " · " + ", ".join(f"{key}={as_text(payload[key])[:40]}" for key in ("route", "llm_calls", "cached") if key in payload) + if isinstance(payload.get("version"), dict): + extra += f", release={payload['version'].get('release')}" + print(f"{OK if good else BAD} #{run} · HTTP {status} · {seconds:.2f}s{extra}") + detail(f"← response HTTP {status}", payload) + rows.append({"run": run, "status": status, "seconds": round(seconds, 3), "response": payload}) + p50, p95 = percentile(seconds_list, 0.50), percentile(seconds_list, 0.95) + print(f"p50 {p50:.2f}s · p95 {p95:.2f}s · max {max(seconds_list):.2f}s · failures {failures}/{args.n}") + if VERBOSE: + print(" sorted " + " ".join(f"{value:.2f}" for value in sorted(seconds_list))) + steps: dict[str, list[float]] = {} + for row in rows: + timings = row["response"].get("timings") if isinstance(row["response"], dict) else None + for key, value in (timings or {}).items(): + if isinstance(value, (int, float)): + steps.setdefault(key, []).append(value) + if steps: + print(" timings (average ms, runs) " + " · ".join(f"{key} {sum(values) / len(values):.0f} ({len(values)})" + for key, values in steps.items())) + runs = ROOT / "runs" + runs.mkdir(exist_ok=True) + report = runs / f"bench-{hook}-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json" + report.write_text(json.dumps({"webhook": hook, "request": body, "n": args.n, "p50": round(p50, 3), "p95": round(p95, 3), + "max": round(max(seconds_list), 3), "failures": failures, "runs": rows}, + ensure_ascii=False, indent=2), encoding="utf-8") + print(f"report: {report.relative_to(ROOT)}") + return 0 if not failures else 1 + + +# ---------------------------------------------------------------- ops (day 76) +def cmd_ops(args: argparse.Namespace) -> int: + base = "http://localhost:8800" + if args.reset: + for path in ("/tickets-reset", "/dlq-clear", "/alerts-clear"): + status, payload, seconds = http("POST", base + path, {}, timeout=10) + detail(f"→ POST {base + path} · HTTP {status} · {seconds:.2f}s", payload) + body = {target: "ok" for target in FAULT_TARGETS} + status, payload, _ = http("POST", base + "/admin/fault", body, timeout=10) + detail(f"→ POST {base}/admin/fault · HTTP {status}", payload) + print(f"{OK if status == 200 else BAD} created tickets, DLQ and alerts emptied, fault switch all ok") + return 0 if status == 200 else 1 + _, created, _ = http("GET", base + "/tickets-created", timeout=10) + _, dlq, _ = http("GET", base + "/dlq", timeout=10) + _, alerts, _ = http("GET", base + "/alerts", timeout=10) + _, fault, _ = http("GET", base + "/admin/fault", timeout=10) + if not all(isinstance(x, dict) for x in (created, dlq, alerts, fault)): + print(f"{BAD} data API did not answer: is it the day 76 version? (uv run tools/flow.py doctor)") + return 1 + on = {key: value for key, value in fault.items() if key in FAULT_TARGETS and value != "ok"} + print(f"fault switch: {'all ok' if not on else as_text(on)}") + print(f"created tickets: {created.get('count')}") + for item in created.get("tickets", [])[-5:]: + print(f" {item.get('ticket_id')} key {item.get('key')} {item.get('category')}/{item.get('priority')} {str(item.get('title'))[:30]}") + print(f"DLQ: {dlq.get('size')} (parked {dlq.get('parked', 0)})") + for item in dlq.get("items", [])[-5:]: + print(f" {item.get('dlq_id')} {(item.get('item') or {}).get('id')} attempts {item.get('attempts')} " + f"parked {item.get('parked')} {str(item.get('reason'))[:50]}") + print(f"alerts: {alerts.get('count')}") + for item in alerts.get("alerts", [])[-3:]: + extra = f" · count {item['count']}" if "count" in item else "" + print(f" [{item.get('severity')}] {item.get('workflow')} · {item.get('reason')} · {item.get('node', '-')}{extra} · {str(item.get('message'))[:60]}") + detail("← GET /dlq", dlq) + detail("← GET /alerts", alerts) + return 0 + + +# ---------------------------------------------------------------- usage / budget (day 75) +def cmd_usage(args: argparse.Namespace) -> int: + if args.clear: + status, payload, _ = http("POST", "http://localhost:8800/usage-clear", {}, timeout=10) + print(f"{OK if status == 200 else BAD} usage cleared: {as_text(payload)}") + return 0 if status == 200 else 1 + status, payload, seconds = http("GET", "http://localhost:8800/usage/summary", timeout=10) + detail(f"← GET /usage/summary · HTTP {status} · {seconds:.2f}s", payload) + if status != 200 or not isinstance(payload, dict): + print(f"{BAD} {as_text(payload)}") + return 1 + print(f"requests {payload['requests']} · calls {payload['calls']} · tokens in {payload['prompt_tokens']}" + f" / out {payload['completion_tokens']} · ${payload['cost_usd']:.6f}") + for title in ("by_model", "by_user", "by_route"): + for key, row in sorted(payload.get(title, {}).items(), key=lambda kv: -kv[1]["cost_usd"]): + print(f" {title[3:]:<6} {key:<14} {row['calls']:>4} calls · in {row['prompt_tokens']:>7}" + f" · out {row['completion_tokens']:>6} · ${row['cost_usd']:.6f}") + show_budget() + return 0 + + +def show_budget() -> dict: + status, b, seconds = http("GET", "http://localhost:8800/budget", timeout=10) + detail(f"← GET /budget · HTTP {status} · {seconds:.2f}s", b) + if isinstance(b, dict) and "daily_usd" in b: + print(f"budget ${b['daily_usd']} / used ${b['used_usd']:.6f} · remaining ${b['remaining_usd']:.6f} · over {b['over']}" + f" · per user {b.get('user_limit_per_min')}/min · question {b.get('max_question_chars')} chars") + return b + print(f"{BAD} budget: {as_text(b)}") + return {} + + +def cmd_budget(args: argparse.Namespace) -> int: + """Show or change the daily budget, the per-user limit and the question length limit (people decide these).""" + body = {key: value for key, value in (("daily_usd", args.daily), ("user_limit_per_min", args.per_min), + ("max_question_chars", args.chars)) if value is not None} + if args.reset or body: + target = "http://localhost:8800/budget/reset" if args.reset else "http://localhost:8800/budget" + status, payload, seconds = http("POST", target, body, timeout=10) + detail(f"→ request POST {target}", body) + detail(f"← response HTTP {status} · {seconds:.2f}s", payload) + if status != 200: + print(f"{BAD} {as_text(payload)}") + return 1 + print(f"{OK} " + ("budget back to data/budget.json" if args.reset else f"budget changed: {as_text(body)}")) + return 0 if show_budget() else 1 + + + + +def cmd_fault(args: argparse.Namespace) -> int: + targets = list(FAULT_TARGETS) if args.target == "all" else [args.target] + body: dict = {target: args.mode for target in targets} + if args.seconds is not None: + body["slow_seconds"] = args.seconds + status, payload, seconds = http("POST", "http://localhost:8800/admin/fault", body, timeout=10) + print(f"{OK if status == 200 else BAD} fault switch: {as_text(payload)}") + detail("→ request POST http://localhost:8800/admin/fault", body) + detail(f"← response HTTP {status} · {seconds:.2f}s", payload) + return 0 if status == 200 else 1 + + +def cmd_llm(args: argparse.Namespace) -> int: + """Outage drill: register the course OpenAI credential with a broken key (down) or the real key from .env (up).""" + script = ROOT.parent / "65" / "n8n" / "register-credentials.sh" + if not script.exists(): + die(f"{script} not found") + env = dict(os.environ, N8N_CONTAINER=CONTAINER) + if args.state == "down": + env["OPENAI_API_KEY"] = "sk-invalid-key-for-drill-000000" + else: + env.pop("OPENAI_API_KEY", None) + result = subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True) + detail(f"$ bash {script.relative_to(ROOT.parent)} → exit {result.returncode}", (result.stdout + result.stderr).strip()) + if result.returncode != 0: + print(result.stderr.strip() or result.stdout.strip()) + return 1 + print(f"{OK} OpenAI credential: " + ("broken key (drill). Restore with: uv run tools/flow.py llm up" if args.state == "down" + else "real key from .env restored")) + return 0 + + +# ---------------------------------------------------------------- doctor +def cmd_doctor(args: argparse.Namespace) -> int: + failures = 0 + + def line(good: bool, text: str, hint: str = "", seen: object = None) -> None: + nonlocal failures + failures += not good + print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}")) + if seen is not None: + detail("seen", seen) + + installed = (N8N_HOME / "compose.yml").exists() + print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else + "\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>")) + running = container_running() + line(running, f"n8n container {CONTAINER}", START_HINT) + status, payload, _ = http("GET", f"{N8N_URL}/healthz", timeout=5) + line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again", + {"GET": "/healthz", "status": status, "response": payload}) + status, payload, _ = http("GET", "http://localhost:8800/health", timeout=5) + if USES_DATA_API: + line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/76/data-api && docker compose up -d --force-recreate (start n8n first)", + {"GET": "/health", "status": status, "response": payload}) + fault_status, fault, _ = http("GET", "http://localhost:8800/admin/fault", timeout=5) + price_status, prices, _ = http("GET", "http://localhost:8800/prices", timeout=5) + budget_status, b, _ = http("GET", "http://localhost:8800/budget", timeout=5) + models = prices.get("models", {}) if isinstance(prices, dict) else {} + dlq_status, dlq, _ = http("GET", "http://localhost:8800/dlq", timeout=5) + line(price_status == 200 and budget_status == 200 and "gpt-5.4-nano" in models and isinstance(b, dict) + and "max_question_chars" in b and dlq_status == 200 and isinstance(dlq, dict) and "parked" in dlq + and isinstance(fault, dict) and "dlq" in fault, + "dead letter queue /dlq · alerts · fault switch dlq (day 76 data API)", + "the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/76/data-api && docker compose up -d --force-recreate && sleep 3", + {"GET": "/prices", "status": price_status, "response": prices, "budget": b, "dlq": dlq, "fault": fault}) + if isinstance(dlq, dict) and dlq.get("size"): + print(f"{WARN} DLQ is not empty ({dlq.get('size')} item(s), left from an earlier drill)\n → uv run tools/flow.py ops --reset") + if isinstance(b, dict) and "daily_usd" in b: + start = load_json(ROOT / "data-api" / "data" / "budget.json") + if any(b.get(key) != value for key, value in start.items()): + print(f"{WARN} budget is not the start state: {as_text({key: b.get(key) for key in start})}" + f"\n → uv run tools/flow.py budget --reset") + if fault_status == 200 and isinstance(fault, dict) and any(fault.get(key, "ok") != "ok" for key in FAULT_TARGETS): + print(f"{WARN} fault switch is on: {fault}\n → uv run tools/flow.py fault all ok") + else: + print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)") + if running: + probe = docker("exec", CONTAINER, "node", "-e", + "fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))", + check=False).stdout.strip() + if USES_DATA_API: + line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)", + {"from n8n": "GET http://data-api:8800/health", "status": probe}) + exported = docker("exec", CONTAINER, "sh", "-c", + "n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; " + "cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout + try: + found = json.loads(exported) if exported.strip() else [] + except json.JSONDecodeError: + found = [] + ids = {item.get("id") for item in found} + detail("credentials in n8n (id · name · type, no secrets)", + [f"{item.get('id')} · {item.get('name')} · {item.get('type')}" for item in found]) + for credential_id, name in COURSE_CREDENTIALS.items(): + line(credential_id in ids, f"credential '{name}' ({credential_id})", + "cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh") + catalog = node_versions() + line(bool(catalog), "node catalog (for check)", seen={"node types": len(catalog), "cache": str(CACHE.relative_to(ROOT))}) + for hook in AGENT_HOOKS: + _, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5) + line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}", + "uv run tools/flow.py push ", {"GET": f"/webhook/{hook}", "response": payload}) + agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)] + line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"), + "install one of codex / claude / agy (see Lecture/76/00-DAY76-PREP.md)", {name: shutil.which(name) for name in agents}) + print("ready" if not failures else f"{failures} item(s) to fix") + return 0 if not failures else 1 + + +def main() -> int: + global VERBOSE + common = argparse.ArgumentParser(add_help=False) + common.add_argument("-v", "--verbose", action="store_true", + help="details: request and response JSON, workflow nodes and links, docker commands") + parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("doctor", parents=[common], help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor) + item = commands.add_parser("check", parents=[common], help="find mistakes in a workflow file") + item.add_argument("file") + item.set_defaults(run=cmd_check) + item = commands.add_parser("push", parents=[common], help="import and publish workflow files (one n8n restart)") + item.add_argument("files", nargs="+") + item.set_defaults(run=cmd_push) + item = commands.add_parser("off", parents=[common], help="unpublish workflow files or webhook paths (a role that is down)") + item.add_argument("files", nargs="+") + item.set_defaults(run=cmd_off) + item = commands.add_parser("call", parents=[common], help="POST one request to a webhook path") + item.add_argument("path", help="webhook path, e.g. my-notice") + item.add_argument("request", nargs="?", help="request file or inline JSON") + item.set_defaults(run=cmd_call) + item = commands.add_parser("test", parents=[common], help="run a test case file") + item.add_argument("cases") + item.add_argument("--repeat", type=int, default=1, help="run every case N times") + item.add_argument("--webhook", help="override the webhook path in the case file") + item.add_argument("--no-before", action="store_true", help="skip the 'before' steps") + item.add_argument("--set", action="append", help="add key=value to every request body (value is JSON when it parses)") + item.set_defaults(run=cmd_test) + item = commands.add_parser("gate", parents=[common], help="release gate: run a test file, PASS/FAIL from pass rate and p95") + item.add_argument("cases") + item.add_argument("--repeat", type=int, default=1) + item.add_argument("--webhook") + item.add_argument("--no-before", action="store_true") + item.add_argument("--set", action="append", help="add key=value to every request body, e.g. release=1.1.0") + item.add_argument("--min-pass", type=float, default=1.0, help="share of cases that must pass (default 1.0)") + item.add_argument("--max-p95", type=float, help="seconds") + item.add_argument("--name", default="helpdesk", help="release name for the gate record (default helpdesk)") + item.add_argument("--no-record", action="store_true", help="do not store the verdict in /gates") + item.set_defaults(run=cmd_gate) + item = commands.add_parser("diff", parents=[common], help="compare two workflow files node by node") + item.add_argument("old") + item.add_argument("new") + item.add_argument("--full", action="store_true", help="print long values (prompts) before and after (-v does too)") + item.set_defaults(run=cmd_diff) + item = commands.add_parser("release", parents=[common], help="show or change the active release / canary in the data API") + item.add_argument("name") + item.add_argument("version", nargs="?") + item.add_argument("--canary") + item.add_argument("--percent", type=int, default=30) + item.add_argument("--no-canary", action="store_true") + item.add_argument("--reset", action="store_true", help="active 1.0.0, no canary, deploy policy off") + item.add_argument("--require-gate", choices=["on", "off"], help="deploy only releases whose last gate record is promote") + item.set_defaults(run=cmd_release) + item = commands.add_parser("bench", parents=[common], help="POST the same request N times: p50, p95, max, failures") + item.add_argument("path", help="webhook path, e.g. my-helpdesk") + item.add_argument("request", nargs="?", help="request file or inline JSON") + item.add_argument("-n", type=int, default=10, help="how many calls (default 10)") + item.add_argument("--set", action="append", help="add key=value to the request body, e.g. release=2.0.0") + item.set_defaults(run=cmd_bench) + item = commands.add_parser("usage", parents=[common], help="recorded LLM calls, tokens and cost by model / user / route, and the budget") + item.add_argument("--clear", action="store_true", help="empty the usage records (the budget counts from 0 again)") + item.set_defaults(run=cmd_usage) + item = commands.add_parser("budget", parents=[common], help="show or change the daily budget and the limits") + item.add_argument("--daily", type=float, help="daily budget in USD, e.g. 0.001") + item.add_argument("--per-min", type=int, help="requests per user per minute, e.g. 2") + item.add_argument("--chars", type=int, help="longest question sent as it is, e.g. 500") + item.add_argument("--reset", action="store_true", help="back to data/budget.json") + item.set_defaults(run=cmd_budget) + item = commands.add_parser("ops", parents=[common], help="created tickets, DLQ, alerts and the fault switch at a glance (day 76)") + item.add_argument("--reset", action="store_true", help="empty created tickets, DLQ and alerts, fault switch all ok") + item.set_defaults(run=cmd_ops) + item = commands.add_parser("fault", parents=[common], help="data API fault switch for drills") + item.add_argument("target", choices=[*FAULT_TARGETS, "all"]) + item.add_argument("mode", choices=["ok", "slow", "error", "flaky"]) + item.add_argument("--seconds", type=float, help="how long 'slow' waits (default 8)") + item.set_defaults(run=cmd_fault) + item = commands.add_parser("llm", parents=[common], help="outage drill: break (down) or restore (up) the OpenAI credential in n8n") + item.add_argument("state", choices=["down", "up"]) + item.set_defaults(run=cmd_llm) + args = parser.parse_args() + VERBOSE = args.verbose + return args.run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Last3Mon/76/tretry/.gitignore b/Last3Mon/76/tretry/.gitignore new file mode 100644 index 0000000..17cd2fc --- /dev/null +++ b/Last3Mon/76/tretry/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Last3Mon/76/tretry/Doc/IMP-PLAN.md b/Last3Mon/76/tretry/Doc/IMP-PLAN.md new file mode 100644 index 0000000..f9d7c93 --- /dev/null +++ b/Last3Mon/76/tretry/Doc/IMP-PLAN.md @@ -0,0 +1,33 @@ +# tretry IMP-PLAN + +## 엔진 1단계 · 다시 하기 · 대체 · 종료 코드 (R1 · R2 · R3 · R4 · R12 · R13) +- [x] retry.py: `Policy(tries, backoff, max_wait, jitter, deadline)`, `wait(i) = min(backoff × 2^(i-1), max_wait) × (1 - jitter × 무작위)` +- [x] `run_with_retry(attempt, policy, fallback, per_try_timeout, sleep, clock, rand)` → `Result(ok, output, attempts, stopped, used_fallback, seconds)` +- [x] `Outcome(ok, retryable, error, output, status, retry_after)`: 다시 할 수 있나는 실행기가 정함 +- [x] runners.command(argv): 종료 코드 ≠ 0 · 시간 초과 → 다시, 명령 없음 → 다시 안 함 +- [x] cli: `tretry [옵션] -- <명령>`, 종료 코드 0 · 1 · 2 · 3, `--json` +- [x] tests: 가짜 시계 [0.5, 1.0], 상한 1 · 2 · 4 · 4, jitter, 대체, 다시 못 함은 1번 · 대체 안 함 +- 확인: `uv run pytest -q`, `uv run tretry --tries 3 --backoff 0.2 -- false; echo $?` + +## 엔진 2단계 · HTTP · 멱등 키 · 기록 (R5 · R6 · R8) +- [x] runners.http(method, url, body, headers, idem_key, body_key): 2xx 성공 · 5xx · 408 · 425 · 429 · 시간 초과 · 연결 실패 다시 · 그 밖 4xx 다시 안 함, `Retry-After` +- [x] `--idem-key auto|값`: 실행에 한 번 만들어 헤더 `Idempotency-Key`, `--body-key`면 본문에도 +- [x] log.py: JSON Lines `~/.tretry/log.jsonl` (`--log`, `$TRETRY_LOG`), 시도마다 한 줄, 키 · 토큰은 `***` +- [x] tests: 가짜 서버 500 → 201 · 400 · 느림, 같은 키 3번, 기록에 `secret123` · `sk-` 없음 +- 확인: 데이터 API `flaky`에서 `tretry http POST .../tickets --idem-key x --body-key key` + +## TUI 1단계 · 기록 보기 (R11) +- [x] app.py `LogApp`: 왼쪽 대상 순위(실패한 시도 많은 순), 고르면 오른쪽에 그 대상의 시도, `[f]` 실패만, `[r]` 다시 읽기 +- [x] 엔진(`log.stats` · `log.runs`)을 불러 쓰기만 +- [x] tests: `run_test`로 두 표 · `down` · `f` +- 확인: `uv run tretry log` + +## 설치 (R14) +- [x] `[project.scripts] tretry = "tretry.cli:main"` +- [x] `uv tool install --editable .`, `/tmp`에서 `tretry --help` + +## 엔진 3단계 · 마감 · 순위 · 회로 차단 (R7 · R9 · R10) +- [x] `--deadline`: 시도 전 남은 시간, 마감을 넘는 기다림은 하지 않음, 마지막 시도의 시간 제한을 남은 시간으로 +- [x] `tretry stats [--json]`: 대상마다 runs · 성공률 · 실행당 시도 · 실패한 시도 · 대체 · p95 +- [x] `--breaker N`(기본 5, 0 끔): 최근 60초 그 대상의 시도(대체 제외) N개가 모두 실패면 바로 대체, 대체가 없으면 멈춤 +- [x] tests: 3초 시도 · 마감 7 → 2번, 마감이 시간 제한을 줄임, 실패 5번 뒤 바로 대체, stats 숫자 diff --git a/Last3Mon/76/tretry/README.md b/Last3Mon/76/tretry/README.md new file mode 100644 index 0000000..61abe9d --- /dev/null +++ b/Last3Mon/76/tretry/README.md @@ -0,0 +1,37 @@ +# tretry · 강사가 수업 준비 때 직접 만든 본 (2026-10-29) + +> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TRETRY.md](../VIBE-A-TRETRY.md)와 함께 올립니다. +> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-Q의 요구 R1~R14와 [Doc/IMP-PLAN.md](Doc/IMP-PLAN.md)를 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구의 **확인 방법**이 같으면 됩니다. + +## 설치 · 실행 + +```bash +cd ~/aio-01d4-stu/Last3Mon/76/tretry +uv sync && uv run pytest -q # 테스트 17개 (기다리지 않음, 가짜 서버) +uv tool install --editable . # 어느 폴더에서나 tretry (R14) +cd /tmp && tretry --help +``` + +| 하고 싶은 것 | 명령 | +| --- | --- | +| 명령을 다시 해 보기 | `tretry --tries 4 --backoff 0.5 --max-wait 8 --jitter 0.2 --timeout 10 -- <명령 ...>` | +| 다 실패하면 대신 | `--fallback "cat ~/cache/faq.json"` | +| 전체 마감 | `--deadline 5` | +| HTTP 호출 | `tretry http POST http://localhost:8800/tickets --body '{"title": "t"}' --idem-key auto --body-key key` | +| 순위 | `tretry stats` (`--json`) | +| 화면(TUI) | `tretry log` (`[f]` 실패만, `[r]` 다시 읽기, `[q]` 끝) | +| 다른 프로그램이 읽게 | `--json` | + +- 종료 코드: 0 됨(대체 포함) · 1 다 실패 · 2 쓰는 법이 틀림 · 3 고칠 수 없는 실패 (HTTP 4xx, 408 · 425 · 429 빼고). +- 기록: `~/.tretry/log.jsonl` (`--log`, `$TRETRY_LOG`). 헤더 값은 남기지 않고 URL · 오류의 `sk-…` · `Bearer …` · `api_key=…`는 `***`. +- 회로 차단: 같은 대상의 최근 60초 시도(대체 제외) 5개가 모두 실패면 시도 없이 바로 대체 (`--breaker 0`은 끔). 대상은 `METHOD URL` 또는 명령 줄. + +## 파일 + +| 파일 | 하는 일 | 요구 | +| --- | --- | --- | +| `src/tretry/retry.py` | 다시 하기 엔진 (`Policy` · `run_with_retry`), sleep · clock · 무작위를 바꿔 끼움 | R1 · R2 · R4 · R7 · R13 | +| `src/tretry/runners.py` | 한 번 해 보기: 명령(종료 코드 · 시간 초과) · HTTP(상태 코드 · `Retry-After` · 멱등 키) | R1 · R3 · R5 · R6 | +| `src/tretry/log.py` | JSON Lines 기록 · 키 지우기 · 순위 · 회로 차단 | R8 · R9 · R10 | +| `src/tretry/cli.py` | 명령 · `--json` · 종료 코드 | R12 | +| `src/tretry/app.py` | Textual 화면 | R11 | diff --git a/Last3Mon/76/tretry/pyproject.toml b/Last3Mon/76/tretry/pyproject.toml new file mode 100644 index 0000000..5f2e739 --- /dev/null +++ b/Last3Mon/76/tretry/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "tretry" +version = "0.1.0" +description = "Run a command or an HTTP call with timeout, retry (backoff), a total deadline and a fallback; keep a log and show it" +requires-python = ">=3.11" +dependencies = ["textual>=0.80"] + +[project.scripts] +tretry = "tretry.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/tretry"] + +[dependency-groups] +dev = ["pytest>=8", "pytest-asyncio>=0.23"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/Last3Mon/76/tretry/src/tretry/__init__.py b/Last3Mon/76/tretry/src/tretry/__init__.py new file mode 100644 index 0000000..73e5092 --- /dev/null +++ b/Last3Mon/76/tretry/src/tretry/__init__.py @@ -0,0 +1 @@ +"""tretry: retry, backoff, deadline, fallback and a log for any command or HTTP call.""" diff --git a/Last3Mon/76/tretry/src/tretry/app.py b/Last3Mon/76/tretry/src/tretry/app.py new file mode 100644 index 0000000..6da2431 --- /dev/null +++ b/Last3Mon/76/tretry/src/tretry/app.py @@ -0,0 +1,72 @@ +"""Textual screen (R11): targets ranked by failed tries on the left, the runs of the chosen target on the right.""" +from __future__ import annotations + +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import DataTable, Footer, Header, Static + +from . import log + + +class LogApp(App): + TITLE = "tretry" + BINDINGS = [("r", "reload", "Reload"), ("f", "failed_only", "Failed only"), ("q", "quit", "Quit")] + CSS = "#targets { width: 55%; } #tries { width: 45%; } #note { height: auto; margin: 0 1; }" + + def __init__(self, path: Path): + super().__init__() + self.path = Path(path) + self.rows: list[dict] = [] + self.failed_only = False + self.chosen: str | None = None + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(): + yield DataTable(id="targets", cursor_type="row") + yield DataTable(id="tries", cursor_type="row") + yield Static("", id="note", markup=False) + yield Footer() + + def on_mount(self): + self.sub_title = str(self.path) + self.action_reload() + + def action_reload(self): + self.rows = log.read(self.path) + table = self.query_one("#targets", DataTable) + table.clear(columns=True) + table.add_columns("target", "runs", "ok%", "tries/run", "failed", "fallback", "p95 s") + for r in log.stats(self.rows): + table.add_row(r["target"][:60], r["runs"], f"{r['success_rate'] * 100:.0f}%", r["attempts_per_run"], + r["failed_attempts"], r["fallbacks"], f"{r['p95_seconds']:.2f}", key=r["target"]) + total = len(log.runs(self.rows)) + self.query_one("#note", Static).update(f"{total} run(s) · {len(self.rows)} tr(ies) in {self.path} · " + "[enter] show tries · [f] failed only · [r] reload") + if self.chosen is None and table.row_count: + self.chosen = str(table.coordinate_to_cell_key((0, 0)).row_key.value) + self.show_tries() + + def action_failed_only(self): + self.failed_only = not self.failed_only + self.show_tries() + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted): + if event.data_table.id == "targets" and event.row_key is not None: + self.chosen = str(event.row_key.value) + self.show_tries() + + def show_tries(self): + table = self.query_one("#tries", DataTable) + table.clear(columns=True) + table.add_columns("run", "try", "at +s", "result", "error", "s") + for number, group in enumerate(log.runs(self.rows), 1): + if group[-1]["target"] != self.chosen: + continue + for r in group: + if self.failed_only and r["ok"]: + continue + table.add_row(number, "fallback" if r.get("fallback") else r["attempt"], f"{r['started']:.2f}", + "✔" if r["ok"] else "✘", (r.get("error") or "")[:40], f"{r['seconds']:.2f}") diff --git a/Last3Mon/76/tretry/src/tretry/cli.py b/Last3Mon/76/tretry/src/tretry/cli.py new file mode 100644 index 0000000..cb1069f --- /dev/null +++ b/Last3Mon/76/tretry/src/tretry/cli.py @@ -0,0 +1,166 @@ +"""Command line. Exit codes: 0 worked (maybe by the fallback) · 1 all tries and the fallback failed · +2 usage error · 3 not retryable (e.g. HTTP 400: the request itself is wrong).""" +from __future__ import annotations + +import argparse +import json +import shlex +import sys +import uuid +from pathlib import Path + +from . import log, runners +from .retry import Policy, Result, run_with_retry + +SUBCOMMANDS = ("http", "stats", "log") + + +def options(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--tries", type=int, default=3, help="how many tries (default 3)") + parser.add_argument("--backoff", type=float, default=0.5, help="wait before try 2, doubled each time (default 0.5s)") + parser.add_argument("--max-wait", type=float, default=8.0, help="longest single wait (default 8s)") + parser.add_argument("--jitter", type=float, default=0.0, help="make each wait up to this share shorter, e.g. 0.2") + parser.add_argument("--timeout", type=float, help="seconds for one try") + parser.add_argument("--deadline", type=float, help="seconds for all tries together") + parser.add_argument("--fallback", help='shell command to run once when every try failed, e.g. "cat cached.json"') + parser.add_argument("--breaker", type=int, default=5, + help="skip straight to the fallback when the last N tries at this target in 60s all failed (0 = off)") + parser.add_argument("--log", type=Path, help="log file (default $TRETRY_LOG or ~/.tretry/log.jsonl)") + parser.add_argument("--json", action="store_true", help="print the result as JSON for other programs") + parser.add_argument("-q", "--quiet", action="store_true", help="print only the output of the command that worked") + + +def policy(args) -> Policy: + if args.tries < 1 or args.backoff < 0 or args.max_wait < 0 or not 0 <= args.jitter < 1: + raise SystemExit(usage_error("--tries must be ≥ 1, --backoff/--max-wait ≥ 0, --jitter in [0, 1)")) + return Policy(args.tries, args.backoff, args.max_wait, args.jitter, args.deadline) + + +def usage_error(message: str) -> int: + print(f"tretry: {message}", file=sys.stderr) + return 2 + + +def report(args, kind: str, target: str, result: Result, path: Path) -> int: + log.write(path, kind, target, result) + code = 0 if result.ok else (3 if result.stopped == "not-retryable" else 1) + if args.json: + print(json.dumps({"ok": result.ok, "exit_code": code, "stopped": result.stopped, "used_fallback": result.used_fallback, + "seconds": result.seconds, "attempts": [a.__dict__ for a in result.attempts], "output": result.output}, + ensure_ascii=False, indent=2)) + return code + if not args.quiet: + for a in result.attempts: + mark = "✔" if a.ok else "✘" + label = "fallback" if a.fallback else f"#{a.attempt}" + wait = f" (waited {a.waited:.2f}s)" if a.waited else "" + print(f"{label:<9}{a.started:>7.2f}s {mark} {a.error or 'ok'} · {a.seconds:.2f}s{wait}", file=sys.stderr) + how = "by the fallback" if result.ok and result.used_fallback else ("" if result.ok else f"({result.stopped})") + tries = sum(1 for a in result.attempts if not a.fallback) + state = " ".join(part for part in ("ok" if result.ok else "FAILED", how) if part) + print(f"result: {state} · {tries} tr{'y' if tries == 1 else 'ies'} · {result.seconds:.2f}s" + + (" · breaker open: tries skipped" if result.stopped == "breaker" else ""), file=sys.stderr) + if result.output: + sys.stdout.write(result.output if result.output.endswith("\n") else result.output + "\n") + return code + + +def run(args, kind: str, target: str, attempt) -> int: + path = args.log or log.default_path() + skip = args.breaker > 0 and log.breaker_open(log.read(path), target, failures=args.breaker) + if skip and not args.fallback: + print(f"tretry: breaker open for {log.clean(target)} (last {args.breaker} tries failed); no --fallback, so stopping", + file=sys.stderr) + fallback = runners.command(args.fallback, shell=True) if args.fallback else None + result = run_with_retry(attempt, policy(args), fallback=fallback, per_try_timeout=args.timeout, skip_tries=skip) + return report(args, kind, target, result, path) + + +def main_command(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="tretry", usage="tretry [options] -- ", + description="Run a command with timeout, retry (backoff), deadline and fallback.", + epilog="also: tretry http METHOD URL · tretry stats · tretry log (screen)") + options(parser) + if "--" not in argv: + if "-h" in argv or "--help" in argv: + parser.print_help() + return 0 + return usage_error("put the command after --, e.g. tretry --tries 3 -- curl -sf http://localhost:8800/health") + split = argv.index("--") + args = parser.parse_args(argv[:split]) + command = argv[split + 1:] + if not command: + return usage_error("no command after --") + return run(args, "command", shlex.join(command), runners.command(command)) + + +def main_http(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="tretry http", description="HTTP call: 5xx, 408/425/429, timeout and connection errors are " + "tried again; other 4xx are not.") + parser.add_argument("method") + parser.add_argument("url") + parser.add_argument("--body", help="JSON body, or @file.json") + parser.add_argument("--header", action="append", default=[], help="K: V (values are never logged)") + parser.add_argument("--idem-key", help="idempotency key sent with every try (header Idempotency-Key); 'auto' makes one") + parser.add_argument("--body-key", help="also put the idempotency key in this JSON body field if it is missing, e.g. key") + options(parser) + args = parser.parse_args(argv) + body = args.body + if body and body.startswith("@"): + body = Path(body[1:]).read_text(encoding="utf-8") + if body: + try: + json.loads(body) + except json.JSONDecodeError as error: + return usage_error(f"--body is not JSON: {error}") + headers = {} + for item in args.header: + name, _, value = item.partition(":") + headers[name.strip()] = value.strip() + key = uuid.uuid4().hex if args.idem_key == "auto" else args.idem_key + if args.body_key and not key: + return usage_error("--body-key needs --idem-key") + target = f"{args.method.upper()} {args.url}" + return run(args, "http", target, runners.http(args.method, args.url, body, headers, key, args.body_key)) + + +def main_stats(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="tretry stats", description="Per target: runs, success rate, tries per run, p95 time.") + parser.add_argument("--log", type=Path) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + rows = log.stats(log.read(args.log or log.default_path())) + if args.json: + print(json.dumps(rows, ensure_ascii=False, indent=2)) + return 0 + if not rows: + print("no log yet") + return 0 + print(f"{'target':<48} {'runs':>4} {'ok%':>5} {'tries/run':>9} {'failed':>6} {'fallback':>8} {'p95 s':>7}") + for r in rows: + print(f"{r['target'][:48]:<48} {r['runs']:>4} {r['success_rate'] * 100:>4.0f}% {r['attempts_per_run']:>9} " + f"{r['failed_attempts']:>6} {r['fallbacks']:>8} {r['p95_seconds']:>7.2f}") + return 0 + + +def main_log(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="tretry log", description="Screen: targets by failures, and the tries of one target.") + parser.add_argument("--log", type=Path) + args = parser.parse_args(argv) + from .app import LogApp + LogApp(args.log or log.default_path()).run() + return 0 + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + try: + if argv and argv[0] in SUBCOMMANDS: + return {"http": main_http, "stats": main_stats, "log": main_log}[argv[0]](argv[1:]) + return main_command(argv) + except SystemExit as stop: + return stop.code if isinstance(stop.code, int) else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Last3Mon/76/tretry/src/tretry/log.py b/Last3Mon/76/tretry/src/tretry/log.py new file mode 100644 index 0000000..9c501bf --- /dev/null +++ b/Last3Mon/76/tretry/src/tretry/log.py @@ -0,0 +1,95 @@ +"""The attempt log as JSON Lines (R7), stats from it (R9) and the circuit breaker that reads it (R10).""" +from __future__ import annotations + +import json +import os +import re +import time +from dataclasses import asdict +from pathlib import Path + +from .retry import Result + +SECRET = re.compile(r"(sk-[A-Za-z0-9_-]{8,}|Bearer\s+\S+|(api[_-]?key|token|password)=[^&\s]+)", re.I) + + +def default_path() -> Path: + return Path(os.environ.get("TRETRY_LOG") or Path.home() / ".tretry" / "log.jsonl") + + +def clean(text: str) -> str: + """Never keep a key or a token in the log, even when it is part of a URL or an error message.""" + return SECRET.sub("***", text) + + +def write(path: Path, kind: str, target: str, result: Result, now: float | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + at = now if now is not None else time.time() + with path.open("a", encoding="utf-8") as out: + first = at - result.seconds # wall clock time of the first try + for item in result.attempts: + row = {"at": round(first + item.started, 3), "kind": kind, "target": clean(target), **asdict(item)} + row["error"] = clean(row["error"]) + row["final"] = item is result.attempts[-1] + row["result"] = "ok" if result.ok else "failed" + row["stopped"] = result.stopped + out.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def read(path: Path) -> list[dict]: + if not path.exists(): + return [] + rows = [] + for line in path.read_text(encoding="utf-8").splitlines(): + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue # a half-written line from a killed run + return rows + + +def runs(rows: list[dict]) -> list[list[dict]]: + """Group attempt rows into runs (one tretry call = the rows up to and including its final row).""" + groups, current = [], [] + for row in rows: + current.append(row) + if row.get("final"): + groups.append(current) + current = [] + return groups + + +def p95(values: list[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + return ordered[min(len(ordered) - 1, round(0.95 * (len(ordered) - 1)))] + + +def stats(rows: list[dict]) -> list[dict]: + """Per target: runs, success rate, attempts per run, failed attempts, fallbacks, p95 run time. Most failures first.""" + table: dict[str, dict] = {} + for group in runs(rows): + target = group[-1]["target"] + row = table.setdefault(target, {"target": target, "kind": group[-1]["kind"], "runs": 0, "ok": 0, "attempts": 0, + "failed_attempts": 0, "fallbacks": 0, "times": []}) + row["runs"] += 1 + row["ok"] += group[-1]["result"] == "ok" + row["attempts"] += len(group) + row["failed_attempts"] += sum(1 for r in group if not r["ok"]) + row["fallbacks"] += any(r.get("fallback") for r in group) + row["times"].append(group[-1]["started"] + group[-1]["seconds"]) + out = [] + for row in table.values(): + times = row.pop("times") + out.append({**row, "success_rate": round(row["ok"] / row["runs"], 3), "attempts_per_run": round(row["attempts"] / row["runs"], 2), + "p95_seconds": round(p95(times), 3)}) + return sorted(out, key=lambda r: (-r["failed_attempts"], r["target"])) + + +def breaker_open(rows: list[dict], target: str, failures: int = 5, window: float = 60.0, now: float | None = None) -> bool: + """Open when the last `failures` attempts at this target (not fallbacks) all failed within `window` seconds.""" + now = now if now is not None else time.time() + recent = [r for r in rows if r.get("target") == clean(target) and not r.get("fallback") and now - r["at"] <= window] + last = recent[-failures:] + return len(last) >= failures and not any(r["ok"] for r in last) diff --git a/Last3Mon/76/tretry/src/tretry/retry.py b/Last3Mon/76/tretry/src/tretry/retry.py new file mode 100644 index 0000000..79af77a --- /dev/null +++ b/Last3Mon/76/tretry/src/tretry/retry.py @@ -0,0 +1,114 @@ +"""The retry engine (R1 · R2 · R6 · R13). Knows nothing about commands or HTTP: it calls attempt() and reads its Outcome. + +sleep, clock and rand are arguments so tests never wait and always get the same numbers.""" +from __future__ import annotations + +import random +import time +from dataclasses import dataclass, field +from typing import Callable + + +@dataclass +class Outcome: + """What one attempt gave. retryable=False means trying again cannot help (e.g. HTTP 400).""" + ok: bool + retryable: bool = True + error: str = "" + output: str = "" + status: int | None = None + retry_after: float | None = None # seconds the server asked us to wait (HTTP 429 / 503 Retry-After) + + +@dataclass +class Policy: + tries: int = 3 + backoff: float = 0.5 # wait before try 2; doubles each time + max_wait: float = 8.0 # one wait is never longer than this + jitter: float = 0.0 # 0.2 = each wait is randomly 0..20% shorter, so many clients do not retry at the same moment + deadline: float | None = None # seconds for all tries together; no new try that could not finish in time + + def wait(self, attempt: int, rand: Callable[[], float] = random.random) -> float: + """Wait after attempt number `attempt` (1-based) and before the next one.""" + base = min(self.backoff * 2 ** (attempt - 1), self.max_wait) + return round(base * (1 - self.jitter * rand()), 3) + + +@dataclass +class Attempt: + attempt: int + started: float # seconds since the first try started + seconds: float + ok: bool + error: str = "" + status: int | None = None + waited: float = 0.0 # wait before this attempt + fallback: bool = False + + +@dataclass +class Result: + ok: bool + output: str = "" + attempts: list[Attempt] = field(default_factory=list) + stopped: str = "" # why it stopped: success · tries · not-retryable · deadline · breaker + used_fallback: bool = False + seconds: float = 0.0 + + +def run_with_retry(attempt: Callable[[float | None], Outcome], policy: Policy, *, + fallback: Callable[[float | None], Outcome] | None = None, + per_try_timeout: float | None = None, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, + rand: Callable[[], float] = random.random, + skip_tries: bool = False) -> Result: + """Call attempt(timeout) until it works, up to policy.tries. Then fallback once, if given. + + skip_tries=True goes straight to the fallback (the circuit breaker is open).""" + start = clock() + result = Result(ok=False) + waited = 0.0 + number = 0 + while not skip_tries and number < policy.tries: + number += 1 + timeout = per_try_timeout + if policy.deadline is not None: + left = policy.deadline - (clock() - start) + if left <= 0: + result.stopped = "deadline" + break + timeout = min(timeout, left) if timeout else left + began = clock() + outcome = attempt(timeout) + result.attempts.append(Attempt(number, round(began - start, 3), round(clock() - began, 3), outcome.ok, + outcome.error, outcome.status, waited)) + if outcome.ok: + result.ok, result.output, result.stopped = True, outcome.output, "success" + break + if not outcome.retryable: + result.output, result.stopped = outcome.output, "not-retryable" + break + if number == policy.tries: + result.stopped = "tries" + break + waited = policy.wait(number, rand) + if outcome.retry_after is not None: + waited = min(max(waited, outcome.retry_after), policy.max_wait) + if policy.deadline is not None and clock() - start + waited >= policy.deadline: + result.stopped = "deadline" + break + sleep(waited) + if skip_tries: + result.stopped = "breaker" + # a request that cannot be right (4xx) gets no fallback: the fallback would hide the caller's mistake + if not result.ok and fallback is not None and result.stopped != "not-retryable": + began = clock() + outcome = fallback(per_try_timeout) + result.attempts.append(Attempt(len(result.attempts) + 1, round(began - start, 3), round(clock() - began, 3), + outcome.ok, outcome.error, outcome.status, 0.0, fallback=True)) + result.used_fallback = True + if outcome.ok: + result.ok, result.output = True, outcome.output + result.seconds = round(clock() - start, 3) + return result diff --git a/Last3Mon/76/tretry/src/tretry/runners.py b/Last3Mon/76/tretry/src/tretry/runners.py new file mode 100644 index 0000000..bffe739 --- /dev/null +++ b/Last3Mon/76/tretry/src/tretry/runners.py @@ -0,0 +1,71 @@ +"""One attempt of a command or of an HTTP call, as an Outcome (R1 · R3 · R4 · R5).""" +from __future__ import annotations + +import json +import socket +import subprocess +import urllib.error +import urllib.request + +from .retry import Outcome + +# 408 timeout · 425 too early · 429 too many requests: waiting can help. Other 4xx: the request itself is wrong. +RETRY_4XX = {408, 425, 429} + + +def command(argv: list[str] | str, shell: bool = False): + """attempt(timeout) for a command: exit code 0 is success; a timeout is retryable.""" + def attempt(timeout: float | None) -> Outcome: + try: + done = subprocess.run(argv, shell=shell, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return Outcome(False, True, f"timeout {timeout:.1f}s") + except FileNotFoundError as error: + return Outcome(False, False, f"not found: {error.filename}") + if done.returncode == 0: + return Outcome(True, output=done.stdout) + tail = (done.stderr or done.stdout).strip().splitlines()[-1:] or [""] + return Outcome(False, True, f"exit {done.returncode} {tail[0][:120]}".strip(), output=done.stdout) + return attempt + + +def retry_after(value: str | None) -> float | None: + try: + return max(0.0, float(value)) if value else None + except ValueError: + return None # an HTTP date: ignore, use our own backoff + + +def http(method: str, url: str, body: str | None = None, headers: dict[str, str] | None = None, + idem_key: str | None = None, body_key: str | None = None): + """attempt(timeout) for an HTTP call: 2xx success · 5xx, 408/425/429, timeout, connection error retryable · + other 4xx not retryable. The same idempotency key goes with every try (header, and the body field if asked).""" + data = None + send = dict(headers or {}) + if body is not None: + if body_key and idem_key: + parsed = json.loads(body) + parsed.setdefault(body_key, idem_key) + body = json.dumps(parsed, ensure_ascii=False) + data = body.encode("utf-8") + send.setdefault("Content-Type", "application/json") + if idem_key: + send["Idempotency-Key"] = idem_key + + def attempt(timeout: float | None) -> Outcome: + request = urllib.request.Request(url, data=data, method=method.upper(), headers=send) + try: + with urllib.request.urlopen(request, timeout=timeout or 30) as response: + return Outcome(True, output=response.read().decode("utf-8", "replace"), status=response.status) + except urllib.error.HTTPError as error: + text = error.read().decode("utf-8", "replace") + retryable = error.code >= 500 or error.code in RETRY_4XX + return Outcome(False, retryable, f"HTTP {error.code} {text[:120]}".strip(), output=text, status=error.code, + retry_after=retry_after(error.headers.get("Retry-After"))) + except (TimeoutError, socket.timeout): + return Outcome(False, True, f"timeout {timeout or 30:.1f}s") + except urllib.error.URLError as error: + if isinstance(error.reason, (TimeoutError, socket.timeout)): + return Outcome(False, True, f"timeout {timeout or 30:.1f}s") + return Outcome(False, True, f"connection: {error.reason}") + return attempt diff --git a/Last3Mon/76/tretry/tests/conftest.py b/Last3Mon/76/tretry/tests/conftest.py new file mode 100644 index 0000000..11a96df --- /dev/null +++ b/Last3Mon/76/tretry/tests/conftest.py @@ -0,0 +1,60 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + + +class FakeClock: + """sleep() only moves the clock, so a test never waits and the waits can be checked.""" + def __init__(self): + self.now = 0.0 + self.slept: list[float] = [] + + def sleep(self, seconds: float): + self.slept.append(seconds) + self.now += seconds + + def __call__(self): + return self.now + + +@pytest.fixture +def clock(): + return FakeClock() + + +@pytest.fixture +def server(): + """A local server whose answers come from a script: path -> list of (status, body, headers), one per call.""" + script: dict[str, list] = {} + seen: list[dict] = [] + + class Handler(BaseHTTPRequestHandler): + def answer(self): + length = int(self.headers.get("Content-Length") or 0) + seen.append({"path": self.path, "method": self.command, "headers": dict(self.headers), + "body": self.rfile.read(length).decode() if length else ""}) + queue = script.get(self.path) or [(200, {"ok": True}, {})] + status, body, headers = queue.pop(0) if len(queue) > 1 else queue[0] + if status == "sleep": + import time + time.sleep(body) + status, body, headers = 200, {"late": True}, {} + data = json.dumps(body).encode() + self.send_response(status) + for key, value in headers.items(): + self.send_header(key, value) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + do_GET = do_POST = answer + + def log_message(self, *args): + pass + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=httpd.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{httpd.server_port}", script, seen + httpd.shutdown() diff --git a/Last3Mon/76/tretry/tests/test_app.py b/Last3Mon/76/tretry/tests/test_app.py new file mode 100644 index 0000000..0b79ea2 --- /dev/null +++ b/Last3Mon/76/tretry/tests/test_app.py @@ -0,0 +1,29 @@ +"""R11: the screen ranks targets by failed tries and lists the tries of the chosen one.""" +from textual.widgets import DataTable + +from tretry import log +from tretry.app import LogApp +from tretry.retry import Attempt, Result + + +def make_log(tmp_path): + path = tmp_path / "log.jsonl" + log.write(path, "http", "POST http://x/tickets", Result(False, attempts=[Attempt(1, 0, 3, False, "timeout"), Attempt(2, 3.5, 3, False, "timeout")], + stopped="tries", seconds=6.5), now=100) + log.write(path, "command", "curl ok", Result(True, attempts=[Attempt(1, 0, 0.1, True)], stopped="success", seconds=0.1), now=101) + return path + + +async def test_tables(tmp_path): + app = LogApp(make_log(tmp_path)) + async with app.run_test() as pilot: + await pilot.pause() + targets = app.query_one("#targets", DataTable) + assert targets.row_count == 2 and targets.get_row_at(0)[0] == "POST http://x/tickets" # most failures first + assert app.query_one("#tries", DataTable).row_count == 2 + await pilot.press("down") + await pilot.pause() + assert app.query_one("#tries", DataTable).row_count == 1 + await pilot.press("f") + await pilot.pause() + assert app.query_one("#tries", DataTable).row_count == 0 diff --git a/Last3Mon/76/tretry/tests/test_cli.py b/Last3Mon/76/tretry/tests/test_cli.py new file mode 100644 index 0000000..cfb05b8 --- /dev/null +++ b/Last3Mon/76/tretry/tests/test_cli.py @@ -0,0 +1,91 @@ +"""R1 · R3 · R4 · R5 · R7 · R8 · R9 · R10 through the command line (real commands, local fake server, temp log).""" +import json +import sys + +from tretry import cli, log + +PY = sys.executable + + +def run(capsys, *argv): + code = cli.main(list(argv)) + out = capsys.readouterr() + return code, out.out, out.err + + +def test_command_fails_then_fallback(tmp_path, capsys): + logfile = tmp_path / "log.jsonl" + code, out, err = run(capsys, "--tries", "2", "--backoff", "0", "--log", str(logfile), "--fallback", "echo cached", + "--", PY, "-c", "import sys; sys.exit(3)") + assert code == 0 and out.strip() == "cached" and "by the fallback" in err + rows = log.read(logfile) + assert [r["fallback"] for r in rows] == [False, False, True] and rows[0]["error"].startswith("exit 3") + + +def test_command_timeout(tmp_path, capsys): + code, _, err = run(capsys, "--tries", "1", "--timeout", "0.3", "--log", str(tmp_path / "l"), "--", + PY, "-c", "import time; time.sleep(5)") + assert code == 1 and "timeout 0.3s" in err + + +def test_http_5xx_retried_4xx_not(server, tmp_path, capsys): + base, script, seen = server + script["/flaky"] = [(500, {"error": "x"}, {}), (201, {"ticket_id": "T-1"}, {})] + script["/bad"] = [(400, {"error": "title is required"}, {})] + logfile = str(tmp_path / "log.jsonl") + code, out, _ = run(capsys, "http", "POST", base + "/flaky", "--body", "{}", "--backoff", "0", "--log", logfile) + assert code == 0 and "T-1" in out and len(seen) == 2 + code, _, err = run(capsys, "http", "POST", base + "/bad", "--body", "{}", "--backoff", "0", "--log", logfile, + "--fallback", "echo cached") + assert code == 3 and len(seen) == 3 and "not-retryable" in err # one try, no fallback + + +def test_http_same_idempotency_key_every_try(server, tmp_path, capsys): + base, script, seen = server + script["/tickets"] = [(500, {}, {}), (500, {}, {}), (201, {"ticket_id": "T-1"}, {})] + code, _, _ = run(capsys, "http", "POST", base + "/tickets", "--body", '{"title": "t"}', "--idem-key", "auto", + "--body-key", "key", "--backoff", "0", "--log", str(tmp_path / "l")) + keys = {s["headers"]["Idempotency-Key"] for s in seen} + bodies = {json.loads(s["body"])["key"] for s in seen} + assert code == 0 and len(seen) == 3 and len(keys) == 1 and keys == bodies # R5 + + +def test_http_timeout_is_retried(server, tmp_path, capsys): + base, script, seen = server + script["/slow"] = [("sleep", 1.0, {}), (200, {"ok": True}, {})] + code, _, err = run(capsys, "http", "GET", base + "/slow", "--timeout", "0.3", "--backoff", "0", "--log", str(tmp_path / "l")) + assert code == 0 and "timeout" in err and len(seen) == 2 + + +def test_log_never_keeps_a_key(server, tmp_path, capsys): + base, script, _ = server + script["/x?api_key=secret123"] = [(503, {}, {})] + logfile = tmp_path / "log.jsonl" + run(capsys, "http", "GET", base + "/x?api_key=secret123", "--tries", "1", "--header", "Authorization: Bearer sk-abcdefghijklmnop", + "--log", str(logfile)) + text = logfile.read_text() + assert "secret123" not in text and "sk-abcdefghijklmnop" not in text and "***" in text # R7 + + +def test_json_output_and_exit_code(tmp_path, capsys): + code, out, _ = run(capsys, "--tries", "2", "--backoff", "0", "--json", "--log", str(tmp_path / "l"), "--", PY, "-c", "raise SystemExit(1)") + data = json.loads(out) + assert code == 1 and data["exit_code"] == 1 and data["stopped"] == "tries" and len(data["attempts"]) == 2 # R8 + + +def test_stats_and_breaker(tmp_path, capsys): + logfile = str(tmp_path / "log.jsonl") + fail = ["--backoff", "0", "--log", logfile, "--", PY, "-c", "raise SystemExit(1)"] + run(capsys, "--tries", "3", *fail) + run(capsys, "--tries", "2", *fail) # 5 failed tries in a row → breaker opens + code, out, err = run(capsys, "--tries", "3", "--fallback", "echo cached", *fail) + assert code == 0 and out.strip() == "cached" and "breaker open" in err # R10: no try, straight to fallback + code, out, _ = run(capsys, "stats", "--log", logfile, "--json") + row = json.loads(out)[0] + assert row["runs"] == 3 and row["failed_attempts"] == 5 and row["fallbacks"] == 1 and row["success_rate"] == 0.333 # R9 + + +def test_usage_errors(capsys): + assert run(capsys, "echo", "hi")[0] == 2 # no -- + assert run(capsys, "http", "POST", "http://x", "--body", "{bad")[0] == 2 + assert run(capsys, "--tries", "0", "--", "true")[0] == 2 diff --git a/Last3Mon/76/tretry/tests/test_engine.py b/Last3Mon/76/tretry/tests/test_engine.py new file mode 100644 index 0000000..4d76539 --- /dev/null +++ b/Last3Mon/76/tretry/tests/test_engine.py @@ -0,0 +1,67 @@ +"""R1 · R2 · R3 · R6: the engine with fake attempts and a fake clock (no waiting).""" +from tretry.retry import Outcome, Policy, run_with_retry + + +def failing(times, retryable=True): + calls = [] + + def attempt(timeout): + calls.append(timeout) + if len(calls) <= times: + return Outcome(False, retryable, f"boom {len(calls)}") + return Outcome(True, output="done") + return attempt, calls + + +def test_success_after_two_failures_waits_with_backoff(clock): + attempt, calls = failing(2) + result = run_with_retry(attempt, Policy(tries=4, backoff=0.5), sleep=clock.sleep, clock=clock) + assert result.ok and result.output == "done" and len(calls) == 3 + assert clock.slept == [0.5, 1.0] # R1: 0.5 → 1.0 (doubles) + + +def test_wait_has_a_cap_and_jitter_only_shortens(): + policy = Policy(tries=10, backoff=1, max_wait=4, jitter=0.5) + assert [policy.wait(n, rand=lambda: 0.0) for n in range(1, 6)] == [1, 2, 4, 4, 4] # R1: cap + assert policy.wait(3, rand=lambda: 1.0) == 2.0 # R2: up to 50% shorter + + +def test_all_fail_then_fallback(clock): + attempt, _ = failing(9) + result = run_with_retry(attempt, Policy(tries=3), fallback=lambda t: Outcome(True, output="cached"), + sleep=clock.sleep, clock=clock) + assert result.ok and result.used_fallback and result.output == "cached" # R3 + assert [a.fallback for a in result.attempts] == [False, False, False, True] + + +def test_not_retryable_stops_at_once_and_skips_fallback(clock): + attempt, calls = failing(9, retryable=False) + result = run_with_retry(attempt, Policy(tries=5), fallback=lambda t: Outcome(True, output="cached"), + sleep=clock.sleep, clock=clock) + assert not result.ok and len(calls) == 1 and result.stopped == "not-retryable" and not result.used_fallback + + +def test_deadline_stops_before_a_wait_that_cannot_fit(clock): + def slow(timeout): + clock.now += 3 # every try takes 3 s + return Outcome(False, True, "timeout") + result = run_with_retry(slow, Policy(tries=5, backoff=1, deadline=7), sleep=clock.sleep, clock=clock) + assert result.stopped == "deadline" and len(result.attempts) == 2 # R6: 3 + 1 + 3 = 7 → no third try + assert result.seconds <= 7 + + +def test_deadline_shortens_the_last_timeout(clock): + seen = [] + + def attempt(timeout): + seen.append(timeout) + clock.now += timeout + return Outcome(False, True, "timeout") + run_with_retry(attempt, Policy(tries=3, backoff=0.5, deadline=5), per_try_timeout=3, sleep=clock.sleep, clock=clock) + assert seen == [3, 1.5] # 3 + 0.5 wait → 1.5 s left for try 2 + + +def test_retry_after_is_respected_up_to_the_cap(clock): + answers = [Outcome(False, True, "HTTP 429", status=429, retry_after=3), Outcome(True, output="ok")] + result = run_with_retry(lambda t: answers.pop(0), Policy(tries=3, backoff=0.5, max_wait=8), sleep=clock.sleep, clock=clock) + assert result.ok and clock.slept == [3] # R4: the server's 3 s, not our 0.5 s diff --git a/Last3Mon/76/tretry/uv.lock b/Last3Mon/76/tretry/uv.lock new file mode 100644 index 0000000..4dd2367 --- /dev/null +++ b/Last3Mon/76/tretry/uv.lock @@ -0,0 +1,195 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/28/1c058a9143d2304b3078f8dadcbb9dc27f7d3581b5d05157c84869c4cc0d/platformdirs-4.14.0.tar.gz", hash = "sha256:83f009c1e8c5fc36869b5171755b291887bd2cade7965233cdfabd23d754720b", size = 70387, upload-time = "2026-10-28T18:27:30.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/2b/887969ef4bb53e0cb271dc7c3f4a73d50c5d58bb88aeaae311e3340cd57e/platformdirs-4.14.0-py3-none-any.whl", hash = "sha256:1dadfc59a8a5d7565b3d9270ae8fa71c065595352c4277b41c11ee6c222c4b91", size = 35259, upload-time = "2026-10-28T18:27:29.659Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tretry" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "textual" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=0.80" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/Lecture/76/00-DAY76-OVERVIEW.md b/Lecture/76/00-DAY76-OVERVIEW.md new file mode 100644 index 0000000..4e3f259 --- /dev/null +++ b/Lecture/76/00-DAY76-OVERVIEW.md @@ -0,0 +1,487 @@ +# 76일차 개요 · 에러 처리와 예외 대응, 안정적 운영 체계 + +> 노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-30 +> 30장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~6장을 보고, 나머지 장은 각 단계 강의(1 · 3 · 5교시) 때 함께 띄웁니다. +> 숫자는 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini`(분류), 76일차 판 데이터 API, 10/29 정답 워크플로우를 학생 순서대로 1회). + +| 장 | 범위 | 띄우는 때 | +| --- | --- | --- | +| 1~6 | 표지 · 목표 · 시간표 · 잃고 겹치는 접수 · 일하는 순서 · 실습 15개 | 1교시 시작 | +| 7~14 | 1단계 · 실패의 종류 | 1교시 강의 | +| 15~21 | 2단계 · 모으고 알리기 | 3교시 강의 | +| 22~28 | 3단계 · 재처리와 운영 체계 | 5교시 강의 | +| 29~30 | 바이브 코딩 · 정리와 예고 (8교시 35분 정리는 [04-STAGE4-WRAPUP](04-STAGE4-WRAPUP.md)) | 7교시 · 8교시 | + +--- + +## 1 / 30 · 표지 + +```mermaid +flowchart LR + F["실패"] --> S["나누기
거절 · 재시도 · 키"] --> C["모으기
DLQ"] --> A["알리기
등급 · 한 번"] --> R["되돌리기
재처리 · 런북"] + classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff + classDef o fill:#F2A541,stroke:#F2A541,color:#fff + class F d + class S,C,A,R o +``` + +**76일차** + +# 에러 처리와 예외 대응, 안정적 운영 체계 + +실패해도 잃지 않고 · 다시 해도 겹치지 않고 · 사람이 알게 + +노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-30 + +--- + +## 2 / 30 · 오늘 할 수 있게 될 세 가지 + +| | 목표 | 내용 | +| --- | --- | --- | +| 1 | **나눈다** | 실패를 다시 할 것 · 거절할 것으로 나누고, 항목마다 상태로 답하고, 재시도에 멱등 키를 붙이고, LLM이 멈춰도 접수하고, 입구에서 막는다 | +| 2 | **모으고 알린다** | 다시 할 실패는 원래 그대로 DLQ에, 예상 못 한 오류는 Error Workflow로, 그물 사이의 구멍을 막고, 등급을 나눠 같은 문제는 한 번만 알린다 | +| 3 | **되돌린다** | 고친 뒤 같은 입구로 재처리하고, 런북으로 짝 연습을 하고, 세 번 실패하면 사람에게 넘기고, 지금 괜찮은지 신호 하나로 | + +--- + +## 3 / 30 · 오늘의 시간표 + +| 교시 | 시간 | 내용 | +| --- | --- | --- | +| 1 | 09:00~09:50 | 개요 · **1단계 강의** · 실패의 종류 | +| 2 | 10:00~10:50 | **Lab 01 실습 5개** · 망가뜨려 보기 · 항목 확인 · 재시도 · 멱등 키 · 고장 종류 · LLM 멈춤 · 요청 규칙 | +| 3 | 11:00~11:50 | **2단계 강의** · 모으고 알리기 | +| 4 | 12:00~12:50 | **Lab 02 실습 5개** · DLQ · Error Workflow · 그물 지도 · 처리한 실패 알림 · 알림 폭주 | +| - | 13:00~14:00 | 점심 | +| 5 | 14:00~14:50 | **3단계 강의** · 재처리와 운영 체계 | +| 6 | 15:00~15:50 | **Lab 03 실습 5개** · 재처리 · 런북 · 장애 연습 · 반쯤 처리된 요청 · parked · 운영 상태 | +| 7 | 16:00~16:50 | 바이브 코딩 `tretry`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | +| 8 | 17:00~17:50 | 발표 · **4단계 정리** (의미 · 효과 · 회사에서 쓰는 법) · 77일차 예고 | + +--- + +## 4 / 30 · 오늘 고칠 것: 실패하면 잃고, 다시 보내면 겹치는 접수 + +`base-intake`: 문의 여러 건(`requests[]`) → 하나씩 LLM 분류 → 접수 건 만들기. + +| 수업 준비 때 한 일 | 사용자가 받은 것 | 실제로 | +| --- | --- | --- | +| 빈 글이 섞인 3건 | **HTTP 500** | **2건은 이미 접수됨** | +| 잠깐 끊김 (`flaky`) | 500 | 0건 (재시도 없음) | +| 같은 3건을 다시 | 200 · 새 번호 3개 | VPN 문의의 접수 건이 **둘** | +| API가 멈춘 동안 (고친 뒤에도 DLQ가 없으면) | `failed: 2` | 화면을 닫으면 **사라짐** | +| 보고서 작업의 버그 | 500 | **아무도 모름** | + +--- + +## 5 / 30 · 일하는 순서 (오늘은 "망가뜨려 보기 → 고치기 → 다시 망가뜨려 확인") + +```mermaid +flowchart LR + A["① 망가뜨려 보기
fault · llm down · test(전)"] --> B["② 요청문
실패마다 갈 곳"] --> C["③ 에이전트
my-work · check"] --> D["④ 내가
push · test -v · ops"] + D -->|✘| C + D -->|✔| E["⑤ RUNBOOK.md 한 줄"] +``` + +| 누가 | 무엇을 | +| --- | --- | +| 나 | 고장 스위치 · LLM 멈춤으로 실패를 일으키기, 요청문, 런북, 짝 연습 | +| CLI 에이전트 | `AGENTS.md`대로 워크플로우 JSON, `check` | +| `flow.py` | `ops`(접수 건 · DLQ · 알림 · 스위치), `fault tickets\|dlq flaky\|slow\|error\|ok`, `test`(`{"sleep": 3}`) | + +--- + +## 6 / 30 · 실습 15개 (Lab마다 예제 3 · 과제 2) + +| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 | +| --- | --- | --- | --- | --- | --- | +| Lab 01 | 망가뜨려 보기 (에이전트 없이) | 항목 확인 · 재시도 · 멱등 키 | 고장 종류마다 (에이전트 없이) | LLM이 멈춰도 미분류 | 같은 id · 10건 상한 | +| Lab 02 | DLQ | Error Workflow | 어느 실패가 어느 그물에 (에이전트 없이) | 처리한 실패 알림 · DLQ가 멈추면 | 알림 폭주 막기 | +| Lab 03 | 재처리 | 런북 · 장애 연습 (에이전트 없이) | 반쯤 처리된 요청 (에이전트 없이) | 세 번 실패하면 parked | 운영 상태 | + +--- + +## 7 / 30 · 1단계 · 부분 실패: 500이 숨기는 것 + +> 생활 속 예: 마트 계산대에서 "결제 실패"라고만 들었는데, 영수증을 보니 앞의 8개는 이미 결제됨. + +| | 한 건 | 여러 건 (일괄) | +| --- | --- | --- | +| 결과 | 성공 / 실패 | 성공 / 실패 / **일부만** | +| 실패했을 때 서버에 | 대개 없음 | **이미 끝난 일** | +| 다시 보내면 | 대개 안전 | 끝난 일을 **또** | + +- 500을 받은 사용자는 다시 보냅니다 → **중복** (접수 · 결제 · 메일 두 통). +- 수업 준비 때: 빈 글이 섞인 3건 → 500, 접수 건 2개는 이미 생김. + +--- + +## 8 / 30 · 실패의 종류 + +> 생활 속 예: 전화가 안 될 때: 통화 중(조금 뒤 다시) · 없는 번호(다시 걸어도 안 됨) · 전원 꺼짐(나중에). + +| 종류 | 예 | 다시 하면? | 할 일 | +| --- | --- | --- | --- | +| 고칠 수 없음 | 빈 글 · 400 · 401 · 404 | 같음 | 그 항목만 **거절** + 이유 | +| 잠깐 | 끊김 · 503 · 429 | 대부분 성공 | **재시도** | +| 오래감 | API가 한 시간 멈춤 | 나중에 성공 | **모아 두기** → 재처리 | +| 예상 못 함 | 버그 · 모르는 응답 | ? | **알림** | + +- HTTP: 4xx = 내 요청 문제 (408 · 425 · 429 빼고), 5xx = 서버 문제, 응답 없음 = **모름**. + +--- + +## 9 / 30 · 부분 성공: 항목마다 상태 + +> 생활 속 예: 택배 배송 조회: "1~4번 배달 완료, 5번 주소 불명 반송" — 상자마다 상태. + +```mermaid +flowchart LR + Q(["3건"]) --> V{"항목 확인"} + V -->|2건| P["분류 · 접수"] --> M["Merge"] + V -->|1건| R["거절 · 이유"] --> M + M --> O(["200 · created 2 · rejected 1"]) +``` + +- 고친 뒤: 빈 글만 `rejected`(`text is required`), **1.7초** (빈 글을 LLM에 보내지 않음). +- 업계: HTTP 207 Multi-Status, SQS batch item failures. + +--- + +## 10 / 30 · 재시도: 몇 번 · 얼마나 기다리나 + +> 생활 속 예: 자판기 지폐는 펴서 몇 번 다시 넣지만, 콘서트 예매 새로고침을 모두가 연타하면 더 막힘 (재시도 폭풍). + +```text +기다림(i) = min(처음 간격 × 2^(i-1), 상한) × (1 - jitter × 무작위) +``` + +| 원칙 | 까닭 | +| --- | --- | +| 다시 할 실패만 | 400을 다시 보내면 부하만 | +| 지수 backoff · 상한 | 힘든 서버를 더 힘들게 하지 않게 | +| jitter | 모두 같은 순간에 다시 보내지 않게 | +| 한 곳에서 · 예산 안에서 | 층마다 3번이면 27배 (재시도 폭풍) | + +- n8n: `retryOnFail` · `maxTries` · `waitBetweenTries` (노드 바로 아래, 고정 간격, 노드의 항목을 **모두** 다시 보냄). + +--- + +## 11 / 30 · 고장 종류마다 재시도가 하는 일 (Lab 01 예제 3) + +> 생활 속 예: 통화 중이면 다시 걸면 되지만, 전화국이 고장이면 백 번 걸어도 시간만 감. + +| 고장 | 시간 | 결과 | 재시도는 | +| --- | --- | --- | --- | +| `flaky` | 2.4초 | `created: 2` | 도움이 됨 | +| `error` | 3.4초 | `failed: 3` | 시간만 더함 | +| `slow` | **12.4초** | `failed: 3` | 사용자를 기다리게 함 | + +- 그리고 8초 뒤: 실패라던 3건이 **접수 건 목록에 있음**. 시간 초과는 "안 됨"이 아니라 **"모름"**. + +--- + +## 12 / 30 · 멱등 키: 몇 번 보내도 한 번 + +> 생활 속 예: 엘리베이터 버튼을 열 번 눌러도 한 대만 옴 · 택배 송장 번호로 같은 상자를 두 번 배달하지 않음. + +```mermaid +sequenceDiagram + participant C as my-intake + participant S as 데이터 API + C->>S: POST /tickets (key R-101) + S-->>S: T-2001 만들고 키 기억 + C->>S: 다시 (key R-101) + S-->>C: 200 · T-2001 · duplicate: true +``` + +| 키 | 다시 보내면 | +| --- | --- | +| `$execution.id` · 시각 | 매번 새 키 → 중복 | +| **요청 id** `R-101` | 같은 키 → 처음 번호 | + +- 같은 3건을 두 번: `created: 3, duplicates: 3`. 업계: Stripe `Idempotency-Key`. + +--- + +## 13 / 30 · 핵심 일과 덧붙인 일: 우아한 저하 (Lab 01 과제 1) + +> 생활 속 예: 카드 단말기가 고장 난 편의점: 문을 닫지 않고 "오늘은 현금만". + +| | 핵심 일 (접수) | 덧붙인 일 (LLM 분류) | +| --- | --- | --- | +| 실패하면 | 일을 못 함 | 줄여서라도 핵심 일은 | + +| LLM 멈춤 (`llm down`) | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 결과 | 0.8초에 500 · 접수 0 | 0.9초 · `created: 3, unclassified: 3` | + +- 72일차 "간단 답변", 75일차 "예산을 넘으면 원문"과 같은 생각. **줄인 수를 숨기지 않기** (`unclassified`). + +--- + +## 14 / 30 · 입구에서 막기 (Lab 01 과제 2) + +> 생활 속 예: 접수 창구 직원이 "방금 접수하셨는데요" 하고 같은 서류를 그 자리에서 돌려줌. + +| 규칙 | 어기면 | 고치기 전 → 후 | +| --- | --- | --- | +| 같은 요청 안의 같은 id | 두 번째만 거절 | `created: 3, duplicates: 1` → `created: 2, rejected: 1` | +| 한 번에 10건까지 | 전체 400 | **11.0초** · 13건 접수 → **0.2초** · 400 | + +- 키는 **피해를** 막고, 입구 규칙은 **일을** 막습니다 (분류 세 번 · 11초 붙잡기). + +--- + +## 15 / 30 · 2단계 · 결과에만 남은 실패는 사라진다 + +> 생활 속 예: 콜센터가 "장애라 접수가 안 돼요" 하고 끊으면, 고객 이름도 내용도 어디에도 안 남음. + +```mermaid +flowchart LR + F["API 멈춤"] --> R(["200 · failed 2"]) --> X["화면을 닫으면
아무 데도 없음"] +``` + +- 수업 준비 때 `fault tickets error`: `failed: 2`, `ops`의 DLQ 0 · 알림 0. +- 실패한 일을 **서버 쪽에** 남겨야 고친 뒤 다시 할 수 있습니다. + +--- + +## 16 / 30 · DLQ: 배달하지 못한 편지 보관함 (Lab 02 예제 1) + +> 생활 속 예: 부재 시 택배는 버리지 않고 보관소에 — 상자 그대로 · 못 전한 까닭 · 시도 횟수와 함께. + +| 넣는 것 | 까닭 | +| --- | --- | +| **원래 요청 그대로** | 재처리 때 같은 입구로 | +| 이유 · 어디서 · `attempts` | 원인 · 계속 실패하는 것 가려내기 | + +| 넣지 않는 것 | 까닭 | +| --- | --- | +| 거절 (빈 글) | 다시 해도 같음 | + +- 고친 뒤 DLQ: `D-1 R-301` · `D-2 R-302` · `attempts 1` · `500 - tickets service is failing`. +- 업계: Amazon SQS · RabbitMQ · Kafka · Azure Service Bus의 DLQ. + +--- + +## 17 / 30 · 처리한 실패와 처리하지 못한 실패 + +> 생활 속 예: 떨어진 우유는 장보기 목록에 (예상한 일), 터진 수도관은 바로 관리실에 (예상 못 한 일). + +| | 처리한 | 처리하지 못한 | +| --- | --- | --- | +| 예 | API 멈춤 → DLQ | `nightly-report`의 버그 | +| 워크플로우 | 계속 · 200 | **멈춤** · 500 | +| 받는 곳 | 내가 만든 알림 | **Error Workflow** | + +- 코드의 `try/catch`(노드 `onError`)와 전역 오류 처리기(Error Workflow · Sentry)와 같은 구조. + +--- + +## 18 / 30 · Error Workflow (Lab 02 예제 2) + +> 생활 속 예: 자동차 경고등: 고장을 고치지는 않지만 "엔진 · 온도 높음"을 알려 정비소로 보냄. + +```mermaid +flowchart LR + W["nightly-report
settings.errorWorkflow"] -->|멈춤| E["Error Trigger"] --> A[("POST /alerts
critical")] +``` + +```text +[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4] +``` + +- 처리기 id 고정(`errHandler76001`) · **게시**해야 불림 (게시를 내리면 알림 0, 수업 준비 때 확인) · 응답 뒤 1~3초. + +--- + +## 19 / 30 · 그물 지도와 구멍 (Lab 02 예제 3) + +> 생활 속 예: 건전지가 다 된 화재경보기는 불이 나도 조용함 → 그래서 "건전지 부족" 삑 소리가 따로 있음. + +| 실패 | 그물 | 수업 준비 때 | +| --- | --- | --- | +| 빈 글 | `rejected` | ✔ | +| API 오류 | DLQ | ✔ DLQ 2 | +| 버그 | Error Workflow | ✔ critical | +| **API 오류 + DLQ 오류** | **없음** | ✘ `failed: 3`, DLQ 그대로 2, 알림 없음 → **3건 사라짐** | + +- `neverError` 덕분에 멈추지 않았고, 그래서 Error Workflow도 안 불렸습니다. **감시자를 누가 감시하나.** + +--- + +## 20 / 30 · 알림 등급 · 구멍 막기 (Lab 02 과제 1) + +> 생활 속 예: 응급실 환자 분류: 심정지는 바로, 골절은 30분 안에, 감기는 몇 시간 뒤. + +| 등급 | 오늘 | 받으면 | +| --- | --- | --- | +| `critical` | 버그, **DLQ가 멈춤** | 바로 | +| `high` | DLQ에 쌓임, parked | 원인 확인 · 재처리 | +| 없음 | 거절, 미분류 | 보고서 | + +```text +[high] intake · dlq · count 2 · 2 request(s) could not be created and wait in the DLQ +[critical] intake · dlq_down · count 2 · DLQ save failed: … · lost ids R-201, R-203 +``` + +- 요청 하나에 하나 · `count`. 결과에 `dlq_saved` · `alert`. + +--- + +## 21 / 30 · 알림 피로: 같은 문제는 한 번 (Lab 02 과제 2) + +> 생활 속 예: 양치기 소년 · 매일 울리는 차 경보 · 알림을 꺼 버린 단톡방. + +| | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 10분 안 두 번째 실패 | `sent`, dlq 알림 2 | **`suppressed`**, dlq 알림 1 | +| DLQ | 5건 | **5건 모두** | +| critical | 보냄 | 늘 보냄 | + +- 줄이는 것은 **알림**이지 기록이 아닙니다. 업계: Alertmanager · PagerDuty의 중복 없애기 · 묶기 · 반복 간격. + +--- + +## 22 / 30 · 3단계 · 재처리: 같은 입구로 다시 (Lab 03 예제 1) + +> 생활 속 예: 보관소의 택배를 다음 날 재배송하고, 전하면 목록에서 지움 — 같은 배송 절차로. + +```mermaid +flowchart LR + F["원인 고침"] --> R["my-replay
DLQ → my-intake 하나씩"] --> O{"created 1?"} + O -->|예| D[("지움")] + O -->|아니오| K["남김 · attempts +1"] +``` + +- 고친 뒤 `replayed: 2, ok: 2` (1.1초), DLQ 0, 접수 건 2 (중복 없음). +- 직접 만들지 않고 **같은 입구로** → 규칙이 하나. 업계: SQS DLQ redrive. + +--- + +## 23 / 30 · 런북: 그때 할 일을 미리 (Lab 03 예제 2) + +> 생활 속 예: 비행기 비상 체크리스트 · 엘리베이터 안의 "갇혔을 때" 안내문. + +| 갖출 것 | 예 | +| --- | --- | +| 어느 알림 | "`high · dlq`를 받으면" | +| 명령 그대로 | `uv run tools/flow.py ops` | +| 볼 숫자 | "DLQ 0, 같은 key가 둘이 아닌지" | +| 갈림길 | "critical unexpected는 코드 담당에게" | +| **하지 말 것** | "DLQ를 비우지 말 것" | +| 누가 · 언제까지 | "parked는 데이터 담당이 다음 업무일까지" | + +- 구글 SRE: 미리 쓴 플레이북은 회복 시간을 약 세 배 줄인다. + +--- + +## 24 / 30 · 장애 연습: 써 둔 런북이 맞는지 + +> 생활 속 예: 소방 훈련: 불이 나기 전에 비상구 앞 상자를 발견. + +```mermaid +sequenceDiagram + participant A as 장애를 내는 사람 + participant B as 런북만 보는 사람 + A->>A: fault tickets error · my-intake · nightly-report + B->>B: ops → fault ok → my-replay → ops +``` + +- 수업 준비 때: 알림 둘(high dlq · critical unexpected) — 재처리 뒤에도 **critical은 남음** (버그는 재처리로 안 고쳐짐). +- `lab03-drill` 7/7, 접수 건 5 (중복 없음). 업계: game day · chaos engineering · blameless postmortem. + +--- + +## 25 / 30 · 최소 한 번 + 멱등 = 결과는 한 번 (Lab 03 예제 3) + +> 생활 속 예: 엘리베이터 버튼: 여러 번 눌려도(최소 한 번) 한 대만 옴(멱등). + +| 순서 | 수업 준비 때 | +| --- | --- | +| `slow` · 2건 | 11.6초 · `failed: 2` → DLQ 2 | +| 8초 뒤 | 접수 건 **이미 2개** | +| 재처리 | `ok: 2, duplicates: 2`, 접수 건 그대로 2 | + +- DLQ의 요청은 **반쯤 처리됐을** 수 있습니다. 재처리가 안전한 것은 1단계의 키 덕분. + +--- + +## 26 / 30 · 끝없이 돌리지 않기: parked (Lab 03 과제 1) + +> 생활 속 예: 택배 재배송은 몇 번에서 멈추고 보낸 사람에게 연락 · 비밀번호 5번 틀리면 창구로. + +| | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 재처리 3 (`attempts` 3) | 또 보냄 (2.0초) | **0.1초 · parked 2** · high 알림 | +| 고친 뒤 재처리 | 자동으로 보냄 | `replayed: 0` (사람이 정함) | + +- 간격 × 횟수 = 포기하기까지. 업계: SQS `maxReceiveCount`, 독 메시지, human in the loop. + +--- + +## 27 / 30 · 운영 상태: 신호 하나와 까닭 (Lab 03 과제 2) + +> 생활 속 예: 미세먼지 앱의 "좋음 · 보통 · 나쁨"과 "마스크를 쓰세요". + +| 신호 | 기준 | +| --- | --- | +| `critical` | parked · 10분 안 critical 알림 | +| `warn` | DLQ · 10분 안 high 알림 | +| `ok` | 둘 다 아님 | + +```json +{"status": "critical", "dlq": 5, "parked": 0, "alerts": {"critical": 1, "high": 1}, + "reasons": ["critical 알림 1건 (10분)", "DLQ 5건: 원인을 고친 뒤 재처리", "high 알림 1건 (10분)"]} +``` + +- **읽기만** (확인이 상태를 바꾸지 않게). 업계: health check · status page. + +--- + +## 28 / 30 · 운영 체계 한 장 + +> 생활 속 예: 택배 회사에 오늘 만든 것이 모두 있음: 송장 번호 · 보관소 · 재배송 · 상황실 화면 (3단계 8절 표). + +| 질문 | 답하는 것 | +| --- | --- | +| 잘못된 요청은? | 입구 규칙 · 거절 + 이유 | +| 잠깐 끊기면? | 재시도 + 멱등 키 | +| 오래 멈추면? | DLQ → 알림 → 재처리 | +| 예상 못 한 오류는? | Error Workflow → critical | +| 처리 장치가 멈추면? | critical `dlq_down` · 잃을 뻔한 id | +| 계속 실패하면? | parked → 사람 | +| 지금 괜찮은가? | 운영 상태 | +| 그때 누가 무엇을? | 런북 · 연습 | + +--- + +## 29 / 30 · 7교시 바이브 코딩 · `tretry` + +요청서(운영 담당의 말 ①~⑫) → `Doc/TODO.md` → `SPEC.md` → `IMP-PLAN.md` → 단계별로 → `uv tool install`. + +| 오늘 n8n에서 | `tretry`로 n8n 밖 어떤 명령에든 | +| --- | --- | +| `retryOnFail` · 고정 간격 | 지수 backoff · 상한 · jitter | +| 노드 timeout | 시도마다 `--timeout`, 전체 `--deadline` | +| 항목 거절 | 4xx는 1번 · 종료 코드 3 · 대체 안 함 | +| `key` = 요청 id | `--idem-key` · `--body-key` | +| 미분류 | `--fallback` | +| parked | 회로 차단 `--breaker` | + +- 강사 본: `slow`에서 `--timeout 3 --deadline 5` → 5.00초에 포기 (Lab 01 예제 3은 12.4초). + +--- + +## 30 / 30 · 오늘의 정리 · 77일차 예고 + +| 단계 | 오늘 숫자 | +| --- | --- | +| 나누기 | 500 · 중복 → 부분 성공 · `duplicates: 3`, 11건 11.0초 → 0.2초 400 | +| 모으고 알리기 | 사라지던 2건 → DLQ · high 1, 버그 → critical, DLQ 멈춤 → lost ids | +| 되돌리기 | 재처리 `ok: 2` · 중복 0, 반쯤 처리 `duplicates: 2`, parked 0.1초, 상태 ok → warn → critical → ok | + +- 8교시 [4단계 정리](04-STAGE4-WRAPUP.md): 의미 · 효과 · 업계 이름 · 면접 · 최종 프로젝트 체크리스트. +- 77일차 (11/2): **실행 로그 분석과 운영 데이터 기반 Workflow 개선** — 오늘 남긴 실패 · 알림 · 실행 기록을 모아 어디가 자주 · 오래 실패하는지 찾고 고친 뒤 다시 잰다. 바이브 코딩 `tlog`. diff --git a/Lecture/76/00-DAY76-PREP.md b/Lecture/76/00-DAY76-PREP.md new file mode 100644 index 0000000..b63cec2 --- /dev/null +++ b/Lecture/76/00-DAY76-PREP.md @@ -0,0 +1,99 @@ +# 76일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것 + +- 수업일: 2026-10-30 (76일차) · 에러 처리와 예외 대응, 안정적 운영 체계 +- 준비 시간: 약 10분 +- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu). +- **수업 전날까지** 1~2절을 끝내 주세요. + +```mermaid +flowchart LR + A["1. n8n · 데이터 API 켜기
(76일차 판)"] --> B["2. 기준 · nightly-report 게시 ·
doctor ready"] --> C["3. 미리 읽기"] +``` + +--- + +## 1. n8n과 데이터 API 켜기 (전날 필수, 3분) + +오늘은 **76일차 판 데이터 API**(75일차 + 접수 건 만들기 · DLQ · 알림 · 고장 스위치 `dlq`)를 씁니다. 같은 이름이라 이 폴더에서 다시 만들면 바뀝니다. + +```bash +sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔 +cd ~/aio-01d4-stu/Last3Mon/65/n8n +docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감) +docker compose start +cd ~/aio-01d4-stu/Last3Mon/76/data-api +docker compose up -d --force-recreate +sleep 3 # 데이터 API가 뜰 때까지 +curl -s localhost:8800/dlq # {"size": 0, "parked": 0, "items": []} +``` + +- **66일차부터 LLM은 OpenAI API 키(`Last3Mon/.env`, Credential `OpenAI (.env)`)만 씁니다.** 오늘 LLM은 문의 분류에만 씁니다 (`gpt-4.1-mini`). Ollama는 쓰지 않습니다. +- 75일차의 예산 · 사용량 기록 · 고장 스위치는 데이터 API를 새로 띄우면 처음으로 돌아갑니다. +- `network day65-n8n_default not found` → n8n이 켜져 있지 않음. 위 n8n 부분부터 다시. + +## 2. 기준 · nightly-report 게시와 준비 확인 (전날 필수, 3분) + +오늘 망가뜨려 볼 **기준 일괄 접수**와 일부러 버그가 있는 **nightly-report**를 게시해 둡니다 (만들지 않고 게시만). + +```bash +cd ~/aio-01d4-stu/Last3Mon/76 +uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json +uv run tools/flow.py call base-intake requests/batch-ok.json # processed 3 +uv run tools/flow.py ops --reset +uv run tools/flow.py doctor +``` + +```text +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready +``` + +| ✘ 항목 | 할 일 | +| --- | --- | +| n8n container · data API | 1절 | +| dead letter queue | 데이터 API가 예전 판 → 1절 `--force-recreate` · `sleep 3` | +| `! fault switch is on` · `! DLQ is not empty` | `uv run tools/flow.py ops --reset` | +| credential | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` | +| CLI coding agent | [67일차 준비](../67/00-DAY67-PREP.md) 2절 | + +--- + +## 3. 미리 읽어 오면 좋은 내용 (선택, 약 5분) + +### 3.1 500을 받았다면 아무것도 안 된 걸까 + +문의 세 건을 한꺼번에 보냈는데 "오류"가 났습니다. 다시 보내야 할까요? 그런데 사실은 두 건이 이미 접수돼 있었다면? 다시 보내면 어떻게 될까요? 오늘 첫 실습에서 직접 겪어 봅니다. + +### 3.2 용어 + +| 용어 | 한 줄 설명 | +| --- | --- | +| 부분 성공 | 여러 건 중 일부만 성공. 무엇이 성공하고 실패했는지 알려 주기 | +| 멱등 (idempotent) | 같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같음 (키로 알아봄) | +| DLQ | Dead Letter Queue — 처리하지 못한 요청을 버리지 않고 모아 두는 곳 | +| Error Workflow | n8n에서 다른 워크플로우가 오류로 멈추면 대신 실행되는 워크플로우 | +| 런북 (runbook) | 장애가 났을 때 무엇을 어떤 순서로 할지 적은 대응 절차 | +| 재시도 · backoff | 실패하면 다시 해 보기 · 다시 할 때마다 기다리는 시간을 늘리기 | +| 알림 피로 | 알림이 너무 많아 아무도 보지 않게 되는 것 | + +### 3.3 7교시 요청서 + +[VIBE-REQ-TRETRY](../../Last3Mon/76/VIBE-REQ-TRETRY.md)를 한 번 읽어 옵니다. 운영 담당이 원하는 것 열두 가지 중 "꼭"은 무엇일까요? 그리고 "고칠 수 없는 실패"는 무엇일까요? + +## 4. 당일 준비물 + +- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저 +- 오후 장애 연습(Lab 03 예제 2)은 **옆 사람과 짝**으로 합니다 + +## 확인 + +- [ ] `curl -s localhost:8800/dlq`가 `size 0` · `parked 0` +- [ ] `call base-intake`가 `processed 3` +- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready` diff --git a/Lecture/76/01-STAGE1-FAILURE-TYPES.md b/Lecture/76/01-STAGE1-FAILURE-TYPES.md new file mode 100644 index 0000000..fb6dc6a --- /dev/null +++ b/Lecture/76/01-STAGE1-FAILURE-TYPES.md @@ -0,0 +1,418 @@ +# 1단계 · 실패의 종류 + +- 교시: 1교시 강의 (09:00~09:50) · 2교시 [Lab 01](../../Last3Mon/76/LAB-01-Q-FAILURE-TYPES.md) 실습 5개 (10:00~10:50) +- 이 단계가 끝나면: 여러 건을 처리하는 워크플로우에서 실패를 종류별로 나누고, 항목 하나의 실패가 전체를 망치지 않게 부분 성공으로 답하고, 재시도의 효과와 한계를 고장 종류별로 말하고, 재시도 · 시간 초과에도 중복이 생기지 않게 멱등 키를 쓰고, 덧붙인 일(LLM 분류)이 실패해도 핵심 일(접수)을 끝내고, 요청 전체의 규칙을 입구에서 지킬 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini`(분류), 76일차 판 데이터 API, 10/29 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 1회). 출력 전부는 수업 뒤 공개되는 `Last3Mon/76/answers/LAB-TEST-RESULT.md`에 있습니다. +- 실습 가리키기: 이 문서에서 "Lab 01 예제 1"은 2교시 [Lab 01 문서](../../Last3Mon/76/LAB-01-Q-FAILURE-TYPES.md)의 그 절입니다. 파일 경로(`cases/` · `requests/` · `my-work/` · `reference/`)는 실습 폴더 `~/aio-01d4-stu/Last3Mon/76` 기준입니다. + +```mermaid +flowchart LR + A["1. 500의 거짓말"] --> B["2. 실패의 종류"] --> C["3. 부분 성공"] --> D["4. 재시도"] --> E["5. 시간 초과는 '모름'"] + E --> F["6. 멱등 키"] --> G["7. 핵심 일과 덧붙인 일"] --> H["8. 입구에서 막기"] --> I["9. 요청문(프롬프트)으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 09:00~09:06 | 개요 1~6장 | 오늘 하루, 실패하면 잃고 다시 보내면 겹치는 접수, 실습 15개 | +| 09:06~09:12 | 1 · 개요 7장 | 부분 실패, 500이 숨기는 것 | +| 09:12~09:18 | 2 · 개요 8장 | 실패의 종류, 4xx · 5xx · 시간 초과 | +| 09:18~09:23 | 3 · 개요 9장 | 부분 성공, 항목마다 상태 | +| 09:23~09:31 | 4 · 5 · 개요 10~11장 | 재시도 · backoff · jitter · 재시도 폭풍, 시간 초과 | +| 09:31~09:37 | 6 · 개요 12장 | 멱등 키 | +| 09:37~09:44 | 7 · 8 · 개요 13~14장 | 우아한 저하, 입구에서 막기 | +| 09:44~09:50 | 9 · 10 · 확인 질문 | 요청문, 자주 하는 실수 | + +### 2교시 Lab 01에서 이어서 하는 것 + +| 2교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| 시작 (10:00) | — | 런북 복사 | `cp templates/RUNBOOK-TEMPLATE.md my-work/RUNBOOK.md` (오늘 본 실패를 모두 여기에) | +| Lab 01 예제 1 (10:00~10:10, 에이전트 없이) | 1 · 2 | 기준을 망가뜨려 보기: 빈 글 · `flaky` · 다시 보내기 | `call base-intake requests/batch-*.json`, `ops` → `RUNBOOK.md` 1.1 | +| Lab 01 예제 2 (10:10~10:25) | 3 · 4 · 6 | 항목 확인 · 재시도 · 멱등 키 · 부분 성공 | `my-work/intake.json` (기준을 복사), 테스트 `cases/lab01.json` | +| Lab 01 예제 3 (10:25~10:32, 에이전트 없이) | 4 · 5 | 고장 종류마다 재시도가 하는 일, 늦게 생긴 접수 건 | `test cases/lab01-faults.json -v` → `RUNBOOK.md` 1.2 | +| Lab 01 과제 1 (10:32~10:40) | 7 | LLM이 멈춰도 미분류로 접수 | 테스트 `cases/lab01-challenge.json` (`llm down` · `up`) | +| Lab 01 과제 2 (10:40~10:50) | 8 | 같은 id · 10건 상한 | 테스트 `cases/lab01-challenge2.json` | + +## 1. 500의 거짓말: 여러 건을 처리할 때의 부분 실패 (2교시 Lab 01 예제 1) + +### 일반 설명 + +> **생활 속 예 · 마트 계산대.** 물건 10개를 계산하다 9번째 우유의 바코드가 안 찍혀 계산원이 "결제 실패입니다"라고만 말했다고 합시다. 손님은 아무것도 안 샀다고 생각하고 다시 줄을 섭니다. 그런데 영수증을 보면 앞의 8개는 **이미 카드로 결제됐습니다.** 다시 계산하면 8개 값을 두 번 냅니다. "실패" 한마디가 "8개는 됐고, 우유 하나가 안 됐다"를 숨긴 것입니다. 여러 건을 한 번에 처리하는 워크플로우의 500이 바로 이 "결제 실패"입니다. + +**한 건과 여러 건은 실패의 모양이 다릅니다.** 요청 하나가 일 하나만 하면, 결과는 "됐다" 아니면 "안 됐다"입니다. 요청 하나가 일 여러 개를 하면(일괄 처리, batch) 세 번째 모양이 생깁니다: **일부만 됐다.** 이것을 부분 실패(partial failure)라고 부릅니다. + +| | 한 건 | 여러 건 (일괄) | +| --- | --- | --- | +| 결과 | 성공 / 실패 | 성공 / 실패 / **일부만 성공** | +| 실패했을 때 서버에 남은 것 | 대개 없음 | **이미 끝난 일**이 남아 있음 | +| 다시 보내면 | 대개 안전 | 끝난 일을 **또** 함 (중복) | + +**분산 시스템의 기본 성질입니다.** 워크플로우는 여러 서비스(LLM · 데이터 API · 메일 · DB)를 부릅니다. 이 서비스들은 서로 다른 곳에서 돌고, 그 사이의 네트워크는 언제든 끊기거나 느려집니다. 분산 컴퓨팅 분야에서는 오래전부터 "네트워크는 믿을 수 있다", "지연은 0이다"를 대표적인 **잘못된 가정**(fallacies of distributed computing)으로 꼽습니다. 그래서 "부르면 된다"가 아니라 "부르다 실패할 수 있다, 그것도 **중간에**"를 전제로 설계합니다. + +**500이 숨기는 것.** 서버가 일부를 처리하다 멈추면 많은 시스템은 그냥 500(서버 오류)을 돌려줍니다. 받는 쪽은 500을 보고 "아무것도 안 됐다"고 생각하고 다시 보냅니다. 그러면: + +| 사용자가 아는 것 | 실제 | +| --- | --- | +| "실패했다" | 일부는 이미 처리됨 | +| "다시 보내면 된다" | 처리된 일부가 **두 번** 됨 (중복 접수 · 중복 결제 · 메일 두 통) | +| "어디가 실패했는지 모른다" | 서버의 실행 기록에만 있음 | + +**은행 이체로 생각해 보기.** 열 사람에게 급여를 보내는 일괄 이체에서 다섯 번째에 오류가 나 "실패"가 떴습니다. 담당자가 다시 누르면 앞의 네 사람은 급여를 두 번 받습니다. 그래서 금융 · 결제 시스템은 일괄 처리의 결과를 **건마다** 돌려주고, 같은 요청을 다시 받으면 이미 한 것은 하지 않게 만듭니다 (3 · 6절). + +### 오늘 실습에서 + +기준 `base-intake`(문의 여러 건 → 하나씩 LLM 분류 → 접수 건 만들기)를 수업 준비 때 망가뜨려 본 결과입니다 (Lab 01 예제 1). + +| 한 일 | 사용자가 받은 것 | 실제로 | +| --- | --- | --- | +| 가운데 항목의 글이 빈 3건 | **HTTP 500** · `Error in workflow` | 1번째 · 3번째 **2건은 이미 접수됨** | +| `flaky`(키마다 첫 시도만 실패)에서 2건 | 500 | 0건 (재시도 없음) | +| 정상 3건을 다시 보냄 | 200 · 새 번호 3개 | 같은 VPN 문의의 접수 건이 **둘** (T-2001 · T-2006) | + +- 2번째의 빈 제목에 데이터 API가 400을 주자 접수 노드가 오류로 멈췄고, 그 앞뒤 항목은 이미 만들어져 있었습니다. +- 같은 3건을 다시 보내 중복이 생긴 것은 키가 `$execution.id + 순서`(실행마다 바뀜)여서입니다 (6절). + +## 2. 실패의 종류: 다시 하면 되는 것과 안 되는 것 + +### 일반 설명 + +> **생활 속 예 · 전화 걸기.** 전화가 안 될 때도 까닭이 여럿입니다. **통화 중**이면 조금 뒤 다시 걸면 됩니다 (잠깐의 실패). **없는 번호**면 백 번 걸어도 안 됩니다 (잘못된 입력 → 다시 하지 말고 번호를 고침). **전원이 꺼져 있음**이면 지금은 안 되지만 몇 시간 뒤엔 될 수 있습니다 (오래 멈춤 → 나중에). 셋을 똑같이 "안 됨"으로 처리하면, 없는 번호에 계속 걸거나 통화 중인 사람을 포기하게 됩니다. + +실패를 하나로 다루면 대응도 하나뿐입니다 ("500, 끝"). 실패를 **다시 하면 되는지**로 나누면 대응이 달라집니다. + +| 종류 | 예 | 다시 하면? | 할 일 | 오늘 | +| --- | --- | --- | --- | --- | +| **고칠 수 없는 실패** (permanent) | 빈 글, 없는 사용자, 형식 오류, 권한 없음 | 몇 번을 해도 같음 | **그 항목만 거절**, 이유를 알려 고쳐서 보내게 | 1단계 | +| **잠깐의 실패** (transient) | 네트워크 끊김, 서버 순간 오류, 잠깐의 과부하 | 대부분 성공 | **재시도** (간격을 두고 몇 번) | 1단계 | +| **오래가는 실패** | API가 한 시간 멈춤, 공급자 장애 | 지금은 실패, 나중엔 성공 | **모아 두었다가** 고친 뒤 다시 | 2 · 3단계 (DLQ · 재처리) | +| **예상 못 한 실패** | 버그, 모르는 응답 모양 | ? | **알림**, 사람이 봄 | 2단계 (Error Workflow) | + +**HTTP 상태 코드로 나누기.** HTTP를 쓰는 API는 상태 코드가 실패의 종류를 알려 줍니다. + +| 코드 | 뜻 | 다시 하면? | +| --- | --- | --- | +| 2xx | 성공 | — | +| 400 · 401 · 403 · 404 · 422 | **내 요청이 잘못됨** (형식 · 인증 · 권한 · 없는 것) | 아니오 — 요청을 고쳐야 함 | +| 408 · 425 · 429 | 시간 초과 · 너무 이름 · **너무 많이 보냄** | 예 — 기다린 뒤 (`Retry-After`) | +| 500 · 502 · 503 · 504 | **서버 쪽 문제** | 대개 예 | +| (응답 없음) 시간 초과 · 연결 실패 | 모름 (5절) | 예 — 단, 멱등이어야 안전 (6절) | + +Microsoft와 AWS의 클라우드 설계 지침은 모두 이 구분을 "**일시적 오류 처리**(transient fault handling)"라는 이름으로 첫머리에 둡니다: 잠깐의 실패만 다시 하고, 고칠 수 없는 실패는 다시 하지 않는다. + +**LLM API도 같습니다.** 틀린 키(401)나 잘못된 매개변수(400, 75일차의 `max_tokens`)는 다시 해도 같고, 429(속도 제한)와 5xx(공급자 장애)는 기다리면 됩니다. LLM에는 하나가 더 있습니다: **답은 왔는데 모양이 틀린 것**(JSON을 달라 했는데 글이 옴). 이것은 다시 하면 될 수도 있고, 대체 값으로 넘어갈 수도 있습니다 (7절). + +### 오늘 실습에서 + +| 오늘의 실패 | 종류 | 오늘의 대응 | 실습 | +| --- | --- | --- | --- | +| 빈 글 · 빠진 id · user | 고칠 수 없음 | 그 항목만 `rejected` + 이유 | Lab 01 예제 2 | +| 같은 요청 안의 같은 id · 10건 넘음 | 고칠 수 없음 (요청 규칙) | 두 번째만 거절 · 전체 400 | Lab 01 과제 2 | +| 데이터 API `flaky` | 잠깐 | 재시도 3번 | Lab 01 예제 2 · 3 | +| 데이터 API `error` · `slow` | 오래감 | `failed` → DLQ → 재처리 | Lab 01 예제 3 → Lab 02 · 03 | +| LLM 멈춤 (틀린 키) | 오래감 (덧붙인 일) | 미분류로 접수 | Lab 01 과제 1 | +| `nightly-report`의 버그 | 예상 못 함 | Error Workflow → critical 알림 | Lab 02 예제 2 | + +## 3. 부분 성공: 항목마다 상태를 돌려주기 (2교시 Lab 01 예제 2) + +### 일반 설명 + +> **생활 속 예 · 택배 배송 조회.** 택배 다섯 상자를 보냈을 때 배송 조회 화면은 "배송 실패" 한 줄을 보여 주지 않습니다. "1~4번 **배달 완료**, 5번 **주소 불명으로 반송**"처럼 상자마다 상태를 보여 줍니다. 보낸 사람은 5번의 주소만 고쳐 다시 보내면 됩니다. 오늘의 부분 성공(HTTP 200 + 항목마다 `created` · `failed` · `rejected`)이 이 배송 조회 화면입니다. + +**"모두 성공 아니면 500" 대신 "무엇이 되고 무엇이 안 됐는지".** 여러 건을 받는 API는 결과를 **항목마다** 돌려줍니다. 전체 응답은 200(요청 자체는 받았다)이고, 항목마다 성공 · 실패 · 거절과 이유를 담습니다. + +| 방식 | 예 | +| --- | --- | +| 항목마다 상태 | 오늘 `results: [{id, status: created \| failed \| rejected, reason}]` | +| HTTP 207 Multi-Status | 여러 자원의 결과를 한 응답에 (WebDAV에서 정의) | +| 실패한 것만 따로 | AWS Lambda가 SQS 묶음을 처리할 때 실패한 메시지 id만 돌려주는 방식 (batch item failures) | +| 성공 · 실패 개수 + 목록 | 많은 일괄 처리 API (예: 이메일 대량 발송 결과) | + +**받는 쪽이 할 일이 분명해집니다.** `rejected`는 고쳐서, `failed`는 나중에 다시(혹은 서버가 모아 두었다가), `created`는 끝. 500 하나로는 이 셋을 나눌 수 없습니다. + +**n8n에서 항목마다 처리하기.** n8n의 노드는 들어온 **항목(item)마다** 일합니다. 그래서 일괄 처리는 자연스럽게 "항목 → 확인(If) → 처리 / 거절 → 다시 모으기(Merge)"가 됩니다. + +```mermaid +flowchart LR + Q(["3건"]) --> V{"항목 확인"} + V -->|맞음 2건| P["분류 · 접수"] --> M["Merge
(입력 0)"] + V -->|빠짐 1건| R["거절 · 이유"] --> M2["Merge
(입력 1)"] + M & M2 --> O(["200 · created 2 · rejected 1"]) +``` + +- Merge는 두 갈래 중 한쪽이 **비어도** 모읍니다. 그래야 "모두 맞음" · "모두 거절"에서도 결과가 나갑니다. +- 결과의 순서를 지킵니다. Code 노드에서 앞 노드의 `.all()`과 **같은 순서**로 맞춰야 이유가 엉뚱한 항목에 붙지 않습니다. + +### 오늘 실습에서 + +- 고친 뒤 빈 글이 섞인 3건 → 200 · `created: 2, rejected: 1`, 이유 `text is required`. 빈 글을 LLM에 보내지 않아 **1.7초** (Lab 01 예제 2). +- 계약은 `AGENTS.md` 3절: `{total, created, failed, rejected, duplicates, unclassified, results: [...]}`. + +## 4. 재시도: 언제 · 몇 번 · 얼마나 기다리나 (2교시 Lab 01 예제 2 · 3) + +### 일반 설명 + +> **생활 속 예 · 자판기와 맛집 줄.** 자판기가 지폐를 뱉으면 한 번 펴서 다시 넣어 봅니다 (재시도). 세 번 뱉으면 다른 지폐를 꺼냅니다 (횟수 상한). 그런데 인기 콘서트 예매에서 모두가 1초마다 새로고침을 누르면 서버가 더 막힙니다 (**재시도 폭풍**). 다시 시도할 때는 기다리는 시간을 점점 늘리고 (1초 → 2초 → 4초, **backoff**), 사람마다 조금씩 다른 순간에 다시 오게 해야 (**jitter**) 한꺼번에 몰리지 않습니다. 맛집이 "30분 뒤에 오세요"라고 할 때 모두가 정확히 30분 뒤에 오면 또 줄이 막히는 것과 같습니다. + +**재시도 정책의 네 가지.** + +| 정하는 것 | 뜻 | 흔한 값 | +| --- | --- | --- | +| 무엇을 | 다시 할 실패만 (2절) | 5xx · 429 · 시간 초과 · 연결 실패 | +| 몇 번 | 첫 시도를 포함한 최대 수 | 3~5 | +| 얼마나 기다리나 | 시도 사이 간격 | 고정 · **지수 증가(exponential backoff)** | +| 언제 그만 | 횟수 · **전체 마감 시간** | 사용자가 기다릴 수 있는 시간 안 | + +**지수 backoff와 상한.** 0.5 → 1 → 2 → 4초처럼 기다림을 두 배씩 늘립니다. 이미 힘든 서버에 0.1초마다 다시 보내면 더 힘들어지기 때문입니다. 끝없이 늘지 않게 **상한**(예: 8초)을 둡니다. + +```text +기다림(i) = min(처음 간격 × 2^(i-1), 상한) +``` + +**jitter (흔들기).** 서버가 잠깐 멈췄다 살아나는 순간, 그동안 실패한 모든 클라이언트가 **같은 간격**으로 기다렸다가 **같은 순간에** 다시 보냅니다. 서버는 살아나자마자 다시 쓰러집니다 (thundering herd). 기다림에 작은 무작위를 섞으면 다시 보내는 시각이 흩어집니다. AWS 설계 글 "Timeouts, retries and backoff with jitter"가 이것을 자세히 다룹니다. + +**재시도 폭풍 (retry storm).** 재시도는 부하를 **곱합니다.** 세 단계를 거치는 요청에서 단계마다 3번씩 다시 하면 맨 아래 서비스는 최대 3 × 3 × 3 = 27배의 요청을 받습니다. 그래서: + +| 원칙 | 까닭 | +| --- | --- | +| 한 곳에서만 다시 한다 (보통 가장 바깥 또는 가장 가까운 곳) | 층마다 다시 하면 곱해짐 | +| 재시도 예산을 둔다 | 예: 전체 요청의 10%까지만 재시도 (Google SRE 책) | +| 계속 실패하면 잠시 부르지 않는다 | 회로 차단기(circuit breaker) — 7교시 `tretry` R10 | +| 고칠 수 없는 실패는 다시 하지 않는다 | 400을 다시 보내는 것은 부하만 늘림 | + +**재시도는 사용자를 기다리게 합니다.** 시도마다 시간 제한이 3초이고 3번 하면, 실패를 알기까지 9초 넘게 걸립니다. 사용자가 기다릴 수 있는 시간(예: 5초)을 먼저 정하고 그 안에서 횟수를 정합니다 (전체 마감, deadline). + +**n8n의 재시도.** 노드 설정(Settings 탭)의 Retry On Fail · Max Tries · Wait Between Tries입니다. JSON으로는 노드 바로 아래 `retryOnFail` · `maxTries` · `waitBetweenTries`(밀리초)이고, `parameters` 안에 쓰면 무시됩니다. n8n의 기다림은 **고정 간격**이고(지수 증가 없음), 시도 수는 5번까지입니다. 그리고 **노드 단위**라서, 노드에 들어온 항목을 **모두** 다시 보냅니다. 이미 성공한 항목도요 (6절). + +**재시도는 어디에 두나.** 같은 재시도라도 두는 자리에 따라 하는 일이 다릅니다. + +| 자리 | 예 | 좋은 점 | 조심할 것 | +| --- | --- | --- | --- | +| 부르는 노드 (오늘) | n8n `retryOnFail` | 설정만으로, 노드마다 | 고정 간격, 항목 모두 다시 | +| 코드 · 라이브러리 | SDK의 기본 재시도, tenacity · Polly · resilience4j | 지수 backoff · jitter · 상태 코드별 | 층마다 두면 곱해짐 | +| 큐 · 나중에 | DLQ → 재처리 (2 · 3단계) | 사용자를 기다리게 하지 않음 | 결과를 나중에 알려야 함 | +| 부르는 사람 | 사용자가 다시 누름 | — | 키가 없으면 중복 | + +- **짧은 실패는 가까이서, 긴 실패는 큐로**: 1초 안에 풀릴 끊김은 노드 재시도로, 몇 분 · 몇 시간 갈 장애는 DLQ에 모았다가 나중에. 오늘 하루가 이 순서입니다. +- LLM 공급자 SDK들도 429 · 5xx에 몇 번 다시 하는 기본값을 가진 경우가 많습니다. 그 위에 n8n 재시도를 또 두면 층이 둘입니다. 어느 층에서 다시 하는지 알고 씁니다. + +### 오늘 실습에서 + +같은 `my-intake`(재시도 3번 · 간격 0.3초 · 시간 제한 3초)를 고장 셋에서 잰 결과입니다 (Lab 01 예제 3). + +| 고장 | 시간 | 결과 | 재시도는 | +| --- | --- | --- | --- | +| `flaky` (키마다 첫 시도만 500) | 2.4초 | `created: 2` | **도움이 됨** | +| `error` (늘 500) | 3.4초 | `failed: 3` | 시간만 더함 | +| `slow` (8초 뒤 답) | **12.4초** | `failed: 3` (`timeout of 3000ms exceeded` × 3) | 사용자를 12초 기다리게 함 | + +## 5. 시간 초과는 "실패"가 아니라 "모름" (2교시 Lab 01 예제 3) + +### 일반 설명 + +> **생활 속 예 · 식당 주문.** 식당에서 주문한 지 40분째 음식이 안 나옵니다. 주방에 주문이 **안 들어간** 걸까요, 들어갔는데 **늦는** 걸까요? 손님은 알 수 없습니다. 지나가는 다른 직원에게 같은 음식을 또 주문하면, 잠시 뒤 두 접시가 나올 수 있습니다. 안전한 방법은 "주문 번호 12번, 들어갔나요?"라고 **같은 번호로 묻는 것**입니다. 시간 초과는 "실패"가 아니라 이 "모름"이고, 주문 번호가 6절의 멱등 키입니다. + +**응답이 오지 않은 요청은 세 가지 중 하나입니다.** + +| 무슨 일이 있었나 | 서버에 | +| --- | --- | +| 요청이 서버에 닿지 않음 | 아무것도 없음 | +| 서버가 처리하는 중 (느림) | **곧 생김** | +| 서버가 처리했는데 응답이 돌아오다 끊김 | **이미 있음** | + +보낸 쪽에서는 셋을 **구분할 수 없습니다.** 그래서 시간 초과 뒤 다시 보내면 두 번째 · 세 번째 경우에 같은 일이 두 번 됩니다. 이것이 분산 시스템에서 "정확히 한 번(exactly-once)"이 어려운 까닭입니다. 실무의 답은 "**최소 한 번(at-least-once)** 보내고, **받는 쪽이 멱등**하게"입니다 (6절). + +**시간 제한은 꼭 둡니다.** 시간 제한이 없으면 느린 서버 하나가 워크플로우 전체를 붙잡습니다. 그 사이 다른 요청도 줄을 섭니다. 시간 제한은 "포기하고 다음으로 간다"는 결정이지, "서버가 안 했다"는 사실이 아닙니다. + +### 오늘 실습에서 + +- `slow`에서 워크플로우는 세 번 포기하고 `failed: 3`이라고 답했습니다. 그런데 8초 뒤 접수 건 목록에 R-101~103이 **있었습니다** (데이터 API가 늦게 처리). 시도는 9번 갔지만 키가 같아 3건만 생겼습니다 (Lab 01 예제 3). +- 같은 현상을 Lab 03 예제 3에서 재처리로 다시 봅니다: DLQ에 들어간 요청이 사실은 이미 처리된 것 → 재처리는 `duplicates: 2`. + +## 6. 멱등 키: 몇 번 보내도 한 번 한 것처럼 (2교시 Lab 01 예제 2) + +### 일반 설명 + +**멱등(idempotent)**은 수학에서 온 말로, 여러 번 해도 한 번 한 것과 결과가 같은 성질입니다. 쉽게 말해 "**한 번 보내든 세 번 보내든 마지막 상태가 같다**"입니다. 재시도가 안전한지가 여기서 갈립니다. + +**GET · PUT · DELETE는 원래 멱등.** 쇼핑몰의 내 주소로 보면: + +| 요청 | 하는 일 | 세 번 보내면 | +| --- | --- | --- | +| GET 내 주소 | 읽기 | 같은 주소를 세 번 읽음, 바뀌는 것 없음 | +| PUT 내 주소 = "서울시 강남구" | 이 값으로 **덮어쓰기** | 세 번 덮어써도 주소는 "서울시 강남구" 하나 | +| DELETE 장바구니 3번 상품 | 지우기 | 첫 번에 지워지고 그다음은 지울 것이 없음, "3번 없음" 그대로 | + +**POST(새로 만들기)는 멱등이 아님.** 보낼 때마다 새 것이 하나씩 생깁니다. + +| 요청 | 세 번 보내면 | +| --- | --- | +| POST 주문 | 주문 3건 | +| POST 결제 | 카드에서 3번 빠짐 | +| POST 메일 보내기 | 같은 메일 3통 | +| POST 문의 접수 (오늘) | 같은 문의가 접수 건 3개 | + +**문제는 "다시 보내기"에서.** 결제 버튼을 눌렀는데 30초째 돌기만 한다면, 보내는 쪽은 둘을 구별할 수 없습니다 (5절: 시간 초과는 "모름"). + +- (가) 요청이 서버에 닿지도 않았다 → 다시 보내야 함 +- (나) 서버는 결제를 끝냈는데 **응답만** 오다가 끊겼다 → 다시 보내면 두 번 결제 + +**멱등 키(idempotency key)로 POST를 멱등하게.** 택배 송장 번호처럼, 보내는 쪽이 요청마다 **고유한 번호표(키)**를 붙입니다. 받는 쪽은 처리하면서 "키 R-101 → 접수 T-2001"을 적어 두고, 같은 키가 다시 오면 **새로 만들지 않고 적어 둔 처음 결과를 돌려줍니다.** 그러면 POST를 열 번 보내도 접수는 하나, PUT처럼 몇 번 보내도 안전해집니다. + +- 키는 **처음 보낼 때 한 번 정하고, 재시도에도 똑같이** 보냅니다. 재시도마다 새 키를 만들면 받는 쪽은 처음 보는 요청으로 알고 또 만듭니다 (아래 "무엇을 키로 쓰나"). +- 키는 **받는 쪽이 기억해 줘야** 효과가 있습니다. 보내는 쪽만 키를 붙이고 받는 쪽이 무시하면 소용없습니다. +- 한 줄로: 재시도는 "다시 보내기", 멱등 키는 "다시 보내도 한 번만 처리되게 하는 번호표". 그래서 둘은 짝입니다. + +```mermaid +sequenceDiagram + participant C as 보내는 쪽 + participant S as 받는 쪽 + C->>S: POST /tickets (key R-101) + S-->>S: 새로 만듦 T-2001, 키 기억 + S--xC: 응답이 끊김 + C->>S: POST /tickets (key R-101) 다시 + S-->>C: 200 · T-2001 · duplicate: true (새로 만들지 않음) +``` + +| 어디서 쓰나 | 예 | +| --- | --- | +| 결제 API | Stripe는 `Idempotency-Key` 헤더를 받아 같은 키의 결제를 한 번만 처리 | +| 표준 | IETF에서 `Idempotency-Key` HTTP 헤더를 표준으로 만드는 중 (초안) | +| 메시지 처리 | 메시지 id로 이미 처리한 것을 건너뜀 (중복 제거) | +| 오늘 데이터 API | `POST /tickets`의 `key`가 같으면 `duplicate: true` | + +**무엇을 키로 쓰나.** 키는 "**같은 요청이면 같고, 다른 요청이면 다른**" 값이어야 합니다. + +| 키 | 같은 요청을 다시 보내면 | 판정 | +| --- | --- | --- | +| 실행 번호 (`$execution.id`) | 매번 새 키 | ✘ 중복 | +| 현재 시각 · 무작위 | 매번 새 키 | ✘ 중복 | +| **요청의 업무 id** (`R-101`, 주문 번호) | 같은 키 | ✔ | +| 요청 내용의 해시 | 같은 내용이면 같은 키 | 내용이 같은 **다른** 요청도 막힘 — 주의 | + +**재시도와 멱등 키는 짝입니다.****키를 언제까지 기억하나.** 받는 쪽은 키를 영원히 기억할 수 없습니다. 결제 API들은 보통 키를 정해진 기간(예: 하루) 동안만 기억합니다. 그 기간 안의 재시도 · 재처리만 안전하다는 뜻이므로, 재처리를 며칠 뒤에 돌린다면 키를 기억하는 기간도 함께 정해야 합니다. 오늘 데이터 API는 다시 켜기 전까지 기억합니다 (`ops --reset`이 지움). + +**같은 키에 다른 내용이 오면.** 키는 같은데 내용이 다르면(같은 `R-101`에 다른 글) 대개 보내는 쪽의 버그입니다. 잘 만든 API는 이때 처음 결과를 돌려주지 않고 오류(예: 409 · 422)를 줍니다. 오늘 데이터 API는 단순하게 처음 번호를 돌려줍니다. + + 4절에서 본 것처럼 n8n의 재시도는 노드의 항목을 **모두** 다시 보냅니다. 키가 없으면 재시도가 곧 중복입니다. 5절의 시간 초과도 마찬가지입니다. 그래서 "재시도를 켠다"와 "멱등 키를 둔다"는 늘 함께 정합니다. + +### 오늘 실습에서 + +- 키를 요청 `id`로 바꾼 뒤 같은 3건을 두 번 보내면 `created: 3, duplicates: 3`, 접수 건은 3개 그대로 (Lab 01 예제 2). +- `flaky`에서 재시도가 두 건을 **모두** 다시 보냈지만 `created: 2`, 중복 없음. + +## 7. 핵심 일과 덧붙인 일: 우아한 저하 (2교시 Lab 01 과제 1) + +### 일반 설명 + +> **생활 속 예 · 정전 난 편의점과 내비게이션.** 편의점의 카드 단말기가 고장 나도 가게 문을 닫지 않습니다. "오늘은 현금만 받아요"로 **줄여서라도 판매(핵심 일)를 계속**합니다. 내비게이션의 실시간 교통 정보가 끊겨도 길 안내는 멈추지 않고, "교통 정보 없음" 표시를 띄운 채 기본 경로로 안내합니다. 줄였다는 것을 **숨기지 않는** 것도 같습니다. 오늘 LLM 분류(덧붙인 일)가 멈추면 접수(핵심 일)는 "미분류"로 계속하고, `unclassified` 수로 줄인 양을 알려 줍니다. + +**워크플로우의 일을 둘로 나눕니다.** + +| | 핵심 일 | 덧붙인 일 | +| --- | --- | --- | +| 뜻 | 이 워크플로우가 있는 이유 | 핵심 일을 더 낫게 함 | +| 오늘 | 문의 **접수** | LLM **분류** (분야 · 우선순위) | +| 실패하면 | 일을 못 함 | 덜 좋게라도 핵심 일은 함 | + +**우아한 저하(graceful degradation).** 덧붙인 일이 실패하면 **줄여서라도** 핵심 일을 끝냅니다. 쇼핑몰의 추천 기능이 멈추면 인기 상품을 보여 주고 주문은 받습니다. 지도 앱의 실시간 교통 정보가 멈추면 평소 소요 시간으로 길을 안내합니다. 72일차에는 LLM이 멈추면 FAQ 원문으로 "간단 답변"을, 75일차에는 예산을 넘으면 원문으로 답했습니다. + +**줄였다는 것을 숨기지 않습니다.** 대체 값으로 넘어갔다면 결과에 그 수를 남깁니다 (오늘 `unclassified`). 그래야 나중에 사람이 몇 건을 다시 봐야 하는지 알고, 대체가 너무 자주 일어나면 알아챕니다. + +**LLM 노드의 실패 셋.** + +| 실패 | 예 | n8n에서 | +| --- | --- | --- | +| 호출 실패 | 틀린 키 · 공급자 장애 · 시간 초과 | 노드 `onError: continueRegularOutput` → 항목에 `error` | +| 답은 왔는데 모양이 틀림 | JSON 대신 글 · 없는 분야 이름 | 다음 Code에서 `JSON.parse` 실패를 잡음 | +| 답이 틀림 | 분야를 잘못 고름 | 테스트 · 사람 검토 (73일차 평가) | + +### 오늘 실습에서 + +- 고치기 전 `llm down`(틀린 키): 분류 노드가 멈춰 **0.8초에 500, 접수 0건**. +- 고친 뒤: 분류는 실패했지만 `created: 3, unclassified: 3` (0.9초). 미분류 접수 건이 목록에 남고, `llm up` 뒤 평소 분류는 그대로 (Lab 01 과제 1). + +## 8. 입구에서 막기: 요청 전체의 규칙 (2교시 Lab 01 과제 2) + +### 일반 설명 + +**잘못된 요청은 비싼 일을 하기 전에 막는 것이 가장 쌉니다** (validate at the edge). LLM · 외부 API · DB를 부른 뒤에 실패하면 시간 · 돈 · 정리할 일이 남습니다. + +| 규칙의 수준 | 예 | 어기면 | +| --- | --- | --- | +| 항목 | 빈 글 · 빠진 칸 | 그 항목만 거절 | +| 항목 사이 | 같은 요청 안의 같은 id | 두 번째부터 거절 | +| 요청 전체 | 목록이 아님 · **너무 많음** | 요청 전체를 400 | + +**왜 묶음에 상한을 두나.** 상한이 없으면 한 요청이 몇 분씩 자원을 붙잡고, 그동안 다른 요청이 기다립니다. 실패했을 때 다시 보낼 양도 커집니다. 많은 API가 한 번에 받을 수를 정해 둡니다 (예: Amazon SQS의 묶음 보내기는 한 번에 10개). 상한을 넘으면 일부만 처리하지 않고 **전체를 거절**합니다. 어디까지 됐는지 사용자가 알기 어렵기 때문입니다. + +**멱등 키가 있어도 입구 규칙이 필요한 까닭.** 같은 id 두 개는 키 덕분에 접수 건이 하나만 생깁니다. 하지만 결과는 두 건을 접수했다고 말하고, LLM 분류를 두 번 합니다. 키는 **피해를 막고**, 입구 규칙은 **일을 막습니다.** + +예: 한 요청 안에 같은 문의가 실수로 두 번 들어옴 (`R-401` · `R-401` · `R-402`). 입구 규칙이 없으면 세 줄을 모두 정상 문의로 보고 하나씩 처리합니다. + +| 줄 | 입구 규칙 없음 | 입구 규칙 있음 | +| --- | --- | --- | +| R-401 | 분류 → 접수 `T-2001` 새로 | 분류 → 접수 `T-2001` 새로 | +| R-401 (또) | 분류 **한 번 더** → 접수 → 키가 같아 `T-2001` 그대로 (`duplicate: true`) | **입구에서 바로 거절**, 분류 · 접수 안 함 | +| R-402 | 분류 → 접수 `T-2002` 새로 | 분류 → 접수 `T-2002` 새로 | +| 답 | `created: 3` (실제 접수 건은 2개) | `created: 2, rejected: 1` | + +- **키가 막은 것 (피해)**: 접수 건이 정말로 둘 생기는 일. 담당자가 같은 문의를 두 번 처리하지 않습니다. +- **키가 못 막은 것 (일)**: 두 번째 R-401의 LLM 분류(돈 · 시간), 접수 API 한 번 더, 실제와 다른 숫자(`created: 3`). 키는 **받는 쪽 끝에서** 일하므로, 거기까지 가는 동안의 일은 그대로 다 일어납니다. +- 비유: 멱등 키는 물류 센터의 **송장 번호 확인**입니다. 같은 상자가 두 번 와도 배송은 한 번이지만, 상자는 이미 트럭에 실려 왔습니다. 입구 규칙은 **접수 창구 직원**입니다. "방금 접수하셨는데요" 하고 그 자리에서 돌려보내, 트럭에 싣지도 않습니다. +- 그래도 키는 필요합니다. 입구 규칙은 **같은 요청 안**의 중복만 봅니다. 다른 요청으로 따로 오는 중복(재시도 · 재처리)은 입구에서 알 수 없고, 키가 막습니다. 그래서 둘 다 둡니다. + +### 오늘 실습에서 + +| | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 같은 id 두 번 + 다른 하나 | `created: 3, duplicates: 1` (분류 세 번) | `created: 2, rejected: 1` (`duplicate id in this request`) | +| 11건 | **11.0초** · 200 · 접수 건 13 | **0.2초** · 400 `too many requests (max 10)` · 접수 건 그대로 | + +## 9. 요청문(프롬프트)으로 에이전트에게 맡기기 + +에러 처리 요청문은 "에러 처리를 잘 해 줘"가 아니라 **실패마다 어디로 가는지**를 적습니다 (`AGENTS.md` 1절: 에이전트는 먼저 "어떤 실패가 어디로 가는지" 표를 보여 줌). + +| 넣을 것 | 예 | +| --- | --- | +| 실패의 종류와 갈 곳 | "빈 글은 `rejected`와 이유, API 실패는 재시도 3번 뒤 `failed`" | +| 재시도 설정과 자리 | "`Create ticket`에 timeout 3000ms, `maxTries` 3, `waitBetweenTries` 300 (노드 바로 아래)" | +| 실패해도 계속 | "`onError: continueRegularOutput`, 다음 Code가 앞 노드와 같은 순서로" | +| 멱등 키 | "`key`는 요청의 `id` (실행 번호가 아님)" | +| 결과 모양 · 상태 코드 | "AGENTS.md 3절 모양으로 HTTP 200, 목록이 아니면 400" | +| 기대 결과 | "`cases/lab01.json`" | + +## 10. 자주 하는 실수 + +| 실수 | 결과 | 바로잡기 | +| --- | --- | --- | +| 재시도 설정을 `parameters` 안에 | 재시도가 안 됨 (조용히) | 노드 바로 아래 (`flow.py check`가 알려 줌) | +| 재시도만 켜고 키는 그대로 | 재시도가 곧 중복 | 키 = 요청 id | +| 400도 다시 | 같은 실패 × 3, 부하만 늚 | 고칠 수 없는 실패는 거절 | +| 재시도 횟수만 늘림 | 사용자가 오래 기다림 (`slow` 12초) | 기다릴 수 있는 시간 안에서 | +| 실패한 항목을 결과에서 뺌 | "모두 성공"처럼 보임 | 항목마다 상태, 실패도 그대로 | +| LLM 실패로 접수까지 멈춤 | 장애 동안 들어온 문의를 모두 잃음 | 덧붙인 일은 대체 값으로 | +| 대체했다는 것을 남기지 않음 | 얼마나 줄였는지 모름 | `unclassified` 같은 수 | + +## 확인 질문 + +
1. 빈 글이 섞인 3건에 500이 났는데 접수 건 2개가 생겼습니다. 사용자는 무엇을 하고, 그러면 어떻게 되나요? + +사용자는 실패로 알고 다시 보냅니다. 키가 실행 번호라서 데이터 API는 새 요청으로 보고, 앞서 만들어진 두 건이 또 생깁니다. 그래서 항목마다 결과를 돌려주고(부분 성공), 키를 요청 id로 바꿉니다. +
+ +
2. 재시도를 3번에서 5번으로 늘리면 좋아지나요? + +고장에 따라 다릅니다. 잠깐의 실패(`flaky`)는 이미 2번째에 됩니다. 늘 실패(`error`)는 결과가 같고 시간만 늘고, 느림(`slow`)은 사용자를 더 오래 기다리게 합니다 (3번에 12.4초). 그리고 여러 층이 다시 하면 부하가 곱해집니다. 기다릴 수 있는 시간 · 재시도 예산 안에서 정하고, 오래가는 실패는 DLQ로 보냅니다. +
+ +
3. 시간 초과로 `failed`라고 답한 요청을 다시 보내도 안전한가요? + +멱등 키가 있으면 안전합니다. 시간 초과는 "안 됐다"가 아니라 "모른다"입니다. 오늘 `slow`에서 실패라던 3건이 8초 뒤 만들어져 있었습니다. 같은 키로 다시 보내면 `duplicate: true`로 처음 번호를 받고, 키가 없으면 두 번째 접수 건이 생깁니다. +
+ +
4. LLM 분류가 멈췄을 때 접수까지 멈추면 안 되는 까닭은? 대신 무엇을 남기나요? + +접수는 핵심 일, 분류는 덧붙인 일입니다. 덧붙인 일의 실패로 핵심 일을 멈추면 장애 동안 들어온 문의를 모두 잃습니다. 미분류 · 보통으로 접수하고, 결과의 `unclassified`에 그 수를 남겨 나중에 사람이 몇 건을 분류해야 하는지 알게 합니다. +
diff --git a/Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md b/Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md new file mode 100644 index 0000000..4264125 --- /dev/null +++ b/Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md @@ -0,0 +1,303 @@ +# 2단계 · 모으고 알리기 + +- 교시: 3교시 강의 (11:00~11:50) · 4교시 [Lab 02](../../Last3Mon/76/LAB-02-Q-COLLECT-AND-ALERT.md) 실습 5개 (12:00~12:50) +- 이 단계가 끝나면: 다시 하면 될 실패를 원래 요청 그대로 DLQ에 모으고, 예상하지 못한 오류를 Error Workflow로 알리고, 실패마다 어느 그물에 걸리는지 지도를 그려 구멍(DLQ가 멈출 때)을 찾아 막고, 알림의 등급과 양을 정해 알림 피로를 막을 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (10/29, 정답 워크플로우를 학생 순서대로 1회). 출력 전부는 수업 뒤 공개되는 `Last3Mon/76/answers/LAB-TEST-RESULT.md`에 있습니다. +- 실습 가리키기: "Lab 02 예제 1"은 4교시 [Lab 02 문서](../../Last3Mon/76/LAB-02-Q-COLLECT-AND-ALERT.md)의 그 절입니다. + +```mermaid +flowchart LR + A["1. 사라지는 실패"] --> B["2. DLQ"] --> C["3. 처리한 실패 ·
처리 못 한 실패"] --> D["4. Error Workflow"] + D --> E["5. 그물 지도와 구멍"] --> F["6. 알림 등급"] --> G["7. 알림 피로"] --> H["8. 요청문(프롬프트)으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 11:00~11:07 | 1 · 개요 15장 | 결과에만 남은 실패는 사라진다 | +| 11:07~11:15 | 2 · 개요 16장 | DLQ: 무엇을 · 왜 · 넣지 않을 것 | +| 11:15~11:25 | 3 · 4 · 개요 17~18장 | 처리한 · 처리하지 못한 실패, Error Workflow | +| 11:25~11:32 | 5 · 개요 19장 | 그물 지도, 감시자를 누가 감시하나 | +| 11:32~11:42 | 6 · 7 · 개요 20~21장 | 알림 등급, 알림 피로, 같은 알림은 한 번 | +| 11:42~11:50 | 8 · 9 · 확인 질문 | 요청문, 자주 하는 실수 | + +### 4교시 Lab 02에서 이어서 하는 것 + +| 4교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| Lab 02 예제 1 (12:00~12:12) | 1 · 2 | 실패한 요청은 원래 그대로 DLQ | `my-work/intake.json`, `fault tickets error`, 테스트 `cases/lab02.json` 앞 두 케이스, `ops` | +| Lab 02 예제 2 (12:12~12:24) | 3 · 4 | 에러 처리기 `errHandler76001`, `nightly-report`의 알림 | `my-work/error-handler.json`, 테스트 `cases/lab02.json` | +| Lab 02 예제 3 (12:24~12:30, 에이전트 없이) | 5 | 실패 넷이 어느 그물에, DLQ도 멈추면 | `test cases/lab02-nets.json -v` (5/7이 맞음) → `RUNBOOK.md` 1.3 | +| Lab 02 과제 1 (12:30~12:40) | 5 · 6 | 처리한 실패 high 알림, DLQ가 멈추면 critical | 테스트 `cases/lab02-challenge.json`, `lab02-nets` 7/7 | +| Lab 02 과제 2 (12:40~12:50) | 7 | 같은 이유의 high 알림은 10분에 한 번 | 테스트 `cases/lab02-challenge2.json` | + +## 1. 결과에만 남은 실패는 사라진다 (4교시 Lab 02 예제 1) + +### 일반 설명 + +> **생활 속 예 · 콜센터 전화.** 고객센터에 전화했더니 상담원이 "지금 시스템 장애라 접수가 안 됩니다. 나중에 다시 걸어 주세요"라고 하고 끊었습니다. 상담원은 정직하게 말했지만, 고객의 이름도 문의 내용도 **어디에도 적히지 않았습니다.** 고객이 다시 걸지 않으면 그 문의는 영영 사라집니다. 상담원이 메모지에 "김민지, 화면 공유 문제, 장애 끝나면 연락"이라고 적어 두기만 해도 달라집니다. 그 메모지가 DLQ입니다. + +1단계에서 실패를 **결과에** 정직하게 남겼습니다 (`failed: 2`). 그런데 결과는 그 요청을 보낸 화면 · 프로그램에만 갑니다. 화면을 닫거나, 보낸 프로그램이 결과를 기록하지 않으면 실패도 함께 사라집니다. 오래가는 실패(API가 한 시간 멈춤)는 그 사이 들어온 요청을 **모두** 이렇게 잃습니다. + +**실패한 일을 서버 쪽에 남겨야 합니다.** 그래야 원인을 고친 뒤 다시 할 수 있습니다. 이것이 DLQ이고, 3단계의 재처리가 그것을 꺼내 다시 돌립니다. + +### 오늘 실습에서 + +- `fault tickets error` 동안 `my-intake`: 200 · `failed: 2`. `ops`의 DLQ 0 · 알림 0 — 두 문의(R-301 · R-302)는 응답 JSON 말고는 아무 데도 없습니다 (Lab 02 예제 1 고치기 전). + +## 2. DLQ: 배달하지 못한 편지 보관함 (4교시 Lab 02 예제 1) + +### 일반 설명 + +> **생활 속 예 · 택배 보관소.** 집에 아무도 없어 택배를 못 전하면 기사는 상자를 버리지 않고 **보관소**에 둡니다. 보관소에는 ① 상자 **그대로**(뜯거나 바꾸지 않음), ② **왜** 못 전했는지("수취인 부재"), ③ 몇 번째 배송 시도인지가 남습니다. 다음 날 **같은 상자를 다시** 배송하고, 전하면 보관소 목록에서 지웁니다. 하지만 "주소가 아예 없는 번지"인 상자는 보관해도 다시 배송할 수 없으니 보관소가 아니라 보낸 사람에게 돌려보냅니다 (오늘의 `rejected`). + +**DLQ(dead letter queue)**는 처리하지 못한 메시지를 버리지 않고 옮겨 두는 곳입니다. 우체국이 주소가 틀린 편지를 따로 모아 두는 것(dead letter office)에서 온 이름입니다. 메시지 시스템마다 있습니다. + +| 시스템 | DLQ | +| --- | --- | +| Amazon SQS | 큐마다 DLQ를 지정, 정해진 횟수(`maxReceiveCount`) 실패하면 옮김, 원래 큐로 되돌리기(redrive) | +| RabbitMQ | dead letter exchange: 거절 · 시간 초과 · 길이 초과 메시지를 다른 곳으로 | +| Apache Kafka (Kafka Connect 등) | 처리 실패 메시지를 따로 토픽(DLQ 토픽)에 | +| Azure Service Bus | 큐마다 dead-letter 하위 큐 | +| 오늘 데이터 API | `POST /dlq` · `GET /dlq` · `/dlq/update` | + +**무엇을 넣나.** + +| 넣는 것 | 까닭 | +| --- | --- | +| **원래 요청 그대로** | 재처리 때 같은 입구로 다시 보냄. 중간 결과(분류)는 그때 다시 만들어짐 | +| 실패 이유 | 사람이 원인을 앎 (`500 - tickets service is failing`, `timeout`) | +| 어디서 (워크플로우 · 단계) | DLQ 하나를 여러 워크플로우가 함께 쓸 때 | +| 몇 번째인지 (`attempts`) | 계속 실패하는 것을 가려냄 (3단계 parked) | +| 언제 | 오래된 것부터, 보관 기간 | + +**넣지 않는 것.** 다시 해도 같은 실패(고칠 수 없는 실패: 빈 글 · 형식 오류)는 DLQ에 넣지 않고 그 자리에서 **거절**합니다. DLQ에 섞이면 재처리할 때마다 같은 실패가 되풀이되고, 정말 살려야 할 것이 묻힙니다. DLQ는 "**나중에 다시 하면 될 것**"만 모읍니다. + +**개인정보 주의.** DLQ에는 원래 요청이 그대로 들어갑니다. 문의 글에 개인정보가 있으면 DLQ에도 있습니다 (73일차 가리기). 접근 권한과 보관 기간을 정하고, 재처리가 끝난 항목은 지웁니다. + +**DLQ도 운영해야 합니다.** DLQ는 넣기만 하면 끝나는 곳이 아니라, 지켜보고 비워 가는 곳입니다. + +| 운영할 것 | 까닭 | 흔한 방법 | +| --- | --- | --- | +| 크기 | 쌓이면 무언가 계속 실패 중 | 크기가 0보다 크면 알림 (오늘 high · 3단계 운영 상태) | +| 가장 오래된 것의 나이 | 재처리가 밀렸나 | "1시간 넘은 것이 있으면" 알림 | +| 보관 기간 | 개인정보 · 저장 공간 | SQS는 메시지 보관 기간을 정함 (최대 14일) | +| 재처리 도구 | 사람이 쉽게 되돌리게 | redrive 버튼 · 재처리 워크플로우 (3단계) | +| 이유별 수 | 같은 원인인가 | `reason`으로 묶어 세기 (77일차) | + +- DLQ가 하나도 비지 않고 자라기만 한다면, 그것은 "잃지 않음"이 아니라 "**버리는 곳을 옮겼을 뿐**"입니다. + +### 오늘 실습에서 + +- 고친 뒤 DLQ에 `D-1 R-301` · `D-2 R-302`가 원래 요청 · 이유 · `attempts 1`과 함께 (Lab 02 예제 1). +- 오늘 데이터 API는 같은 `item.id`가 다시 들어오면 새 줄 없이 `attempts`를 올립니다 (Lab 03에서 씀). + +## 3. 처리한 실패와 처리하지 못한 실패 + +### 일반 설명 + +> **생활 속 예 · 집의 고장.** 우유가 떨어지는 것은 **예상한 일**입니다. 냉장고 앞 장보기 목록에 적어 두고 장 볼 때 삽니다 (처리한 실패 → 정해 둔 곳에 모음). 수도관이 갑자기 터지는 것은 **예상 못 한 일**입니다. 목록에 적을 일이 아니라 바로 관리실에 연락해야 합니다 (처리하지 못한 실패 → 마지막 그물이 사람을 부름). 둘을 같은 방법으로 다루면, 우유 때문에 관리실을 부르거나 터진 수도관을 장보기 목록에 적게 됩니다. + +| | 처리한 실패 (handled) | 처리하지 못한 실패 (unhandled) | +| --- | --- | --- | +| 뜻 | 예상하고 갈 곳을 정해 둔 실패 | 예상하지 못해 워크플로우가 멈춘 실패 | +| 예 | API가 멈춤 → DLQ, 빈 글 → 거절 | 버그, 모르는 응답 모양, 누가 바꾼 설정 | +| 워크플로우 | 계속, 200 | **멈춤**, 500 | +| 알리는 곳 | 내가 만든 알림 (6절) | **Error Workflow** (n8n이 부름) | + +**둘 다 있어야 합니다.** 처리한 실패만 있으면 예상하지 못한 버그는 아무도 모릅니다. 처리하지 못한 실패의 그물(Error Workflow)만 있으면 API가 멈출 때마다 워크플로우가 멈추고, 그 실행의 다른 항목도 함께 잃습니다. 예상한 것은 **그 자리에서**, 예상하지 못한 것은 **마지막 그물로**. + +**프로그래밍 언어와 같은 구조입니다.** 코드에서 `try/catch`로 예상한 예외를 잡고, 잡지 못한 예외는 프로그램 전체의 처리기(웹 서버의 500 처리, 오류 추적 도구 Sentry 등)가 받아 기록 · 알림합니다. n8n에서는 노드의 `onError`가 `try/catch`, Error Workflow가 전체 처리기입니다. + +## 4. Error Workflow: n8n이 부르는 마지막 그물 (4교시 Lab 02 예제 2) + +### 일반 설명 + +> **생활 속 예 · 자동차 경고등.** 운전자는 차의 모든 고장을 미리 알 수 없습니다. 그래서 차는 엔진에 무슨 일이 생기든 **계기판에 경고등**을 켭니다. 경고등은 고장을 고치지 않습니다. 대신 "어디(엔진)가, 무엇이(온도 높음)" 문제인지 알려서 정비소에 가게 합니다. Error Workflow도 버그를 고치지 않습니다. 어느 워크플로우 · 어느 노드 · 무슨 오류인지 알림을 남겨 사람이 고치게 합니다. 서커스의 맨 아래 **안전망**처럼, 다른 장치가 모두 놓친 것을 받는 마지막 그물입니다. + +**n8n의 Error Workflow.** 워크플로우 설정에 `errorWorkflow`(에러 처리기 워크플로우의 id)를 두면, 그 워크플로우가 처리하지 못한 오류로 멈출 때 n8n이 에러 처리기를 부릅니다. 에러 처리기는 **Error Trigger** 노드로 시작합니다. + +```mermaid +flowchart LR + W1["접수 워크플로우"] & W2["nightly-report"] & W3["다른 워크플로우"] -->|settings.errorWorkflow| E["에러 처리기
(Error Trigger)"] + E --> C["알림 만들기
워크플로우 · 노드 · 오류 · 실행 번호"] --> A[("알림 저장 · 메신저 · 메일")] +``` + +| Error Trigger가 넘기는 것 | 알림에 | 쓰임 | +| --- | --- | --- | +| `workflow.name` · `workflow.id` | 어느 워크플로우 | 담당자 찾기 | +| `execution.lastNodeExecuted` | 어느 노드 | 고칠 곳 | +| `execution.error.message` | 무슨 오류 | 원인 짐작 | +| `execution.id` | 어느 실행 | n8n 화면 Executions에서 그 실행을 열어 노드마다 입력 · 출력을 봄 | + +**처리기 하나를 여러 워크플로우가 함께 씁니다.** 그래서 처리기의 id를 고정하고(오늘 `errHandler76001`, 70일차 모듈처럼), 워크플로우마다 설정에 그 id를 적습니다. 처리기도 **게시**되어 있어야 합니다. + +**알림은 응답 뒤에 갑니다.** Error Workflow는 실패한 실행이 끝난 **뒤에** 따로 돕니다. 부른 사람은 이미 500을 받았고, 알림은 1~3초 늦게 생깁니다. + +**알림에 넣지 말 것.****에러 처리기가 할 수 있는 일.** 알림만이 아닙니다. + +| 일 | 예 | +| --- | --- | +| 알림 | 메신저 · 메일 · 호출 (등급에 따라 다른 곳) | +| 기록 | 오류 기록 저장소 · 오류 추적 도구에 남김 (나중에 묶어 셈, 77일차) | +| 일감 만들기 | 같은 오류가 처음이면 이슈 · 티켓을 만듦 | +| 정리 | 반쯤 한 일을 되돌림 (보상 트랜잭션, saga) | + +**에러 처리기도 실패합니다.** 처리기 안의 알림 HTTP가 실패하면 그 알림은 그냥 사라집니다. 그래서 처리기는 **아주 단순하게**(노드 몇 개, 외부 호출 하나) 두고, 처리기가 보낸 알림이 실제로 도착하는지는 따로 확인합니다 (가끔 일부러 오류를 내 보는 연습, 3단계). + + 오류 문장에는 요청 내용 · 키 · 토큰이 섞일 수 있습니다 (73일차). 알림 채널(메신저)은 많은 사람이 봅니다. 길이를 자르고, 비밀은 지우고, 자세한 것은 실행 번호로 n8n에서 보게 합니다. + +### 오늘 실습에서 + +- `nightly-report`(일부러 버그)는 고친 뒤에도 500입니다. 달라진 것은 3초 뒤 알림입니다: + `[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]` (Lab 02 예제 2). +- 수업 준비 때 확인: 에러 처리기를 **게시 해제**하면 n8n이 부르지 않아 알림이 0입니다. + +## 5. 그물 지도와 구멍: 감시자를 누가 감시하나 (4교시 Lab 02 예제 3 · 과제 1) + +### 일반 설명 + +> **생활 속 예 · 건전지가 다 된 화재경보기.** 화재경보기는 불을 알리는 장치입니다. 그런데 건전지가 다 되면 불이 나도 **조용합니다.** 그래서 경보기는 건전지가 떨어지면 따로 "삑" 소리를 냅니다. 감시하는 장치가 멈춘 것을 알리는 한 겹이 더 있는 것입니다. 오늘 DLQ가 멈추면 실패한 문의를 넣을 곳이 없는데 워크플로우는 조용히 끝났습니다. 택배 보관소 문이 잠겨 기사가 상자를 그냥 들고 돌아간 셈이니, 최소한 송장 번호(잃을 뻔한 id)를 메모로 남겨야 합니다. + +**실패마다 어느 그물에 걸리는지 표로 그립니다.** 그물이 여럿이면 그물 **사이**에 구멍이 생기기 쉽습니다. + +| 실패 | 그물 | 사람이 아는 방법 | +| --- | --- | --- | +| 잘못된 항목 | 결과의 `rejected` | 부른 쪽이 결과를 봄 | +| API 오류 | DLQ | 알림 (6절) | +| 버그 | Error Workflow | 알림 | +| **실패를 처리하는 장치의 실패** (DLQ 저장 실패, 알림 서버 장애) | ? | ? | + +**처리하는 장치도 실패합니다.** DLQ에 넣는 HTTP 호출이 실패하면? 예제 1에서 "DLQ가 실패해도 응답은 나가게" `neverError`를 두었습니다. 그 결과 워크플로우는 멈추지 않고(→ Error Workflow도 안 불림), DLQ에도 없고, 결과는 `failed`만 말합니다. **조용히 잃습니다.** "감시자를 누가 감시하나(who watches the watchers)"라는 오래된 질문이 운영에서는 이렇게 나타납니다. + +**구멍을 막는 방법.** + +| 방법 | 오늘 | +| --- | --- | +| 처리 장치의 결과를 확인한다 (상태 코드가 아니라 본문의 `stored`) | 과제 1 | +| 실패하면 **다른 길**로 알린다 (더 높은 등급, 잃을 뻔한 id를 알림 안에) | 과제 1 `critical · dlq_down` | +| 결과에도 남긴다 | `dlq_saved: false` | +| 처리 장치 자체를 감시한다 (DLQ가 살아 있나, 알림이 오나) | 3단계 운영 상태 · 79일차 대시보드 | + +### 오늘 실습에서 + +- 고치기 전 `fault tickets error` + `fault dlq error`에서 `batch-ok`: 200 · `failed: 3`, DLQ는 2건 그대로 — R-101~103이 **어디에도 남지 않음**, 알림 없음 (`lab02-nets` 5/7, Lab 02 예제 3). +- 고친 뒤: `dlq_saved: false` + `[critical] dlq_down` 알림 `DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203` (Lab 02 과제 1, `lab02-nets` 7/7). + +## 6. 알림 등급: 무엇을 · 누구에게 · 얼마나 빨리 (4교시 Lab 02 과제 1) + +### 일반 설명 + +> **생활 속 예 · 응급실의 환자 분류.** 응급실은 온 순서대로 보지 않습니다. 심장이 멈춘 환자는 **바로**, 팔이 부러진 환자는 **30분 안에**, 가벼운 감기는 **몇 시간 뒤**에 봅니다 (환자 분류, triage). 휴대폰도 급한 일은 전화로, 덜 급한 일은 문자로, 참고할 일은 앱 알림으로 옵니다. 알림 등급도 같습니다. `critical`(요청을 잃을 수 있음)은 지금 보고, `high`(DLQ에 쌓임)는 원인을 확인해 재처리하고, 거절 · 미분류는 알림 없이 보고서로 셉니다. + +모든 알림이 같은 무게면 받는 사람은 무엇부터 볼지 모릅니다. 운영 조직은 알림에 **등급**(severity)을 두고, 등급마다 누가 · 몇 분 안에 · 무엇을 하는지 정합니다. 이름은 회사마다 다르지만(SEV1~4, P1~P5, critical · high · warning) 생각은 같습니다. + +| 등급 | 오늘 | 받으면 | 예 (흔한 기준) | +| --- | --- | --- | --- | +| `critical` | 예상 못 한 오류, **요청을 잃을 수 있음** (DLQ 멈춤) | 바로 본다, 밤에도 깨운다 | 서비스 전체 장애, 데이터 손실 | +| `high` | DLQ에 쌓임, 계속 실패해 멈춤(parked) | 업무 시간 안에 원인 확인 · 재처리 | 일부 기능 실패, 자동 복구 실패 | +| (알림 없음) | 거절, 미분류 | 결과 · 보고서로 셈 | 사용자 실수, 대체로 처리됨 | + +**알림은 "사람이 할 일이 있을 때만".** 자동으로 처리되었고 사람이 할 일이 없으면 알림이 아니라 **기록**(보고서 · 대시보드)입니다. 구글 SRE 책의 원칙도 같습니다: 알림(page)은 사람이 지금 무엇을 해야 하는 일에만. + +**요청 하나에 알림 하나.****좋은 알림에 담을 것.** + +| 담을 것 | 예 (오늘) | 없으면 | +| --- | --- | --- | +| 무엇이 · 어디서 | `workflow: intake` · `node: Send to DLQ` | 어느 시스템인지부터 찾음 | +| 얼마나 | `count: 2` | 한 건인지 천 건인지 모름 | +| 왜 (오류 문장, 짧게) | `500 - tickets service is failing` | 원인 짐작을 못 함 | +| 자세히 볼 곳 | `execution_id` | 처음부터 다시 찾음 | +| 사람이 할 일 | 런북 주소 · 절 (3단계) | 받은 사람이 할 일을 모름 | +| 잃을 수 있는 것 | `lost ids R-201, R-203` | 사람이 살릴 수 없음 | + + 실패한 항목마다 알림을 보내면 50건 실패에 알림 50개입니다. 같은 원인이면 **하나로 묶고 개수**(`count`)를 담습니다. + +### 오늘 실습에서 + +- 실패 2건 → high 알림 **하나** `count 2`. 실패가 없으면 `alert: none`, 알림 수 그대로 (Lab 02 과제 1). + +## 7. 알림 피로: 같은 알림은 한 번, 대신 숨기지 않기 (4교시 Lab 02 과제 2) + +### 일반 설명 + +> **생활 속 예 · 양치기 소년과 단톡방.** "늑대다!"를 매일 외친 소년의 말은, 정말 늑대가 왔을 때 아무도 믿지 않았습니다. 매일 밤 울리는 주차장 자동차 경보에는 아무도 창문을 열어 보지 않습니다. 하루 수백 개가 오는 단톡방은 결국 알림을 꺼 버립니다. 알림이 많으면 안전한 것이 아니라 **아무도 보지 않게 됩니다.** 그래서 같은 일은 한 번만 알리고 (10분에 한 번), 정말 급한 일(`critical`)은 늘 알립니다. 단톡방 알림을 꺼도 대화 기록은 남는 것처럼, 줄이는 것은 알림이지 기록(DLQ)이 아닙니다. + +**알림 피로(alert fatigue).** 알림이 너무 많으면 사람은 알림을 보지 않게 됩니다. 병원의 의료 기기 경보가 너무 자주 울려 간호사가 무뎌지는 문제로 처음 널리 알려졌고, IT 운영에서도 같은 일이 일어납니다. 그러면 정말 급한 알림도 묻힙니다. + +**같은 문제는 한 번.** 운영 도구(Prometheus Alertmanager · PagerDuty · Opsgenie 등)는 이렇게 줄입니다. + +| 방법 | 뜻 | 오늘 | +| --- | --- | --- | +| 중복 없애기 (deduplication) | 같은 알림이 이미 열려 있으면 새로 만들지 않음 | 같은 `reason`이 10분 안에 있으면 보내지 않음 | +| 묶기 (grouping) | 같은 원인의 알림을 하나로 | 요청 하나에 하나 · `count` | +| 반복 간격 (repeat interval) | 해결될 때까지 정해진 간격으로만 다시 | 10분 | +| 억누르기 (inhibition) | 더 큰 알림이 있으면 작은 것은 숨김 | (오늘은 안 함) | + +**줄이는 것은 알림이지 기록이 아닙니다.****알림 피로를 재는 법.** 알림이 너무 많은지는 느낌이 아니라 숫자로 봅니다. + +| 볼 숫자 | 뜻 | +| --- | --- | +| 하루 · 한 주 알림 수 | 사람이 다 볼 수 있는 양인가 | +| 할 일이 없었던 알림의 비율 | 높으면 알림이 아니라 기록이어야 함 | +| 같은 원인의 반복 수 | 묶기 · 반복 간격이 필요함 | +| 알림 → 사람이 확인하기까지 | 늘어나면 이미 무뎌짐 | + +구글 SRE 책은 당번(on-call) 한 번에 처리할 사건 수를 적게 유지하라고 권합니다. 사건이 너무 많으면 알림을 줄이거나 원인을 고치는 일이 먼저입니다. + + 보내지 않은 알림도 결과에 `suppressed`로 남기고, 실패한 요청은 **DLQ에 모두** 남깁니다. 그리고 critical은 줄이지 않습니다: DLQ가 멈춘 알림을 10분에 한 번만 보내면, 그 10분 동안 잃을 뻔한 id가 알림에 남지 않습니다. + +### 오늘 실습에서 + +| | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 10분 안에 두 번째 실패 | `alert: sent`, dlq 알림 2 | `alert: suppressed`, dlq 알림 1 | +| DLQ | 5건 | **5건 모두** | +| DLQ 멈춤 (critical) | `sent` | `sent` (늘) | + +## 8. 요청문(프롬프트)으로 에이전트에게 맡기기 + +| 넣을 것 | 예 | +| --- | --- | +| 언제 · 어디로 | "`failed`가 1 이상일 때만 `POST /dlq`, 본문 `{items: [{item: 원래 요청, reason, workflow}]}`" | +| 넣지 않을 것 | "`rejected`는 넣지 않는다" | +| 처리 장치가 실패하면 | "DLQ 응답에 `stored`가 없으면 critical `dlq_down`, 잃을 뻔한 id를 message에" | +| 처리기의 고정 id · 설정 | "파일 맨 위 `"id": "errHandler76001"`, 부르는 쪽 `settings.errorWorkflow`" | +| 알림 모양 · 등급 · 수 | "요청 하나에 하나, `{workflow, severity, reason, count, message}`" | +| 줄이는 규칙 | "같은 `reason`의 high가 10분 안에 있으면 보내지 않고 `suppressed`, critical은 늘" | +| 읽기는 읽기로 | "확인은 `/alerts/list` (POST `/alerts`는 알림을 만든다)" | + +## 9. 자주 하는 실수 + +| 실수 | 결과 | 바로잡기 | +| --- | --- | --- | +| 거절도 DLQ에 | 재처리 때마다 같은 실패 | DLQ는 다시 하면 될 것만 | +| 분류 결과 등 중간 값을 DLQ에 | 재처리 때 원래 요청을 모름 | 원래 요청 그대로 | +| 처리기를 게시하지 않음 · id가 다름 | 알림 0 | `"id"` 고정 · push | +| `neverError`만 두고 끝 | DLQ가 멈추면 조용히 잃음 | 본문 확인 · critical | +| 항목마다 알림 | 50건에 50개 → 아무도 안 봄 | 요청 하나에 하나 · `count` | +| 알림을 줄이려고 DLQ에서도 뺌 | 실패를 숨김 | 알림만 줄이고 기록은 그대로 | +| 테스트가 `POST /alerts`로 확인 | 확인이 알림을 만듦 | 읽기 전용 `/alerts/list` | + +## 확인 질문 + +
1. DLQ에 무엇을 넣고, 무엇을 넣지 않나요? 왜요? + +원래 요청 그대로와 실패 이유 · 워크플로우를 넣습니다. 재처리 때 같은 입구로 다시 보내기 위해서입니다. 다시 해도 같은 실패(빈 글 같은 거절)는 넣지 않습니다. 섞이면 재처리할 때마다 되풀이되고 살려야 할 것이 묻힙니다. +
+ +
2. DLQ가 있는데 Error Workflow도 필요한 까닭은? + +DLQ는 예상한 실패(API가 멈춤)를 그 자리에서 처리한 것입니다. 버그처럼 예상하지 못한 오류는 워크플로우를 멈추게 하고, 그때는 n8n이 부르는 Error Workflow만 알 수 있습니다. 오늘 `nightly-report`의 버그는 처리기 없이는 알림 0, 처리기를 게시한 뒤 3초 만에 critical 알림이 생겼습니다. +
+ +
3. DLQ 저장이 실패하면 오늘 워크플로우는 어떻게 되었고, 어떻게 막았나요? + +DLQ 노드에 `neverError`를 두었기 때문에 워크플로우는 멈추지 않았고(Error Workflow도 안 불림), 세 요청은 DLQ에도 없고 결과도 `failed`만 말했습니다. DLQ 응답의 `stored`를 확인해, 없으면 critical `dlq_down` 알림에 잃을 뻔한 id를 적고 결과에 `dlq_saved: false`를 남겨 막았습니다. +
+ +
4. 알림을 줄이면 위험하지 않나요? + +줄이는 것은 같은 문제의 반복 알림이지, 실패의 기록이 아닙니다. 오늘 두 번째 실패의 알림은 `suppressed`로 결과에 남고, DLQ에는 5건 모두 있습니다. 그리고 critical은 줄이지 않습니다. 알림이 너무 많으면 사람이 알림을 끄게 되고, 그것이 더 위험합니다. +
diff --git a/Lecture/76/03-STAGE3-REPLAY-AND-RUNBOOK.md b/Lecture/76/03-STAGE3-REPLAY-AND-RUNBOOK.md new file mode 100644 index 0000000..50a5d6c --- /dev/null +++ b/Lecture/76/03-STAGE3-REPLAY-AND-RUNBOOK.md @@ -0,0 +1,363 @@ +# 3단계 · 재처리와 운영 체계 + +- 교시: 5교시 강의 (14:00~14:50) · 6교시 [Lab 03](../../Last3Mon/76/LAB-03-Q-REPLAY-AND-RUNBOOK.md) 실습 5개 (15:00~15:50) +- 이 단계가 끝나면: 원인을 고친 뒤 DLQ의 요청을 같은 입구로 중복 없이 다시 돌리고, 알림을 받았을 때 할 일을 런북으로 써서 짝과 장애 연습을 하고, 반쯤 처리된 요청도 재처리가 안전한 까닭을 말하고, 계속 실패하는 요청은 정해진 횟수 뒤 사람에게 넘기고, "지금 괜찮은가"를 한 번에 알려 주는 상태 웹훅을 만들 수 있다. +- 숫자: 모두 강사 노트북 실측입니다 (10/29, 정답 워크플로우를 학생 순서대로 1회). 출력 전부는 수업 뒤 공개되는 `Last3Mon/76/answers/LAB-TEST-RESULT.md`에 있습니다. +- 실습 가리키기: "Lab 03 예제 1"은 6교시 [Lab 03 문서](../../Last3Mon/76/LAB-03-Q-REPLAY-AND-RUNBOOK.md)의 그 절입니다. + +```mermaid +flowchart LR + A["1. 재처리"] --> B["2. 같은 입구로"] --> C["3. 런북"] --> D["4. 장애 연습"] + D --> E["5. 최소 한 번 + 멱등"] --> F["6. 끝없이 돌리지 않기"] --> G["7. 운영 상태"] --> H["8. 운영 체계 한 장"] --> I["9. 요청문(프롬프트)으로"] +``` + +## 0. 이 시간 (50분) + +| 시간 | 절 · 개요 | 하는 일 | +| --- | --- | --- | +| 14:00~14:08 | 1 · 2 · 개요 22장 | 재처리(redrive), 같은 입구로 다시 | +| 14:08~14:18 | 3 · 4 · 개요 23~24장 | 런북, 장애 연습 (game day) | +| 14:18~14:25 | 5 · 개요 25장 | 최소 한 번 + 멱등 = 결과는 한 번 | +| 14:25~14:33 | 6 · 개요 26장 | 최대 시도 수, parked, 사람에게 넘기기 | +| 14:33~14:40 | 7 · 개요 27장 | 운영 상태, health check · status page | +| 14:40~14:50 | 8 · 9 · 10 · 개요 28장 · 확인 질문 | 운영 체계 한 장, 요청문, 자주 하는 실수 | + +### 6교시 Lab 03에서 이어서 하는 것 + +| 6교시 실습 (시각) | 이 강의의 절 | 실습에서 하는 일 | 쓰는 파일 · 명령 | +| --- | --- | --- | --- | +| Lab 03 예제 1 (15:00~15:12) | 1 · 2 | 재처리 `my-replay` | `my-work/replay.json`, 테스트 `cases/lab03.json` | +| Lab 03 예제 2 (15:12~15:24, 에이전트 없이) | 3 · 4 | 런북 쓰기 · 짝 연습 | `RUNBOOK.md` 2 · 3절, `test cases/lab03-drill.json -v` | +| Lab 03 예제 3 (15:24~15:30, 에이전트 없이) | 5 | 반쯤 처리된 요청을 재처리 | `test cases/lab03-half.json -v` | +| Lab 03 과제 1 (15:30~15:40) | 6 | 세 번 실패하면 parked | 테스트 `cases/lab03-challenge.json` | +| Lab 03 과제 2 (15:40~15:50) | 7 | 운영 상태 `my-ops-status` | `my-work/ops-status.json`, 테스트 `cases/lab03-challenge2.json` | + +## 1. 재처리: 모은 것은 다시 돌려야 끝난다 (6교시 Lab 03 예제 1) + +### 일반 설명 + +> **생활 속 예 · 택배 재배송.** 보관소에 둔 택배는 보관만 해서는 일이 끝나지 않습니다. 받는 사람이 집에 있는 날 **다시 배송**하고, 전하면 보관소 목록에서 **지워야** 끝납니다. 다시 배송하다 또 부재면 목록에 그대로 두고 다음에 또 시도합니다. 재처리는 DLQ(보관소)의 요청을 원인이 고쳐진 뒤 다시 돌리는 이 재배송입니다. + +DLQ는 실패를 잃지 않게 해 줄 뿐, 일을 끝내 주지는 않습니다. 원인(API 장애)을 고친 뒤 모아 둔 요청을 **다시 돌려야** 문의가 접수됩니다. 이것을 재처리(replay, reprocess)라고 하고, 메시지 큐에서는 DLQ의 메시지를 원래 큐로 되돌린다는 뜻으로 redrive라고 부릅니다 (Amazon SQS의 DLQ redrive). + +**재처리의 순서.** + +```mermaid +flowchart LR + F["원인 고침"] --> C["확인
(다시 실패하지 않을지)"] --> R["재처리
하나씩"] --> V["확인
DLQ 0 · 중복 없음"] + R -->|또 실패| D[("DLQ에 남음
attempts +1")] +``` + +| 원칙 | 까닭 | +| --- | --- | +| **원인을 고친 뒤에** | 고치기 전에 돌리면 또 실패하고 `attempts`만 오름 (6절) | +| **하나씩** (또는 작은 묶음으로) | 하나가 실패해도 다른 것은 됨, 어느 것이 실패했는지 보임 | +| **성공한 것만 지운다** | 실패한 것은 남아 다음 재처리 · 사람이 봄 | +| **천천히** | 쌓인 수천 건을 한꺼번에 보내면 막 살아난 서비스가 다시 쓰러짐 | +| 재처리 전후로 **센다** | DLQ 수 · 접수 건 수 · 중복 | + +**언제 돌리나.** 사람이 원인을 확인하고 손으로(오늘), 정해진 시간마다 자동으로(Schedule Trigger), 또는 상태가 좋아지면 자동으로. 자동으로 돌릴수록 6절의 "끝없이 돌리지 않기"가 더 중요합니다. + +| 언제 돌리나 | 좋은 점 | 조심할 것 | +| --- | --- | --- | +| 사람이 손으로 (오늘) | 원인을 확인한 뒤라 안전 | 사람이 잊으면 쌓임 | +| 정해진 시간마다 (Schedule Trigger) | 잊지 않음 | 원인이 안 고쳐졌으면 또 실패 → `attempts`만 | +| 상태가 좋아지면 자동으로 | 빠름 | "좋아졌다"를 무엇으로 판단하나 (health check · 시험 요청 하나) | + +**순서가 중요한 일.** 접수는 순서가 바뀌어도 괜찮지만, "주소 바꾸기 → 배송"처럼 순서가 중요한 일은 재처리가 순서를 뒤집으면 안 됩니다 (예전 주소로 배송). 그런 일은 같은 대상(같은 사람 · 주문)의 요청을 묶어 차례대로 재처리하거나, 최신 상태를 다시 읽고 처리합니다. + +### 오늘 실습에서 + +- 고치기 전: `my-replay`가 없어 404, DLQ 2 · 접수 건 0. +- 고친 뒤: `replayed: 2, ok: 2` (1.1초), DLQ 0, 접수 건 2 — 중복 없음. 한 번 더 돌리면 `replayed: 0` (Lab 03 예제 1). + +## 2. 새로 만들지 말고 같은 입구로 다시 보내기 + +### 일반 설명 + +> **생활 속 예 · 은행 창구.** 은행 마감 뒤 맡겨 둔 서류를 다음 날 처리할 때, 직원이 "어제 거니까"라며 뒷문으로 신분 확인 · 서명 없이 처리하면 안 됩니다. **같은 창구, 같은 절차**(신분 확인 → 서명 → 처리)를 거쳐야 합니다. 뒷문 절차를 따로 만들면 언젠가 두 절차가 달라져, 어제 서류만 확인이 빠지는 구멍이 생깁니다. 재처리가 접수 건을 직접 만들지 않고 `my-intake`(같은 창구)로 다시 보내는 까닭입니다. + +**재처리가 일을 직접 하면 규칙이 둘이 됩니다.** 재처리 워크플로우가 접수 건을 직접 만들면 항목 확인 · 분류 · 멱등 키 · DLQ · 알림을 또 만들어야 하고, 언젠가 두 곳의 규칙이 달라집니다 (원래 입구는 고쳤는데 재처리는 옛 규칙). 그래서 재처리는 원래 요청을 **원래 입구로** 다시 보냅니다. 70일차에 공통 모듈을 하나 두고 여러 곳에서 부른 것과 같은 생각입니다. + +| 재처리가 | 장점 | 단점 | +| --- | --- | --- | +| 원래 입구(`my-intake`)를 다시 부름 | 규칙이 하나, 재처리도 같은 키 · 알림 · DLQ | 입구가 받을 수 있는 모양이어야 함 | +| 일을 직접 함 | 빠를 수 있음 | 규칙이 둘, 하나만 고치는 사고 | + +**성공은 결과로 판단합니다.** `my-intake`는 부분 성공이라 늘 200입니다. HTTP 상태가 아니라 결과의 `created`로 판단합니다. + +## 3. 런북: 그때 할 일을 미리 써 두기 (6교시 Lab 03 예제 2) + +### 일반 설명 + +> **생활 속 예 · 비행기 비상 체크리스트와 엘리베이터 안내문.** 조종사는 엔진에 불이 나면 기억에 의존하지 않고 **비상 체크리스트**를 펴서 한 줄씩 따라 합니다. 아무리 숙련된 조종사도 급할 때는 단계를 빠뜨리기 때문입니다. 엘리베이터 안에는 "갇혔을 때: ① 비상벨을 누른다 ② 관리실 ○○○-○○○○ ③ 문을 억지로 열지 않는다"가 붙어 있습니다. 처음 탄 사람도 따라 할 수 있고, **하지 말 것**까지 적혀 있습니다. 런북은 알림 하나에 대한 이런 안내문입니다. + +**런북(runbook)**은 특정 알림 · 장애가 났을 때 할 일을 순서대로 적은 문서입니다. 플레이북(playbook)이라고도 부릅니다. 장애는 대개 바쁠 때, 밤에, 그 시스템을 만든 사람이 없을 때 납니다. 그때 머릿속으로 떠올리면 단계를 빠뜨리고, 급한 마음에 하지 말아야 할 일(DLQ 비우기)을 합니다. 구글 SRE 책은 미리 써 둔 플레이북이 장애 회복 시간(MTTR)을 약 세 배 줄인다고 말합니다. + +**좋은 런북의 모양.** + +| 갖출 것 | 나쁜 예 | 좋은 예 | +| --- | --- | --- | +| 어느 알림에 대한 것인지 | "장애 대응" | "`high · dlq` 알림을 받으면" | +| 명령을 그대로 | "DLQ를 확인한다" | `uv run tools/flow.py ops` | +| 확인은 무엇을 보면 되는지 | "정상인지 확인" | "`ops`의 DLQ 0, 같은 key가 둘이 아닌지" | +| 갈림길 | (없음) | "`critical · unexpected`면 코드 담당에게, `high · dlq`면 3번으로" | +| **하지 말 것** | (없음) | "DLQ를 비우지 말 것 — 재처리할 요청이 사라짐", "parked를 자동으로 돌리지 말 것" | +| 누구 · 언제까지 | (없음) | "1차 대응 ○○, 30분 안에 / parked는 데이터 담당이 다음 업무일까지" | +| 마지막으로 고친 날 | (없음) | 시스템이 바뀌면 런북도 바뀜 | + +**알림과 런북을 잇습니다.****런북의 뼈대 (오늘 `RUNBOOK.md`와 같음).** + +```text +제목: high · dlq 알림을 받으면 (마지막으로 고친 날: 2026-10-30) +1. 확인 — 명령 · 볼 숫자 +2. 나누기 — 알림의 reason · severity에 따라 갈림길 +3. 원인 — 누구에게 묻나 · 무엇을 보나 +4. 고침 — (오늘은 고장 스위치) +5. 되돌리기 — 재처리 명령 +6. 확인 — DLQ 0 · 같은 key 없음 · 운영 상태 ok +하지 말 것 — DLQ 비우기 · parked 자동 재처리 · 급한 배포 +누가 · 언제까지 — 1차 대응 · 데이터 담당 · parked 결정 +``` + + 알림 메시지에 런북의 주소(또는 절 번호)를 넣으면, 알림을 받은 사람이 바로 할 일로 갑니다. 많은 회사가 알림 규칙마다 `runbook_url`을 붙입니다. + +### 오늘 실습에서 + +- `ops` 한 번에 DLQ 2건과 알림 둘: `[high] intake · dlq · count 2`, `[critical] … unexpected · rows is not iterable`. **원인이 다른 두 알림**입니다 (Lab 03 예제 2). +- 고장 스위치를 되돌리고 재처리하자 DLQ 0 · 접수 건 2. 그런데 critical 알림은 남았습니다 — 버그는 재처리로 고쳐지지 않습니다. 런북에 갈림길이 있어야 하는 까닭입니다. + +## 4. 장애 연습: 써 둔 런북이 맞는지 일부러 장애를 내 본다 + +### 일반 설명 + +> **생활 속 예 · 소방 훈련.** 학교와 회사는 불이 나지 않았는데도 일부러 경보를 울리고 모두 대피해 봅니다. 해 보면 "비상구 앞에 상자가 쌓여 있다", "3층 경보가 안 들린다", "모이는 곳을 모르는 사람이 있다" 같은 문제가 **불이 나기 전에** 드러납니다. 대피 안내문(런북)은 써 둔 것만으로는 맞는지 모릅니다. 오늘 짝 연습에서 한 사람이 일부러 장애를 내고 다른 사람이 런북만 보고 대응하는 것이 이 소방 훈련입니다. + +런북은 써 두기만 하면 틀린 줄을 모릅니다. **일부러 장애를 내고, 다른 사람이 런북만 보고 대응**해 봐야 빠진 단계가 드러납니다. + +| 이름 | 무엇 | +| --- | --- | +| 장애 연습 (game day, fire drill) | 정해진 날 정해진 장애를 내고 팀이 대응해 봄 | +| 카오스 엔지니어링 | 운영 환경에서 일부러 고장(서버 끄기 · 지연)을 내 약한 곳을 찾음. Netflix의 Chaos Monkey가 유명 | +| 사후 검토 (postmortem) | 장애가 끝난 뒤 무엇이 · 왜 · 다음엔 어떻게를 **탓하지 않고**(blameless) 적음 | + +**사후 검토에서 묻는 것.** 장애나 연습이 끝나면 짧게라도 적습니다. "누가 잘못했나"가 아니라 "**시스템과 절차의 무엇이 이것을 가능하게 했나**"를 묻습니다 (탓하지 않는 사후 검토). + +| 질문 | 오늘 연습이라면 | +| --- | --- | +| 무슨 일이 언제 있었나 (시간 순서) | 장애 시작 · 알림 · 알아챔 · 고침 · 재처리 | +| 영향은 | 문의 몇 건이 몇 분 늦었나, 잃은 것은 없나 | +| 왜 알아채는 데 그만큼 걸렸나 | 알림은 있었나, 누가 봤나 | +| 무엇이 잘 됐나 | DLQ · 키 덕분에 잃지 않고 중복 없음 | +| 다음에 바꿀 것 (담당 · 기한) | 런북에 갈림길 더하기 | + +**연습에서 재는 것.** + +| 잴 것 | 뜻 | +| --- | --- | +| 알아채기까지 (MTTD) | 장애 시작 → 사람이 앎 | +| 회복까지 (MTTR) | 장애 시작 → 다시 정상 | +| 런북에 없던 단계 | 다음 판 런북에 더할 것 | + +### 오늘 실습에서 + +- 짝 연습: 한 사람이 `fault tickets error` · `call my-intake` · `call nightly-report`, 다른 사람이 런북만 보고 `ops → fault ok → my-replay → ops`. `RUNBOOK.md` 3절에 시간과 빠진 단계를 적습니다. +- 같은 연습을 테스트로: `lab03-drill` 7/7, 접수 건 5 (평소 3 + 재처리 2), 중복 없음 (Lab 03 예제 2). + +## 5. 최소 한 번 + 멱등 = 결과는 한 번 (6교시 Lab 03 예제 3) + +### 일반 설명 + +> **생활 속 예 · 엘리베이터 버튼.** 급한 사람은 엘리베이터 버튼을 여러 번 누릅니다. 그래도 엘리베이터는 한 대만 옵니다. 버튼이 "이미 불렀음"을 기억하기 때문입니다 (멱등). 누르는 쪽이 "한 번만 눌러야지"를 지키게 하는 것보다, **받는 쪽이 여러 번 눌러도 한 번만 처리**하게 만드는 편이 훨씬 쉽고 확실합니다. 재시도 · 재처리가 있으면 같은 요청이 여러 번 갈 수 있으니 (최소 한 번), 받는 쪽을 이 버튼처럼 만듭니다 (멱등 키). + +**전달 보장의 세 가지.** 메시지 · 요청을 보내는 시스템은 보통 셋 중 하나를 약속합니다. + +| 보장 | 뜻 | 대가 | +| --- | --- | --- | +| 최대 한 번 (at-most-once) | 다시 보내지 않음 | 실패하면 **잃음** | +| 최소 한 번 (at-least-once) | 될 때까지 다시 보냄 | 같은 것이 **두 번 이상** 갈 수 있음 | +| 정확히 한 번 (exactly-once) | 꼭 한 번 | 네트워크 너머에서는 매우 어려움 (특별한 저장 · 조정 필요) | + +오늘 재시도 · DLQ · 재처리는 **최소 한 번**입니다. 잃지 않는 대신 같은 요청이 두 번 이상 갈 수 있습니다. 1단계 5절의 시간 초과처럼, DLQ에 들어간 요청이 **사실은 이미 처리된 것**일 수도 있습니다. 그래서 받는 쪽을 **멱등**하게 만들어 "여러 번 가도 결과는 한 번"을 만듭니다. 실무에서 "effectively once"라고 부르는 방식입니다. + +```mermaid +flowchart LR + A["최소 한 번
재시도 · DLQ · 재처리"] --> P["같은 요청이
두 번 이상"] --> K["받는 쪽이 멱등
키 = 요청 id"] --> O(["결과는 한 번"]) +``` + +### 오늘 실습에서 + +- `slow`에서 11.6초 뒤 `failed: 2`, DLQ 2건. 8초 뒤 접수 건 **이미 2개** (데이터 API가 늦게 처리) — 같은 문의가 DLQ에도, 접수 건에도. +- 재처리: `replayed: 2, ok: 2, duplicates: 2`, 접수 건 2개 그대로 (Lab 03 예제 3). 1단계의 멱등 키가 재처리까지 지켜 줬습니다. + +## 6. 끝없이 돌리지 않기: 최대 시도 수와 사람에게 넘기기 (6교시 Lab 03 과제 1) + +### 일반 설명 + +> **생활 속 예 · 택배 반송과 비밀번호 잠금.** 택배는 재배송을 무한히 하지 않습니다. 몇 번 시도해도 못 전하면 멈추고 **보낸 사람에게 연락**합니다. 은행 앱은 비밀번호를 5번 틀리면 잠기고, 그다음은 **창구에서 사람이** 확인합니다. 자동으로 다시 하는 데에는 끝이 있어야 하고, 그 끝은 "포기"가 아니라 "사람에게 넘기기"입니다. 오늘의 `parked`가 이것입니다. + +**원인이 고쳐지지 않은 요청을 계속 돌리면 독이 됩니다.** 매번 API를 두드리고, 알림이 쌓이고, 새로 들어온 실패가 묻힙니다. 어떤 요청은 영영 성공하지 못합니다 (데이터 API가 받지 않는 내용, 지워진 사용자). 그런 메시지를 "독 메시지(poison message)"라고 부릅니다. + +**정해진 횟수 뒤 따로 빼기.** + +| 시스템 | 방법 | +| --- | --- | +| Amazon SQS | `maxReceiveCount`를 넘으면 DLQ로 | +| 많은 작업 큐 (Sidekiq · Celery 등) | 최대 재시도 뒤 "죽은 작업" 목록으로 | +| 오늘 | `attempts ≥ 3`이면 다시 보내지 않고 `parked: true` + high 알림 | + +**사람에게 넘기기 (human in the loop).****parked를 받은 사람이 하는 결정.** + +| 보이는 것 | 결정 | +| --- | --- | +| 요청 자체가 이상함 (영영 받지 않을 글) | 요청한 사람에게 돌려보내고 DLQ에서 지움 (거절로 바꿈) | +| 데이터 API의 다른 문제 (권한 · 설정) | 그쪽을 고친 뒤 parked를 풀어 재처리 | +| 이미 다른 길로 처리됨 (전화로 접수) | 기록을 남기고 지움 | +| 모르겠음 | 담당에게 넘기고 기한을 정함 (런북 4절) | + + parked는 "포기"가 아니라 "**자동으로는 여기까지, 이제 사람이**"입니다. 사람이 보고 요청을 고치거나, 데이터 API의 다른 문제를 고치거나, 정말 버릴지 정합니다. 그래서 parked는 지우지 않고, 런북에 **누가 · 언제까지**를 적습니다. + +**몇 번이 좋은가.** 숫자 자체보다 **간격 × 횟수 = 포기하기까지 걸리는 시간**으로 정합니다. + +| 재처리 간격 | 3번이면 | 맞는 경우 | +| --- | --- | --- | +| 1분마다 (자동) | 3분 | 잠깐의 장애가 대부분일 때 | +| 1시간마다 (자동) | 3시간 | 공급자 장애가 몇 시간 갈 수 있을 때 | +| 하루 한 번 (사람) | 3일 | 담당자가 확인하고 돌릴 때 | + +### 오늘 실습에서 + +| | 고치기 전 | 고친 뒤 | +| --- | --- | --- | +| 재처리 3 (`attempts` 3) | 또 보냄 `replayed: 2` (2.0초) | **0.1초** `parked: 2`, `[high] parked` | +| 고친 뒤 재처리 | 자동으로 다시 보냄 | `replayed: 0` (사람이 보기 전에는 안 돌림) | + +## 7. 운영 상태: "지금 괜찮은가"를 한 번에 (6교시 Lab 03 과제 2) + +### 일반 설명 + +> **생활 속 예 · 미세먼지 예보와 자동차 계기판.** 미세먼지 앱은 측정값 여럿(PM10 · PM2.5 · 오존)을 늘어놓지 않고 **좋음 · 보통 · 나쁨**과 "마스크를 쓰세요"를 먼저 보여 줍니다. 자동차 계기판도 숫자 대신 초록 · 노랑 · 빨강 등을 켭니다. 처음 보는 사람도 "지금 괜찮은가, 무엇을 해야 하나"를 바로 압니다. 운영 상태 웹훅은 DLQ · parked · 알림 숫자를 이 신호등(`ok` · `warn` · `critical`)과 까닭으로 줄입니다. + +**상태 확인(health check).** 운영 중인 서비스는 대개 "살아 있나 · 일할 수 있나"를 알려 주는 주소를 둡니다. 쿠버네티스는 그 주소(liveness · readiness probe)로 컨테이너를 다시 띄울지, 요청을 보낼지 정합니다. 많은 회사가 고객이 보는 **상태 페이지(status page)**도 둡니다 (정상 · 일부 장애 · 장애). + +**숫자 여럿을 신호 하나로.** 사람에게는 DLQ 수 · parked 수 · 알림 수를 각각 주는 것보다 **신호등 하나와 까닭**이 빠릅니다. + +| 신호 | 기준 (오늘) | 사람이 할 일 | +| --- | --- | --- | +| `ok` | 아래 둘 다 아님 | — | +| `warn` | parked가 아닌 DLQ가 있음, 10분 안 high 알림 | 원인을 고친 뒤 재처리 | +| `critical` | parked가 있음, 10분 안 critical 알림 | 지금 봄 · 담당에게 | + +- 더 나쁜 쪽을 먼저 봅니다 (둘 다 맞으면 `critical`). 까닭(`reasons`)은 맞는 것을 모두 적습니다. + +**무엇을 묻는 상태인가.** 상태 확인에도 종류가 있습니다. + +| 종류 | 묻는 것 | 예 | +| --- | --- | --- | +| 살아 있나 (liveness) | 프로그램이 멈추지 않았나 | 데이터 API `/health` → 다시 띄울지 | +| 받을 수 있나 (readiness) | 지금 요청을 받아도 되나 | DB 연결 · 의존 서비스 확인 → 요청을 보낼지 | +| 일이 잘 되나 (오늘) | 실패가 쌓였나 · 사람이 볼 일이 있나 | `my-ops-status` → 사람이 무엇을 할지 | + +오늘 데이터 API의 `/health`는 고장 스위치가 켜져 있어도 `ok`입니다 (살아 있기는 하니까). 그래서 "살아 있다"만으로는 "일이 잘 된다"를 말할 수 없고, 오늘 만든 운영 상태가 따로 필요합니다. + +**확인은 상태를 바꾸지 않습니다.** 상태를 묻는 것이 DLQ나 알림을 바꾸면, 묻는 횟수에 따라 결과가 달라집니다. 2단계에서 테스트가 `/alerts`에 POST해 빈 알림을 만든 일이 그 예입니다. 상태 웹훅은 **읽기만** 합니다. + +### 오늘 실습에서 + +- `ok` → 실패 2건 뒤 `warn` (DLQ 2, "원인을 고친 뒤 재처리") → 버그 뒤 `critical` (critical 1) → 재처리 · 알림 확인 뒤 `ok` (Lab 03 과제 2, 7/7). 상태 웹훅은 0.1초 안팎 (읽기만, LLM 없음). +- Lab 02 과제 2 테스트가 남긴 상태를 읽으면 `critical` · DLQ 5 · 까닭 셋 (`critical 알림 1건 (10분)`, `DLQ 5건: 원인을 고친 뒤 재처리`, `high 알림 1건 (10분)`). + +## 8. 운영 체계 한 장 + +오늘 하루 만든 것을 모으면 하나의 체계입니다. + +> **생활 속 예 · 택배 회사로 본 오늘 하루.** 오늘 만든 것은 모두 택배 회사에 이미 있습니다. +> +> | 오늘 만든 것 | 택배 회사에서는 | +> | --- | --- | +> | 부분 성공 (항목마다 상태) | 상자마다 배송 조회 | +> | 입구 규칙 · 거절 | 접수 창구에서 주소 없는 상자를 바로 돌려줌 | +> | 재시도 | 벨을 한 번 더 누름 | +> | 멱등 키 | 송장 번호 (같은 상자를 두 번 배달하지 않음) | +> | 우아한 저하 | 분류기가 고장 나도 손으로 분류해 배송은 계속 | +> | DLQ | 부재 시 보관소 | +> | Error Workflow | 사고 나면 본사 상황실로 자동 보고 | +> | 알림 등급 · 폭주 막기 | 사고는 즉시 전화, 지연은 하루 한 번 요약 | +> | 재처리 | 다음 날 재배송 | +> | parked | 세 번 부재면 보낸 사람에게 연락 | +> | 런북 · 장애 연습 | 사고 대응 매뉴얼 · 정기 훈련 | +> | 운영 상태 | 상황실 화면의 초록 · 노랑 · 빨강 | + +```mermaid +flowchart TB + subgraph 막기["막기 (1단계)"] + V["입구 규칙 · 항목 확인"] --> RT["재시도 · 시간 제한"] --> K["멱등 키"] --> G["우아한 저하 (미분류)"] + end + subgraph 모으고알리기["모으고 알리기 (2단계)"] + D[("DLQ")] --> A["알림 high · critical
10분에 한 번"] + EW["Error Workflow"] --> A + end + subgraph 되돌리기["되돌리기 (3단계)"] + RB["런북 · 연습"] --> RP["재처리 (같은 입구)"] --> PK["parked → 사람"] + ST["운영 상태 ok · warn · critical"] + end + 막기 --> 모으고알리기 --> 되돌리기 +``` + +| 질문 | 답하는 것 | +| --- | --- | +| 잘못된 요청은? | 입구 규칙 · 거절 + 이유 | +| 잠깐 끊기면? | 재시도 (키와 함께) | +| 오래 멈추면? | DLQ → 알림 → 재처리 | +| 예상 못 한 오류는? | Error Workflow → critical | +| 처리 장치가 멈추면? | critical `dlq_down` · 잃을 뻔한 id | +| 계속 실패하면? | parked → 사람 | +| 지금 괜찮은가? | 운영 상태 | +| 그때 누가 무엇을? | 런북 · 연습 | + +## 9. 요청문(프롬프트)으로 에이전트에게 맡기기 + +| 넣을 것 | 예 | +| --- | --- | +| 다시 보내는 곳 · 모양 | "항목마다 `POST /webhook/my-intake` `{requests: [item]}`" | +| 성공 판단 | "`my-intake` 결과의 `created`가 1이면 성공 (HTTP는 늘 200)" | +| 지우기 · 남기기 | "성공만 `dlq/update remove`, 실패는 그대로" | +| 비었을 때 | "`alwaysOutputData`, 할 일 없음 갈래" | +| 멈추는 기준 | "`attempts >= max_attempts`(기본 3)면 parked + high 알림, 지우지 않음" | +| 상태 규칙의 순서 | "critical 조건을 먼저, 까닭은 모두" | +| 읽기만 | "상태 웹훅은 DLQ · 알림을 바꾸지 않는다" | + +## 10. 자주 하는 실수 + +| 실수 | 결과 | 바로잡기 | +| --- | --- | --- | +| 원인을 고치기 전에 재처리 | 또 실패, `attempts`만 오름 → 금방 parked | 런북: 확인 → 재처리 | +| 재처리가 접수 건을 직접 만듦 | 규칙이 둘 | 같은 입구로 | +| HTTP 상태로 성공 판단 | 늘 성공처럼 보임 | 결과의 `created` | +| 쌓인 것을 한꺼번에 | 막 살아난 서비스가 다시 쓰러짐 | 하나씩 · 천천히 | +| parked를 지움 | 사람이 볼 것이 사라짐 | 지우지 않기 | +| 급해서 DLQ를 비움 | 재처리할 요청이 사라짐 | 런북 "하지 말 것" | +| 런북에 "확인한다"만 | 무엇을 봐야 할지 모름 | 볼 숫자까지 | +| 상태 확인이 상태를 바꿈 | 볼 때마다 결과가 달라짐 | 읽기만 | + +## 확인 질문 + +
1. 재처리가 접수 건을 직접 만들지 않고 my-intake를 다시 부르는 까닭은? + +규칙을 하나로 두기 위해서입니다. 직접 만들면 항목 확인 · 분류 · 키 · DLQ · 알림을 또 만들어야 하고, 한쪽만 고치는 사고가 납니다. 같은 입구로 보내면 재처리도 같은 키로 중복을 막고, 또 실패하면 같은 DLQ로 돌아가 `attempts`가 오릅니다. +
+ +
2. DLQ의 요청을 재처리했더니 duplicates: 2가 나왔습니다. 무엇이 일어난 건가요? + +그 요청들이 사실은 이미 처리되어 있었습니다. 데이터 API가 느려 워크플로우는 시간 초과로 포기하고 DLQ에 넣었지만, 데이터 API는 늦게라도 접수 건을 만들었습니다. 재처리가 같은 키로 보내자 처음 번호를 돌려받았습니다. 최소 한 번 보내고 받는 쪽이 멱등하면 결과는 한 번입니다. +
+ +
3. 3번 실패한 요청을 parked로 두는 것과 지우는 것은 무엇이 다른가요? + +parked는 자동 재처리에서만 빼고 DLQ에 남깁니다. 사람이 원인을 보고 고친 뒤 다시 돌리거나 버릴지 정합니다. 지우면 그 문의는 사라집니다. 런북에 parked를 누가 언제까지 보는지 적습니다. +
+ +
4. 좋은 런북과 나쁜 런북의 차이를 오늘 연습으로 말해 보세요. + +좋은 런북은 어느 알림에 대한 것인지, 그대로 복사해 쓸 명령, 무엇을 보면 확인인지(DLQ 0 · 같은 key 없음), 갈림길(critical unexpected는 코드 담당에게), 하지 말 것(DLQ 비우기), 누가 언제까지를 적습니다. 오늘 연습에서 재처리 뒤에도 critical 알림이 남은 것은 갈림길이 없는 런북으로는 당황하기 쉬운 장면이었습니다. +
diff --git a/Lecture/76/04-STAGE4-WRAPUP.md b/Lecture/76/04-STAGE4-WRAPUP.md new file mode 100644 index 0000000..f2e17de --- /dev/null +++ b/Lecture/76/04-STAGE4-WRAPUP.md @@ -0,0 +1,219 @@ +# 4단계 · 정리: 오늘 한 일의 의미 · 효과 · 실전 응용 + +- 교시: 8교시 17:10~17:45 (35분, 바이브 코딩 발표 10분 뒤) · 자료: 이 문서 + [개요](00-DAY76-OVERVIEW.md) 30장 +- 이 시간이 끝나면: 오늘 1~3단계에서 한 일이 **무엇을 지키기 위한 것이었는지**, 숫자로 **무엇이 달라졌고 무엇을 치렀는지**, 회사에서는 같은 생각을 **어떤 이름과 도구로** 쓰는지 말할 수 있고, 최종 프로젝트에 무엇을 가져갈지 정할 수 있다. +- 숫자: 오늘 교안의 실측(강사 노트북, 10/29 정답 워크플로우 1회)입니다. "예시"라고 적은 계산은 가정을 둔 어림입니다. + +```mermaid +flowchart LR + A["1. 오늘 한 장"] --> B["2. 왜 실패 처리가
운영의 일인가"] --> C["3. 효과와 값을 숫자로"] --> D["4. 회사에서 부르는 이름"] + D --> E["5. 산업별 응용"] --> F["6. 운영 지표"] --> G["7. 하지 말아야 할 때"] --> H["8. 직무 · 면접"] --> I["9. 최종 프로젝트로"] +``` + +## 0. 이 시간 (35분) + +| 시간 | 절 | 하는 일 | +| --- | --- | --- | +| 17:10~17:15 | 1 · 2 | 오늘 한 장, 왜 실패 처리가 운영의 일인가 | +| 17:15~17:20 | 3 | 오늘 숫자로 본 효과와 값, 하루로 보면 (예시) | +| 17:20~17:28 | 4 · 5 | 같은 생각의 업계 이름 · 도구, 산업별 응용 예 | +| 17:28~17:33 | 6 · 7 | 운영 지표 여섯, 하지 말아야 할 때 | +| 17:33~17:40 | 8 · 9 | 직무와 면접 질문, 최종 프로젝트 체크리스트 | +| 17:40~17:45 | 10 · 토론 | 다음 일차와의 연결, 질문 둘 | + +## 1. 오늘 한 장 + +| 단계 | 한 일 | 오늘 숫자 | +| --- | --- | --- | +| 1단계 · 실패의 종류 | 망가뜨려 보기, 항목 확인 · 재시도 · 멱등 키, 고장 종류마다, LLM 멈춤, 요청 규칙 | 500인데 **2건 접수** · 중복 → 부분 성공 · `duplicates: 3`, `slow` **12.4초** · 늦게 생긴 3건, LLM 멈춰도 `created: 3`, 11건 11.0초 → **0.2초 400** | +| 2단계 · 모으고 알리기 | DLQ, Error Workflow, 그물 지도, 처리한 실패 알림 · DLQ가 멈추면, 알림 폭주 | 사라지던 2건 → **DLQ**, 버그 → 3초 뒤 **critical**, DLQ 멈춤 → **잃을 뻔한 id**를 알림에, 두 번째 알림 → **suppressed** (DLQ 5건 그대로) | +| 3단계 · 재처리와 운영 체계 | 재처리, 런북 · 짝 연습, 반쯤 처리된 요청, parked, 운영 상태 | 재처리 `ok: 2` · 중복 0, 반쯤 처리 → `duplicates: 2`, 세 번째 재처리 **0.1초 parked**, 상태 `ok → warn → critical → ok` | +| 바이브 코딩 | 아무 명령 · HTTP에 재시도 · 마감 · 대체를 붙이는 `tretry` | 요구 14개, `slow`에서 마감 5초 | + +한 문장으로: **실패를 종류별로 나눠 각자 갈 곳을 정하고, 잃지 않게 모으고, 사람이 알게 하되 지치지 않게, 고친 뒤 중복 없이 되돌린다.** + +## 2. 왜 실패 처리가 운영의 일인가 + +> **생활 속 예 · 좋은 식당은 실수했을 때 알 수 있다.** 음식이 맛있는 식당은 많지만, 손님이 기억하는 것은 **일이 틀어졌을 때**입니다. 주문이 누락됐을 때 "죄송합니다, 주문서가 빠졌네요. 바로 해 드리고 음료는 서비스입니다"라고 하는 식당과, "주방에 물어보세요" 하는 식당. 앞의 식당에는 주문서 번호(멱등 키), 누락 주문 메모판(DLQ), 실장을 부르는 규칙(알림 등급), 직원 교육 매뉴얼(런북)이 있습니다. 실패 처리는 잘 될 때가 아니라 **안 될 때의 서비스 품질**입니다. 3단계 8절의 "택배 회사로 본 오늘 하루" 표도 함께 봅니다. + +72일차는 "빠르게, 멈춰도 답하게", 73일차는 "속지 않게, 새지 않게", 74일차는 "바꿔도 망가지지 않게", 75일차는 "써도 감당할 수 있게"였습니다. 오늘은 "**실패해도 잃지 않게**"입니다. + +| | 만들기 | 운영하기 (실패 처리) | +| --- | --- | --- | +| 질문 | "잘 될 때 맞나?" | "안 될 때 무엇이 남나? 누가 아나? 어떻게 되돌리나?" | +| 기준 | 정상 요청의 테스트 | **망가뜨린** 상태의 테스트 (고장 스위치 · `llm down`) | +| 실패 | 오류 메시지 | 잃은 문의 · 중복 접수 · 아무도 모르는 버그 | +| 정하는 사람 | 개발자 | 개발자 + 운영(알림 · 런북) + 업무 담당(parked를 어떻게) | + +- 실패 처리의 사고는 대개 **두 번째 행동**에서 납니다. 500을 받은 사용자가 다시 보내 중복이 생기고, 급한 담당자가 DLQ를 비워 요청이 사라집니다. 그래서 결과를 정직하게(부분 성공), 다시 보내도 안전하게(멱등), 할 일을 미리(런북) 둡니다. +- 실패 처리 장치도 실패합니다. 오늘 DLQ가 멈추자 세 문의가 조용히 사라졌습니다. **처리 장치를 처리하는** 한 겹(critical `dlq_down`, 운영 상태)이 있어야 체계입니다. + +## 3. 효과와 값을 숫자로 + +### 3.1 오늘 실측 전 · 후 + +| 무엇 | 전 | 후 | 바꾼 것 | +| --- | --- | --- | --- | +| 빈 글이 섞인 3건 | 500 · 사실은 2건 접수 | 200 · `created: 2, rejected: 1` (1.7초) | 항목 확인 · 부분 성공 | +| 같은 3건을 다시 | 새 접수 건 3개 (중복) | `duplicates: 3`, 접수 건 그대로 | 키 = 요청 id | +| 잠깐의 끊김 (`flaky`) | 500 | `created: 2` (2.4초) | 재시도 3 + 키 | +| LLM 멈춤 | 0.8초에 500 · 접수 0 | `created: 3, unclassified: 3` (0.9초) | 실패해도 계속 · 미분류 | +| 11건 | 11.0초 · 모두 처리 | **0.2초** · 400 | 입구 상한 | +| API가 멈춘 동안 2건 | 결과에만 → 사라짐 | DLQ 2 · high 알림 1 · 재처리 `ok: 2` | DLQ · 알림 · 재처리 | +| 버그 (`nightly-report`) | 500 · 아무도 모름 | 3초 뒤 critical (노드 · 오류 문장) | Error Workflow | +| DLQ도 멈춤 | 3건 조용히 사라짐 | `dlq_saved: false` · critical · lost ids | 처리 장치의 결과 확인 | +| 같은 문제의 두 번째 알림 | 또 보냄 | `suppressed` (DLQ에는 그대로) | 10분 안 같은 알림 확인 | +| 세 번 실패한 요청 | 재처리마다 또 보냄 | 0.1초 · parked · high 알림 | 최대 시도 수 | + +**치른 값**: + +- **시간**: 재시도는 실패를 늦게 알게 합니다. `slow`에서 사용자는 12초를 기다리고 실패를 받습니다. 사용자가 기다릴 수 있는 시간을 먼저 정해야 합니다 (`tretry --deadline`). +- **복잡도**: 기준 워크플로우는 노드 8개, 오늘 마지막 접수(Lab 02 과제 2 정답)는 22개입니다. 노드가 늘수록 실패 처리 자체가 버그가 될 수 있어(DLQ가 멈출 때의 구멍), 망가뜨린 상태의 테스트가 함께 늘어야 합니다. +- **미분류**: LLM이 멈춘 동안의 접수는 사람이 나중에 분류해야 합니다. +- **사람의 일**: parked · critical은 결국 사람이 봅니다. 런북의 "누가 · 언제까지"가 비어 있으면 parked는 영원히 parked입니다. +- **최소 한 번**: 잃지 않는 대신 같은 요청이 두 번 이상 갈 수 있고, 받는 쪽 모두가 멱등해야 합니다. + +### 3.2 하루로 보면 (예시) + +가정: 하루 문의 1,000건, 데이터 API가 하루 한 번 30분 멈춤 (문의가 고르게 온다면 약 21건이 그 사이), 잠깐의 끊김은 1% (10건). + +| | 기준 (`base-intake`) | 오늘 저녁의 접수 | +| --- | --- | --- | +| 잠깐의 끊김 10건 | 10번의 500 → 사용자가 다시 보내면 일부는 중복 | 재시도로 거의 모두 접수, 중복 없음 | +| 30분 장애 동안 21건 | 21번의 500, 다시 보내지 않으면 잃음 | DLQ 21건 · high 알림 3번 (10분에 한 번) · 고친 뒤 재처리 | +| 같은 기간 알림 | 0 (아무도 모름) | high 3 · 필요하면 critical | +| 사람이 할 일 | 사용자 문의 · 중복 정리 | 런북대로 재처리, parked 확인 | + +- 숫자는 가정입니다. 그래도 **잃는 건수와 알림 수를 같은 표에서 말할 수 있다**는 것이 오늘 만든 체계의 효과입니다. + +## 4. 회사에서는 같은 생각을 이렇게 부른다 + +| 오늘 한 것 | 업계 이름 | 쓰는 도구 · 표준 (예) | +| --- | --- | --- | +| 실패를 다시 할 것과 아닌 것으로 | 일시적 오류 처리 (transient fault handling) | 클라우드 설계 지침 (Azure Architecture Center · AWS Builders' Library) | +| 항목마다 상태 · 200 | 부분 성공, batch item failures | HTTP 207, AWS Lambda의 SQS batch item failures | +| 재시도 · backoff · jitter | 재시도 정책, exponential backoff with jitter | 클라우드 SDK 기본 재시도, Polly(.NET) · resilience4j(Java) · tenacity(Python) | +| 멱등 키 | idempotency key, 중복 제거 | Stripe `Idempotency-Key`, IETF Idempotency-Key 헤더 초안 | +| 미분류로 접수 | 우아한 저하, 대체 경로 (fallback) | 기능 플래그, 기본값 | +| 입구 규칙 · 상한 | 입력 검증, 요청 크기 제한 | API 게이트웨이, JSON Schema | +| DLQ | dead letter queue | Amazon SQS · RabbitMQ · Kafka · Azure Service Bus | +| Error Workflow | 전역 오류 처리기, 오류 추적 | n8n Error Trigger, Sentry, 클라우드 로그 알림 | +| 알림 등급 · 줄이기 | 심각도(SEV), 중복 제거 · 묶기 · 반복 간격 | PagerDuty, Opsgenie, Prometheus Alertmanager | +| 재처리 | redrive, replay | SQS DLQ redrive, 작업 큐 재시도 화면 | +| parked | 독 메시지 · 최대 받기 횟수 | SQS `maxReceiveCount`, 작업 큐의 dead jobs | +| 런북 · 짝 연습 | runbook · playbook, game day, chaos engineering | 사내 위키, Chaos Monkey, 사후 검토(blameless postmortem) | +| 운영 상태 | health check, status page | 쿠버네티스 probe, 상태 페이지 서비스 | +| `tretry` | 재시도 래퍼, 회로 차단기 | 위 재시도 라이브러리, 서비스 메시(Istio 등)의 재시도 · circuit breaking | + +- 오늘은 n8n 노드 · 작은 데이터 API · `flow.py`로 직접 만들었습니다. 회사에서는 큐 · 라이브러리 · 알림 도구가 대신하는 경우가 많습니다. 그래도 **무엇을 다시 할 실패로 볼지, 몇 번 · 얼마나 기다릴지, 어떤 알림을 누가 받을지, parked를 누가 볼지**는 오늘처럼 사람이 정합니다. + +## 5. 산업별 응용 (예) + +아래는 오늘 한 일을 다른 업무에 옮긴 예입니다 (실제 특정 회사의 사례가 아니라 흔한 모양). + +| 업무 | 오늘의 무엇을 | 이렇게 | +| --- | --- | --- | +| 쇼핑몰 주문 · 결제 | 멱등 키 · 부분 성공 | 주문 번호를 키로 결제 요청, 시간 초과 뒤 다시 보내도 한 번만 결제 | +| 급여 · 대량 이체 | 항목마다 상태 · 입구 규칙 | 계좌 형식 오류는 그 건만 거절, 한 번에 보낼 건수 상한, 건마다 결과 파일 | +| 대량 메일 · 알림톡 발송 | DLQ · 재처리 · parked | 발송 실패는 모아 두었다 재시도, 세 번 실패한 수신자는 따로 (주소 확인) | +| 고객 문의 AI 분류 (오늘) | 우아한 저하 · 미분류 | LLM 장애 때도 접수, 미분류 수를 대시보드에 | +| 데이터 수집 · ETL | 그물 지도 · 운영 상태 | 원천이 잠깐 끊기면 재시도, 형식이 틀린 행은 따로, 저녁 작업 상태를 신호등으로 | +| 사내 자동화 (n8n · RPA) | Error Workflow · 런북 | 모든 워크플로우에 같은 에러 처리기, 알림에 실행 번호와 런북 주소 | + +**토론 거리**: 내 회사(또는 가고 싶은 회사)의 업무 하나를 골라, "두 번 하면 안 되는 일은 무엇이고, 무엇을 키로 쓸까"를 정해 봅니다. + +## 6. 운영 대시보드에 올릴 숫자 여섯 + +오늘 남긴 결과 · DLQ · 알림의 칸만으로 운영 지표가 됩니다 (77일차 로그 분석 · 79일차 운영 대시보드에서 실제로 모읍니다). + +| 지표 | 어느 칸에서 | 무엇을 알 수 있나 | +| --- | --- | --- | +| 부분 성공 비율 | `failed` · `rejected` ÷ `total` | 실패가 늘었나, 사용자 입력이 나빠졌나 | +| 재시도로 살아난 수 | `flaky`처럼 재시도 뒤 `created` (실행 기록) | 잠깐의 끊김이 얼마나 잦나 | +| DLQ 크기와 가장 오래된 것 | `/dlq`의 `size` · `at` | 재처리가 밀렸나 | +| parked 수 | `/dlq`의 `parked` | 사람이 볼 일이 쌓였나 | +| 등급별 알림 수 · suppressed 수 | `/alerts`, 결과의 `alert` | 장애가 잦나, 알림이 너무 많나 | +| 미분류 비율 | `unclassified` ÷ `created` | LLM이 자주 멈추나 | + +- 오늘 결과에 `reason` · `attempts` · `alert`를 남기게 한 까닭이 이것입니다. **센 수가 없으면 개선했는지도 말할 수 없습니다.** + +## 7. 하지 말아야 할 때 (흔한 실수) + +| 하기 쉬운 일 | 왜 위험한가 | 대신 | +| --- | --- | --- | +| 모든 실패를 재시도 | 400을 세 번, 부하만 늚 | 고칠 수 없는 것은 거절 | +| 재시도만 켜고 키는 없음 | 재시도가 곧 중복 | 키 = 업무 id | +| 층마다 재시도 | 부하가 곱해짐 (재시도 폭풍) | 한 곳에서, 예산 안에서 | +| 실패를 결과에서 뺌 | "모두 성공"이라는 거짓 | 항목마다 상태 | +| `neverError`만 두고 끝 | 처리 장치의 실패가 조용히 사라짐 | 본문 확인 · 다른 길로 알림 | +| 항목마다 알림 | 알림 피로 → 아무도 안 봄 | 요청 하나에 하나, 같은 문제는 한 번 | +| 급해서 DLQ를 비움 | 재처리할 요청이 사라짐 | 런북 "하지 말 것" | +| 원인을 고치기 전에 재처리 | 또 실패, 금방 parked | 확인 → 재처리 | +| 런북을 써 두기만 | 틀린 줄 모름 | 짝 연습 · game day | + +## 8. 직무와 면접 + +오늘 한 일은 다음 직무들이 날마다 하는 일입니다: **백엔드 · 플랫폼 엔지니어, SRE · 운영 엔지니어, 데이터 엔지니어(파이프라인), AI · LLM 애플리케이션 엔지니어, 업무 자동화(RPA · n8n) 개발자.** 채용 공고의 "장애 대응", "on-call", "재시도 · 멱등성", "메시지 큐 · DLQ", "모니터링 · 알림", "런북 작성", "안정성(reliability)"이 오늘 내용입니다. + +면접에서 흔히 받는 질문과 오늘 숫자로 답하는 법: + +
1. "일괄 처리 API에서 일부가 실패하면 어떻게 답하나요?" + +전체를 500으로 끝내지 않고 200에 항목마다 상태와 이유를 담습니다. 오늘 기준 워크플로우는 빈 글 하나에 500을 줬지만 사실 두 건은 이미 접수되어 있었고, 사용자가 다시 보내면 중복이 생겼습니다. 고친 뒤에는 빈 글만 `rejected`, 나머지는 `created`로 1.7초에 답했습니다. 받는 쪽은 거절은 고쳐서, 실패는 나중에 다시 보내면 됩니다. +
+ +
2. "재시도할 때 주의할 점은?" + +세 가지입니다. 다시 하면 되는 실패(5xx · 429 · 시간 초과)만 다시 하고, 간격을 지수로 늘리고 흔들며(jitter) 상한과 전체 마감을 둡니다. 그리고 받는 쪽이 멱등해야 합니다. 오늘 `flaky`에서는 재시도로 2.4초에 성공했지만, 늘 실패하는 장애에서는 시간만 늘었고, 느린 장애에서는 사용자가 12초를 기다렸습니다. 게다가 시간 초과로 포기한 요청이 나중에 만들어져 있어서, 키가 없었다면 다시 보낼 때 중복이 생겼을 것입니다. +
+ +
3. "멱등성이란 무엇이고 어떻게 구현하나요?" + +같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같은 성질입니다. 보내는 쪽이 업무 id 같은 고유한 키를 붙이고, 받는 쪽은 키를 기억했다가 같은 키가 오면 처음 결과를 돌려줍니다. 실행 번호나 시각은 매번 바뀌어서 키가 될 수 없습니다. 오늘 같은 세 건을 두 번 보내도 `duplicates: 3`, DLQ 재처리에서도 `duplicates: 2`로 접수 건이 늘지 않았습니다. +
+ +
4. "DLQ에 쌓인 메시지는 어떻게 처리하나요?" + +원인을 먼저 고치고, 원래 입구로 하나씩 다시 보내 성공한 것만 지웁니다. 정해진 횟수 넘게 실패한 것은 자동 재처리에서 빼서 사람에게 넘깁니다. 오늘 재처리는 `my-intake`를 다시 불러 같은 규칙과 키로 처리했고, 세 번 실패한 두 건은 parked로 남기고 high 알림을 보냈습니다. DLQ를 비우는 것은 런북의 "하지 말 것"에 적었습니다. +
+ +
5. "알림이 너무 많아서 다들 무시합니다. 어떻게 하나요?" + +등급을 나누고, 사람이 할 일이 있을 때만 알리고, 같은 문제는 묶습니다. 오늘은 요청 하나에 알림 하나(`count`)로, 같은 이유의 high 알림은 10분에 한 번으로 줄였습니다. 대신 보내지 않은 것도 `suppressed`로 남기고 DLQ에는 모두 남겼고, 요청을 잃을 수 있는 critical은 줄이지 않았습니다. +
+ +## 9. 최종 프로젝트로 가져갈 것 + +최종 프로젝트(85일차~)의 워크플로우에 아래를 넣으면 "운영을 생각한 프로젝트"가 됩니다. + +- [ ] 여러 건을 받는 곳은 항목마다 상태로 답한다 (부분 성공), 입구 규칙 · 상한 +- [ ] 외부 API 호출에 시간 제한 · 재시도 (다시 할 실패만), 두 번 하면 안 되는 일에는 멱등 키 +- [ ] LLM 같은 덧붙인 일이 실패해도 핵심 일은 끝낸다, 대체한 수를 남긴다 +- [ ] 오래가는 실패는 원래 요청 그대로 DLQ에 (개인정보 · 보관 기간 주의) +- [ ] 모든 워크플로우에 같은 에러 처리기 (고정 id, 실행 번호 · 노드 · 오류) +- [ ] 알림 등급 · 요청 하나에 하나 · 같은 문제는 한 번, 처리 장치가 실패할 때의 알림 +- [ ] 재처리는 같은 입구로, 최대 시도 수 뒤 사람에게 +- [ ] 운영 상태 웹훅 (읽기만) +- [ ] 런북 (명령 · 확인 · 갈림길 · 하지 말 것 · 누가) + 한 번은 짝 연습, `RUNBOOK.md` + +## 10. 다음 일차와의 연결 + +| 일차 | 오늘의 무엇을 잇나 | +| --- | --- | +| 77 | 실행 로그 분석 — 오늘 남긴 실패 · 알림 · n8n 실행 기록을 모아 어디가 자주 · 오래 실패하는지 찾고 고친 뒤 다시 잼 | +| 78 | 데이터 품질 검증 — 오늘의 입구 규칙을 넓혀 들어오는 데이터의 품질을 자동으로 검사 | +| 79 | 운영 대시보드 · `tops` — 6절의 운영 지표 여섯, `tretry`의 기록 · 운영 상태 | +| 80~82 | 단위 프로젝트 — 팀 워크플로우에 9절 체크리스트 | + +## 토론 (5분) + +1. 오늘 바꾼 것 중 **사용자에게 보이는 것**과 **운영자에게만 보이는 것**은 각각 무엇인가요? 어느 쪽이 더 중요할까요? +2. 내 업무의 자동화 하나에서 "parked가 되면 누가, 언제까지, 무엇을 보고 정하나"를 정해 보세요. + +## 정리 + +- [ ] 실패 처리도 **운영**: 실패해도 잃지 않게, 다시 해도 겹치지 않게, 사람이 알게 +- [ ] 효과는 망가뜨린 상태의 전 · 후 숫자로 (500 · 중복 → 부분 성공 · `duplicates`, 사라짐 → DLQ · 알림 · 재처리, 11.0초 → 0.2초) +- [ ] 같은 생각을 업계에서는 transient fault handling · idempotency key · DLQ · redrive · alert fatigue · runbook · game day라고 부른다 +- [ ] 결과 · DLQ · 알림의 칸이 곧 운영 지표가 된다 +- [ ] 최종 프로젝트에 체크리스트 9개를 가져간다 diff --git a/Lecture/76/05-VIBE-CODING.md b/Lecture/76/05-VIBE-CODING.md new file mode 100644 index 0000000..243e66c --- /dev/null +++ b/Lecture/76/05-VIBE-CODING.md @@ -0,0 +1,95 @@ +# 7·8교시 · 바이브 코딩 (`tretry`) + +- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 10분 · [4단계 정리](04-STAGE4-WRAPUP.md) 35분 · 예고 5분) +- 과제: [VIBE-REQ-TRETRY](../../Last3Mon/76/VIBE-REQ-TRETRY.md)(요청서, 요청의 말 ①~⑫) → [VIBE-Q-TRETRY](../../Last3Mon/76/VIBE-Q-TRETRY.md)의 8단계: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용 → 수업 뒤 `VIBE-A-TRETRY.md` 공개 +- 요구 14개(R1~R14). 50분 안에는 **엔진 1단계(R1 · R2 · R3 · R4 · R12 · R13) + 엔진 2단계(R5 · R6 · R8) + TUI 1단계(R11) + 설치(R14)**, 나머지(R7 마감 · R9 순위 · R10 회로 차단)는 IMP-PLAN의 다음 단계로 집에서. +- 72일차 `tbench` · 73일차 `tscan` · 74일차 `tver` · 75일차 `tcost`와 같은 흐름입니다: 사람의 말(요청서)을 **확인할 수 있는 요구**로 바꾸는 일을 학생이 먼저 하고, VIBE-Q는 그 다음 단계(비교 대상)로 씁니다. +- 오늘은 `tretry`: 아무 명령이나 HTTP 호출을 감싸 다시 하기(지수 backoff · 상한 · 흔들기) · 시도마다 시간 제한 · 전체 마감 · 대체 명령을 붙이고, 고칠 수 없는 실패(4xx)는 다시 하지 않고, 같은 멱등 키를 붙이고, 기록 · 순위 · 회로 차단을 TUI로. 오늘 n8n 노드 설정으로 한 일을 **n8n 밖 어떤 명령에든.** + +```mermaid +flowchart LR + R["요청서 → TODO · SPEC ·
IMP-PLAN 20분"] --> A["7교시
엔진 1 · 2 · TUI · 설치 30분"] --> B["발표
10분"] --> C["4단계 정리
35분"] --> F["77일차 예고
5분"] +``` + +> **생활 속 예 · 자동 재다이얼 전화기.** 옛날 전화기의 "재다이얼" 버튼은 통화 중이면 몇 초 뒤 알아서 다시 겁니다. 잘 만든 재다이얼은 ① 간격을 점점 늘리고 ② 몇 번에서 멈추고 ③ "없는 번호" 안내가 나오면 다시 걸지 않고 ④ 정해 둔 시간이 지나면 "나중에 다시 거세요"로 끝냅니다. `tretry`는 아무 명령이나 HTTP 호출에 이런 재다이얼을 붙이는 도구입니다. + +> 이 문서는 7 · 8교시 진행 문서입니다. 8교시의 정리 35분은 [04-STAGE4-WRAPUP](04-STAGE4-WRAPUP.md)으로 합니다 (74일차부터 4단계 정리를 따로 두었습니다). + +## 1. 7교시 · 각자 만들기 (50분) + +| 시간 | 단계 | 확인 | +| --- | --- | --- | +| 16:00~16:09 | 0 준비 · 1 요청서 → `Doc/TODO.md` (에이전트 없이) | 할 일 ①~⑫ · 꼭 / 나중 | +| 16:09~16:15 | 2 `Doc/SPEC.md` → VIBE-Q 요구 표(R1~R14)와 비교 · commit | 요구마다 확인 방법 (시도 수 · 기다린 시간 · 종료 코드) | +| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~3단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 | +| 16:20~16:44 | 4 구현: 엔진 1단계 → 엔진 2단계 → TUI 1단계, 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit | +| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tretry` | 데이터 API `flaky`에서 `tretry http POST localhost:8800/tickets ... --idem-key x --body-key key` | + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| TODO · SPEC 없이 VIBE-Q를 통째로 붙여 넣었나 | "요청서로 Doc/TODO.md부터. 기다린 시간 [0.5, 1.0]이 SPEC에 없으면 맞았는지 모릅니다" | +| IMP-PLAN이 한 덩어리 · 첫 요청에 14개 | "엔진 1단계만. 단계마다 확인 · 체크 · commit" | +| 테스트가 몇 초씩 | "엔진이 sleep · clock을 인자로, 테스트에서는 가짜로" | +| 400도 다시 함 | "고칠 수 없는 실패는 1번, 종료 코드 3, 대체도 하지 않기 (오늘 Lab 01)" | +| 간격이 늘 같음 · 끝없이 늚 | "지수 backoff와 상한" | +| 다시 보낸 POST로 접수 건이 둘 | "멱등 키는 실행에 한 번 만들고 모든 시도에, 본문 key에도" | +| `--timeout`이 안 먹음 | "subprocess와 urlopen 둘 다 timeout, 시간 초과도 다시 할 실패" | +| `--` 뒤 옵션을 tretry가 먹음 | "`--` 앞만 tretry 옵션" | +| 기록에 토큰 | "헤더 값은 남기지 않기, URL · 오류의 키도 `***`" | +| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tretry ...; echo $?`" | + +정답 예(R1~R14를 모두 넣은 `tretry`와 정답본 `VIBE-A-TRETRY.md`)는 수업 뒤 공개됩니다. + +## 2. 8교시 · 발표 (10분) + +- 1~2명. **데이터 API의 `flaky` · `slow` · `error`에서 다시 하기 · 마감 · 대체를 보여 줄 수 있는 사람**, 400에서 멈추는 것(종료 코드 3)을 보여 줄 수 있는 사람을 먼저 고릅니다. +- 3분 발표 · 2분 질문: **내 SPEC과 VIBE-Q가 달랐던 곳** / IMP-PLAN을 어떻게 나눴나 / 잘 먹힌 요청문 / 안 먹힌 요청문 / 에이전트가 틀린 곳 / 시연. + +## 3. 강사 정리 (8교시 4단계 정리 안에서) + +| | n8n 일괄 접수 (오늘 Lab) | tretry | +| --- | --- | --- | +| 다시 하기 | 노드 `retryOnFail` · `maxTries` 3 · 고정 300ms | `--tries` · 지수 backoff · 상한 · jitter (R1 · R2) | +| 시간 제한 | 노드 `options.timeout` 3000 | `--timeout`, 전체 `--deadline` (R3 · R7) | +| 고칠 수 없는 실패 | 항목 확인 → `rejected` (Lab 01 예제 2) | 4xx는 1번 · 종료 코드 3 (R5) | +| 멱등 키 | `key` = 요청 id | `--idem-key` · `--body-key` (R6) | +| 대체 | 미분류로 접수 (Lab 01 과제 1) | `--fallback` (R4) | +| 실패 기록 | DLQ · 알림 | JSON Lines 기록 · `stats` (R8 · R9) | +| 끝없이 하지 않기 | `attempts` 3이면 parked (Lab 03 과제 1) | 회로 차단 `--breaker` (R10) | + +- 풀이 거리: Lab 01 예제 3의 `slow`는 내 접수가 **12.4초** 뒤 실패했습니다. 강사 본 `tretry`에 `--timeout 3 --deadline 5`를 주면 두 번째 시도의 시간 제한을 남은 1.5초로 줄여 **5.00초**에 포기합니다. "몇 번 할까" 대신 "얼마나 기다릴 수 있나"로 정하는 것. + +## 4. 강사 바이브 코딩 (시간이 남으면 · 수업 뒤, 15분) + +| 주제 | 요청문 요지 | 보여 줄 것 | +| --- | --- | --- | +| 회로 차단의 대상 | "breaker 대상을 METHOD URL 이 아니라 METHOD + 경로 + 상태 코드 종류로, 4xx 는 세지 않기" | 400 실수 때문에 차단이 열리지 않는 것 | +| 재시도 예산 | "기록에서 최근 1분 시도 중 재시도 비율이 10% 넘으면 재시도하지 않기" | 재시도 폭풍 막기 | +| n8n 웹훅 감싸기 | "tretry http POST localhost:5678/webhook/my-intake --body @requests/batch-two.json" | 부분 성공(200)은 성공 — 결과를 보고 판단해야 함 | + +걸린 시간과 결과를 `Last3Mon/76/VIBE-A-TRETRY.md`에 적어 둡니다. + +## 5. 보충 · 팁 (읽을거리, 4단계 정리 7절과 함께) + +| 팁 | 설명 | +| --- | --- | +| 요청서 → 요구가 먼저 | 확인 방법(시도 수 · 기다린 시간 · 종료 코드)이 있는 요구가 있어야 에이전트의 "완료"를 판단할 수 있음 | +| 시간은 바꿔 끼우게 | `sleep` · `clock` · 무작위를 인자로 받으면 3초짜리 시도도 0초에 시험 | +| 엔진은 "한 번 해 보기"만 안다 | 명령 · HTTP의 차이는 실행기에, 엔진은 하나 | +| 고칠 수 없는 실패를 가리지 않기 | 대체가 400을 덮으면 실수를 영영 모름 | +| 키는 실행에 한 번 | 시도마다 새 키면 멱등이 아님 | +| 기록은 한 줄에 하나 | 중간에 죽어도 앞 줄은 남음, 비밀은 지움 | +| 단계마다 `git commit` | 에이전트가 망가뜨려도 돌아갈 곳 | + +## 6. 77일차 예고 (5분) + +- 오늘: 실패를 나누고 · 모으고 · 알리고 · 다시 돌렸습니다. 다음 수업(77일차, 11/2): **실행 로그 분석과 운영 데이터 기반 Workflow 개선** — 오늘 남긴 알림 · DLQ · n8n 실행 기록을 모아 어디가 자주 · 오래 실패하는지 찾고 고친 뒤 다시 잽니다. +- 바이브 코딩: `tlog`. 오늘 `tretry`는 지우지 마세요. 79일차 `tops`에서 씁니다. + +## 확인 (강사) + +- [ ] 16:00 요청서 → TODO(에이전트 없이) → SPEC → IMP-PLAN 20분을 지켰다, 16:20부터 엔진 1단계. +- [ ] 7교시 시작 전 데이터 API가 `ops --reset` 상태이고, 고장 스위치를 바꿀 명령을 화면에 띄웠다. +- [ ] 수업 뒤: 요청이 있을 때 학생 저장소에 정답(LAB-A · `answers/` · VIBE-A · `tretry/`)을 올린다. +- [ ] 발표자 1~2명, 강사 정리 표, 팁, 예고. +- [ ] 수업이 끝날 때 학생들의 데이터 API가 처음 상태인지 (`ops --reset`, `llm up`, `doctor`). diff --git a/Lecture/76/README.md b/Lecture/76/README.md new file mode 100644 index 0000000..6cb0b90 --- /dev/null +++ b/Lecture/76/README.md @@ -0,0 +1,41 @@ +# 76일차 · 에러 처리와 예외 대응, 안정적 운영 체계 (바이브 코딩) + +- 일자: 2026-10-30 (76일차) +- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영 +- 단원: AI 워크플로우 운영 및 관리 역량 강화 +- 학습 내용: 에러 처리 및 예외 대응 Workflow 구성, 안정적 운영 체계 적용 +- 실습 폴더: [Last3Mon/76](../../Last3Mon/76/README.md) +- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`, 분류) + 76일차 데이터 API(75일차 + 접수 건 만들기 · DLQ · 알림 · 고장 스위치) + **CLI 코딩 에이전트** +- **실습 준비: [00-DAY76-PREP.md](00-DAY76-PREP.md) (수업 전날까지 1~2절 완료)** +- 수업 형식: 1 · 3 · 5교시 강의, 2 · 4 · 6교시 실습 5개씩 (예제 3 · 과제 2), 7교시 바이브 코딩, 8교시 발표 · 4단계 정리 + +## 오늘의 시간표 + +| 교시 | 시간 | 내용 | 자료 | +| --- | --- | --- | --- | +| 1 | 09:00~09:50 | 개요 · 1단계 강의 · 실패의 종류 | [개요](00-DAY76-OVERVIEW.md) 1~14장, [1단계](01-STAGE1-FAILURE-TYPES.md) | +| 2 | 10:00~10:50 | Lab 01 실습 5개 (망가뜨려 보기 · 항목 확인 · 재시도 · 멱등 키 · 고장 종류 · LLM 멈춤 · 요청 규칙) | [Lab 01](../../Last3Mon/76/LAB-01-Q-FAILURE-TYPES.md) | +| 3 | 11:00~11:50 | 2단계 강의 · 모으고 알리기 | 개요 15~21장, [2단계](02-STAGE2-COLLECT-AND-ALERT.md) | +| 4 | 12:00~12:50 | Lab 02 실습 5개 (DLQ · Error Workflow · 그물 지도 · 처리한 실패 알림 · 알림 폭주) | [Lab 02](../../Last3Mon/76/LAB-02-Q-COLLECT-AND-ALERT.md) | +| - | 13:00~14:00 | 점심 | | +| 5 | 14:00~14:50 | 3단계 강의 · 재처리와 운영 체계 | 개요 22~28장, [3단계](03-STAGE3-REPLAY-AND-RUNBOOK.md) | +| 6 | 15:00~15:50 | Lab 03 실습 5개 (재처리 · 런북 · 장애 연습 · 반쯤 처리된 요청 · parked · 운영 상태) | [Lab 03](../../Last3Mon/76/LAB-03-Q-REPLAY-AND-RUNBOOK.md) | +| 7 | 16:00~16:50 | 바이브 코딩 `tretry`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TRETRY](../../Last3Mon/76/VIBE-REQ-TRETRY.md), [진행](05-VIBE-CODING.md) | +| 8 | 17:00~17:50 | 발표 · **4단계 정리** (의미 · 효과 · 회사에서 쓰는 법) · 77일차 예고 | [4단계 정리](04-STAGE4-WRAPUP.md), [진행](05-VIBE-CODING.md) | + +```mermaid +flowchart LR + P["개요"] --> S1["1단계
실패의 종류 · 부분 성공
재시도 · 멱등 키"] --> S2["2단계
DLQ · Error Workflow
그물 지도 · 알림"] --> S3["3단계
재처리 · 런북
parked · 운영 상태"] --> V["7교시
tretry"] --> W["8교시
4단계 정리"] +``` + +## 자료 목록 + +| 파일 | 내용 | +| --- | --- | +| `00-DAY76-PREP.md` | 실습 준비: n8n · 76일차 판 데이터 API 켜기, 기준(`base-intake`) · `nightly-report` 게시, `flow.py doctor` (수업 전) | +| `00-DAY76-OVERVIEW.md` | 개요 30장 (PPT 대신 Markdown, 가로줄로 장 구분) | +| `01-STAGE1-FAILURE-TYPES.md` | 부분 실패 · 500의 거짓말, 실패의 종류(4xx · 5xx · 시간 초과), 부분 성공, 재시도 · backoff · jitter · 재시도 폭풍, 시간 초과는 "모름", 멱등 키, 우아한 저하, 입구에서 막기 | +| `02-STAGE2-COLLECT-AND-ALERT.md` | 사라지는 실패, DLQ, 처리한 · 처리하지 못한 실패, Error Workflow, 그물 지도와 구멍, 알림 등급, 알림 피로 | +| `03-STAGE3-REPLAY-AND-RUNBOOK.md` | 재처리 · 같은 입구로, 런북, 장애 연습, 최소 한 번 + 멱등, 끝없이 돌리지 않기 · parked, 운영 상태, 운영 체계 한 장 | +| `04-STAGE4-WRAPUP.md` | 8교시 35분: 오늘의 의미 · 효과 숫자 · 업계 이름 · 산업별 응용 · 운영 지표 · 면접 · 최종 프로젝트 체크리스트 | +| `05-VIBE-CODING.md` | 7 · 8교시 바이브 코딩 진행 (tretry), 발표, 강사 정리, 강사 바이브 코딩, 팁, 예고 |