Files

7304 lines
234 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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["① 프롬프트<br/>터미널 ① codex"] --> C["② codex<br/>my-work/*.json · check"] --> U["③ push<br/>터미널 ②"] --> T["④ test -v · ops<br/>항목마다 상태 · DLQ · 알림"]
T -->|✘| X["실패 줄 · 보고서 경로를<br/>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)<br/>500인데 2건 접수 · 중복<br/>→ 부분 성공 · 키<br/>slow 12.4초 · 늦게 생긴 3건"] --> L2["Lab 02 (09~16)<br/>사라진 2건 → DLQ<br/>버그 → critical 알림<br/>DLQ도 멈춤 → dlq_down<br/>알림 폭주 → suppressed"]
L2 --> L3["Lab 03 (17~24)<br/>재처리 ok 2 · 중복 0<br/>반쯤 처리 → duplicates 2<br/>세 번 실패 → parked<br/>운영 상태 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 <the workflow file>
✔ 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초씩 걸립니다.