76일차 자료 추가 (답 포함): 실습 15개 · 의도 · 생활 속 예 · 실행 결과, LAB-A · 정답 워크플로우와 25단계 실행 기록, 교안 개요 30장 · 4단계 정리, 바이브 코딩 tretry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f36605043
commit
bbf17e4696
141 files changed
+34072
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
runs/
|
||||||
|
tools/.cache/
|
||||||
|
my-work/*.json
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# AGENTS.md · n8n 에러 처리·예외 대응 작업 규칙 (76일차 실습 폴더)
|
||||||
|
|
||||||
|
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 75일차 규칙과 같고, 3절이 오늘 만드는 **문의 일괄 접수 · 에러 처리 · 재처리의 계약**, 5절에 **항목마다 실패 · 재시도 · 멱등 키 · DLQ · Error Workflow** 노드가 더해졌습니다.
|
||||||
|
|
||||||
|
## 1. 작업 순서 (항상 이 순서)
|
||||||
|
|
||||||
|
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 에러 처리 요청이면 **어떤 실패가 어디로 가는지(거절 · 재시도 · DLQ · 알림)** 를 표로 함께 보여 준다.
|
||||||
|
2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다).
|
||||||
|
3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다.
|
||||||
|
4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄.
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/<이름>.json
|
||||||
|
uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json
|
||||||
|
```
|
||||||
|
5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다 (push는 약 10초, n8n이 잠깐 재시작됨).
|
||||||
|
6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다.
|
||||||
|
7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.
|
||||||
|
|
||||||
|
## 2. 환경 (바꾸지 말 것)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`, 컨테이너 보통 `day65-n8n-n8n-1`), 브라우저 http://localhost:5678 |
|
||||||
|
| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) |
|
||||||
|
| 게시된 웹훅 주소 | 밖에서 `http://localhost:5678/webhook/<path>`, **n8n 안에서도** `http://localhost:5678/webhook/<path>` |
|
||||||
|
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` (분류). `gpt-4.1-nano`는 OpenAI가 받지 않음 |
|
||||||
|
| 다른 Credential | 쓰지 않음 (Ollama 등 없음) |
|
||||||
|
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**76일차 판** `Last3Mon/76/data-api`: 75일차 + 접수 건 만들기 · DLQ · 알림) |
|
||||||
|
| 비교 기준 · 제공 | `reference/baseline-intake.json` (`base-intake`), `reference/nightly-report.json` (`nightly-report`, 일부러 버그가 있음). 고치지 않는다 |
|
||||||
|
|
||||||
|
### 데이터 API (76일차 판)
|
||||||
|
|
||||||
|
| 요청 | 응답 |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /tickets` 본문 `{"key", "title", "requester", "category", "priority"}` | 201 `{"ticket_id": "T-2001", "duplicate": false}` · **같은 `key`면** 200 `{"ticket_id"(처음 것), "duplicate": true}` · `key` · `title`이 없으면 400 · 고장 스위치 `tickets`를 따른다 |
|
||||||
|
| `GET /tickets-created` (`POST /tickets-created/list`) | `{"count", "tickets": [...]}` |
|
||||||
|
| `POST /dlq` 본문 `{"items": [{"item": <원래 요청>, "reason", "workflow"}]}` | `{"stored", "size"}` · **같은 `item.id`면 새 줄 없이 `attempts` +1** |
|
||||||
|
| `GET /dlq` (`POST /dlq/list`) | `{"size", "parked", "items": [{"dlq_id", "item", "reason", "attempts", "parked"}]}` · 고장 스위치 `dlq`를 따른다 (`POST /dlq`가 500) |
|
||||||
|
| `POST /dlq/update` | `{"dlq_id", "remove": true}` 또는 `{"dlq_id", "parked": true, "reason"}` |
|
||||||
|
| `POST /alerts` 본문 `{"workflow", "node", "message", "severity", "reason", ...}` | `{"stored", "count"}` |
|
||||||
|
| `GET /alerts` · `POST /alerts/list` · `POST /alerts/latest` | 모든 알림 · **거르기** 본문 `{"workflow", "reason", "severity", "within": 초}` (예: `{"workflow": "intake", "reason": "dlq", "within": 600}`는 10분 안의 것만) · 가장 최근 알림 |
|
||||||
|
| `POST /admin/fault` | `{"tickets": "ok" \| "slow" \| "error" \| "flaky", "dlq": ...}` — `flaky`: 접수 건 만들기에서 **키마다 첫 번째 시도만 실패**, `slow`: 8초 기다린 뒤 **그래도 접수 건을 만든다**(부른 쪽이 포기했어도) (**사람이** `flow.py fault`로) |
|
||||||
|
| `POST /tickets-reset` · `/dlq-clear` · `/alerts-clear` | 비우기 (`flow.py ops --reset`이 한 번에) |
|
||||||
|
|
||||||
|
## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)
|
||||||
|
|
||||||
|
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 문의 일괄 접수 | `my-work/intake.json` · `POST /webhook/my-intake` | `{"requests": [{"id": "R-101", "user": "이준호", "text": "..."}]}` | HTTP 200 `{"total", "created", "failed", "rejected", "duplicates", "unclassified", "results": [{"id", "status": "created" \| "failed" \| "rejected", "ticket_id", "duplicate", "category", "priority", "reason"}]}` |
|
||||||
|
| 에러 처리기 | `my-work/error-handler.json`, 파일 안에 **`"id": "errHandler76001"`**, Error Trigger로 시작 | (n8n이 부름) | `POST /alerts` `{"workflow", "workflow_id", "node", "message", "execution_id", "severity": "critical", "reason": "unexpected"}` |
|
||||||
|
| 재처리 | `my-work/replay.json` · `POST /webhook/my-replay` | `{"max_attempts": 3}`(선택) | `{"replayed", "ok", "still_failed", "parked", "duplicates"}` |
|
||||||
|
| 운영 상태 (Lab 03 과제 2) | `my-work/ops-status.json` · `POST /webhook/my-ops-status` | `{}` | `{"status": "ok" \| "warn" \| "critical", "dlq", "parked", "created", "alerts": {"critical", "high"}, "reasons": [...]}` |
|
||||||
|
|
||||||
|
- `requests`가 목록이 아니면 HTTP 400 `{"error": "requests must be a list"}`. (Lab 01 과제 2) 10건이 넘으면 HTTP 400 `{"error": "too many requests (max 10)"}`, 같은 요청 안에 같은 `id`가 또 있으면 두 번째부터 거절 `duplicate id in this request`.
|
||||||
|
- 항목 하나가 잘못돼도(**거절** `rejected`: id · user · text 없음) 나머지는 처리한다. 결과는 늘 HTTP 200 (부분 성공).
|
||||||
|
- 접수 건을 만들 때 **`key` = 요청의 `id`** (멱등 키). 같은 요청을 다시 보내도 접수 건이 두 번 생기지 않는다.
|
||||||
|
- 데이터 API 실패는 timeout 3초 · 재시도 3번 뒤 **실패** `failed`로 표시하고 원래 요청을 DLQ에 넣는다.
|
||||||
|
- LLM 분류가 실패하면 `category: 미분류`, `priority: 보통`으로 접수한다 (멈추지 않는다).
|
||||||
|
- (Lab 02 과제 1) 처리한 실패(DLQ로 간 것)가 1건 이상이면 `POST /alerts` `{"workflow": "intake", "severity": "high", "reason": "dlq", "count"}`. DLQ에 넣지 **못했으면**(`stored`가 없음) `{"severity": "critical", "reason": "dlq_down", "count", "message"(잃을 뻔한 id들)}`. 결과에 `dlq_saved`(true/false, 실패가 있을 때)와 `alert`(`sent` · `none`).
|
||||||
|
- (Lab 02 과제 2) 같은 `reason`의 high 알림이 10분 안에 이미 있으면 보내지 않고 `alert: "suppressed"`. critical은 늘 보낸다.
|
||||||
|
- (Lab 03 과제 2) 운영 상태: parked가 있거나 10분 안 critical 알림이 있으면 `critical`, parked가 아닌 DLQ가 있거나 10분 안 high 알림이 있으면 `warn`, 아니면 `ok`. `reasons`에 까닭을 한국어로.
|
||||||
|
- 접수 워크플로우와 `nightly-report`의 설정 `settings.errorWorkflow`는 `errHandler76001`.
|
||||||
|
- 재처리: DLQ에서 `parked`가 아닌 것을 `my-intake`로 하나씩 다시 보내고, 성공하면 DLQ에서 지운다. `attempts`가 `max_attempts` 이상이면 다시 보내지 않고 `parked: true` + 알림 `{"severity": "high", "reason": "parked"}`.
|
||||||
|
|
||||||
|
## 4. 워크플로우 파일 규칙
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "76 Intake (my)",
|
||||||
|
"nodes": [ ... ],
|
||||||
|
"connections": { ... },
|
||||||
|
"settings": { "executionOrder": "v1", "errorWorkflow": "errHandler76001" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 파일 하나에 워크플로우 하나. 노드 `name`은 영어로, 겹치지 않게. 노드마다 `type`, `typeVersion`, `position`, `parameters`.
|
||||||
|
- `id`를 쓰지 않으면 `push`가 파일 이름으로 정한다. **에러 처리기는 다른 워크플로우가 id로 부르므로** 파일 맨 위에 `"id": "errHandler76001"`을 쓴다 (70일차 모듈과 같음).
|
||||||
|
- **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다.
|
||||||
|
|
||||||
|
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
|
||||||
|
|
||||||
|
### 항목마다 확인 (Code → If) · 두 갈래 모으기 (Merge)
|
||||||
|
```json
|
||||||
|
{"name": "Check items", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [440, 300],
|
||||||
|
"parameters": {"jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nreturn requests.map((r, index) => ({ json: { index, valid: ..., reason: ..., id: r.id, user: r.user, text: r.text } }));"}}
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"name": "Collect", "type": "n8n-nodes-base.merge", "typeVersion": 3, "position": [1760, 300], "parameters": {"numberInputs": 2}}
|
||||||
|
```
|
||||||
|
- 처리한 갈래 → `Collect` 입력 0번, 거절한 갈래 → 입력 1번 (`"index": 1`). 한쪽이 비어도 모은다.
|
||||||
|
|
||||||
|
### AI Agent (항목마다 분류) — 실패해도 계속
|
||||||
|
```json
|
||||||
|
{"name": "Classify", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [880, 200],
|
||||||
|
"parameters": {"promptType": "define", "text": "={{ $json.text }}", "options": {"systemMessage": "... Return only JSON ...", "maxIterations": 1}},
|
||||||
|
"onError": "continueRegularOutput"}
|
||||||
|
```
|
||||||
|
- Agent는 들어온 항목마다 한 번씩 돈다. `continueRegularOutput`이면 실패한 항목은 `output` 대신 `error`를 가진 채 다음으로 간다.
|
||||||
|
|
||||||
|
### 접수 건 만들기 (HTTP) — 재시도 · 멱등 키 · 실패해도 계속
|
||||||
|
```json
|
||||||
|
{"name": "Create ticket", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 200],
|
||||||
|
"parameters": {"method": "POST", "url": "http://data-api:8800/tickets", "sendBody": true, "specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ key: $json.id, title: $json.text, requester: $json.user, category: $json.category, priority: $json.priority }) }}",
|
||||||
|
"options": {"timeout": 3000}},
|
||||||
|
"retryOnFail": true, "maxTries": 3, "waitBetweenTries": 300, "onError": "continueRegularOutput"}
|
||||||
|
```
|
||||||
|
- **노드의 재시도는 노드에 들어온 항목을 모두 다시 보낸다.** 그래서 멱등 키(`key`)가 없으면 이미 만든 접수 건이 또 생긴다.
|
||||||
|
- `continueRegularOutput`: 재시도 뒤에도 실패한 항목은 `{"error": {...}}`를 가진 채 다음 노드로. 다음 Code에서 앞 노드 항목과 같은 순서로 맞춰 `created` / `failed`를 정한다.
|
||||||
|
|
||||||
|
### Error Workflow (에러 처리기)
|
||||||
|
```json
|
||||||
|
{"name": "On error", "type": "n8n-nodes-base.errorTrigger", "typeVersion": 1, "position": [0, 300], "parameters": {}}
|
||||||
|
```
|
||||||
|
- 다른 워크플로우가 **처리하지 못한 오류로 멈추면** n8n이 이 워크플로우를 부른다. 받는 값: `$json.workflow.name`, `$json.workflow.id`, `$json.execution.id`, `$json.execution.lastNodeExecuted`, `$json.execution.error.message`.
|
||||||
|
- 부르는 쪽 설정: `"settings": {"executionOrder": "v1", "errorWorkflow": "errHandler76001"}`. 에러 처리기도 push(게시)해야 한다.
|
||||||
|
- 웹훅 응답(500)이 나간 **뒤에** 돈다 — 알림은 1~3초 늦게 생긴다.
|
||||||
|
|
||||||
|
### DLQ · 알림 (HTTP POST)
|
||||||
|
```json
|
||||||
|
{"name": "Send to DLQ", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2420, 140],
|
||||||
|
"parameters": {"method": "POST", "url": "http://data-api:8800/dlq", "sendBody": true, "specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ items: ... }) }}", "options": {"response": {"response": {"neverError": true}}}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 내 웹훅 다시 부르기 (재처리, 항목마다)
|
||||||
|
```json
|
||||||
|
{"name": "Run intake again", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1100, 200],
|
||||||
|
"parameters": {"method": "POST", "url": "http://localhost:5678/webhook/my-intake", "sendBody": true, "specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ requests: [$json.item] }) }}", "options": {"response": {"response": {"neverError": true}}, "timeout": 60000}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 그 밖 (72~75일차와 같음)
|
||||||
|
- Webhook · If · Code · Respond to Webhook. 비어 있을 수 있는 결과에는 노드에 `"alwaysOutputData": true`.
|
||||||
|
|
||||||
|
## 6. 연결 (`connections`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Valid?": {"main": [[{"node": "Classify", "type": "main", "index": 0}], [{"node": "Mark rejected", "type": "main", "index": 0}]]},
|
||||||
|
"Mark results": {"main": [[{"node": "Collect", "type": "main", "index": 0}]]},
|
||||||
|
"Mark rejected": {"main": [[{"node": "Collect", "type": "main", "index": 1}]]}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 표현식 주의
|
||||||
|
|
||||||
|
- 표현식은 `=`로 시작한다. 이번 실행 번호는 `$execution.id`, 항목 번호는 `$itemIndex`.
|
||||||
|
- Code 노드의 `throw new Error('...')`는 워크플로우를 멈춘다 (Error Workflow가 받음). 처리할 수 있는 실패는 던지지 말고 결과로.
|
||||||
|
|
||||||
|
## 8. 테스트 파일 (`cases/*.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [{"url": "http://localhost:8800/tickets-reset", "body": {}}],
|
||||||
|
"cases": [
|
||||||
|
{"name": "API 오류", "before": [{"url": "http://localhost:8800/admin/fault", "body": {"tickets": "error"}}],
|
||||||
|
"request": "requests/batch-two.json", "expect": {"equals": {"failed": 2}}},
|
||||||
|
{"name": "DLQ 확인", "url": "http://localhost:8800/dlq/list", "body": {}, "expect": {"equals": {"size": 2}}},
|
||||||
|
{"name": "알림 확인", "before": [{"sleep": 3}], "url": "http://localhost:8800/alerts/latest", "body": {}, "expect": {"equals": {"severity": "critical"}}},
|
||||||
|
{"name": "다른 웹훅", "webhook": "my-replay", "body": {}, "expect": {"equals": {"ok": 2}}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `url` 케이스는 그 주소에 **POST** 합니다. 읽기만 할 때는 `/dlq/list`, `/alerts/list`, `/alerts/latest`, `/tickets-created/list`.
|
||||||
|
- `{"sleep": 3}`: 기다리기 (Error Workflow가 늦게 돌 때).
|
||||||
|
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
|
||||||
|
|
||||||
|
## 9. 하지 말 것
|
||||||
|
|
||||||
|
- `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만). `nightly-report`의 버그를 고쳐서 테스트를 통과시키지 않는다 (오늘은 일부러 실패시키는 연습).
|
||||||
|
- 워크플로우 안에서 고장 스위치 · `*-reset` · `*-clear`를 부르지 않는다.
|
||||||
|
- 실패를 숨기지 않는다: 실패한 항목을 결과에서 빼거나 성공으로 바꾸지 않는다.
|
||||||
|
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
|
||||||
|
- `.env`나 API 키를 읽어서 출력하지 않는다.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Lab 01 · 실패를 겪어 보고, 항목마다 처리하고, 중복 없이 다시 해 보기
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-01-Q-FAILURE-TYPES.md](LAB-01-Q-FAILURE-TYPES.md).
|
||||||
|
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 2026-10-29에 1회씩 실행한 값입니다 (강사 Mac, 분류 `gpt-4.1-mini`). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||||
|
|
||||||
|
## 예제 1 · 결과 (에이전트 없이, `answers/01`)
|
||||||
|
|
||||||
|
| 한 일 | 사용자가 본 것 | 실제로 생긴 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 정상 3건 | 200 · `processed: 3` (5.2초) | 접수 건 3 (T-2001~2003) |
|
||||||
|
| 빈 글이 섞인 3건 | **500** · `Error in workflow` | 접수 건 **2** (1번째 · 3번째) |
|
||||||
|
| `flaky` · 2건 | 500 | 0 (재시도 없음) |
|
||||||
|
| 정상 3건을 다시 | 200 · 새 번호 3개 | 접수 건 8 · VPN 문의가 T-2001 · T-2006 **둘** (키 `10057-0` → `10060-0`) |
|
||||||
|
|
||||||
|
- 풀이 때: "500을 받은 사용자는 무엇을 하나?" → 다시 보냄 → 2건이 또 생김. 500은 "아무것도 안 됨"이 아닙니다.
|
||||||
|
|
||||||
|
## 예제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json (baseline 을 복사한 것) 의 웹훅 경로를 my-intake 로, 이름을 "76 Intake (my)" 로 바꿔 줘.
|
||||||
|
requests 가 목록이 아니면 400 {"error": "requests must be a list"}.
|
||||||
|
항목마다 id · user · text 를 확인해서, 빠진 것이 있으면 LLM 과 API 로 보내지 말고 status rejected 와 이유("text is required" 등)로.
|
||||||
|
Create ticket 노드에 timeout 3000ms, 재시도 3 번, 간격 300ms, 그리고 실패해도 다음으로 가게 onError continueRegularOutput .
|
||||||
|
접수 건의 key 는 요청의 id (실행 번호가 아님).
|
||||||
|
처리한 갈래와 거절한 갈래를 Merge 로 모아 AGENTS.md 3절 결과 모양으로, HTTP 200 .
|
||||||
|
기대 결과 cases/lab01.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `3` | 조건 표 "3번" (`maxTries`는 첫 시도를 포함한 수) |
|
||||||
|
| ② | `continueRegularOutput` | 실패한 항목이 `error`를 가진 채 다음 Code로 (AGENTS.md 5절) |
|
||||||
|
| ③ | 요청의 `id` | 같은 문의를 다시 보내도 바뀌지 않는 것. `$execution.id`는 실행마다 바뀜 (예제 1의 `10057` → `10060`) |
|
||||||
|
| ④ | `200` | 부분 성공. 일부가 실패해도 항목마다 상태로 알림 |
|
||||||
|
|
||||||
|
정답: [reference/lab01-example2-intake.json](reference/lab01-example2-intake.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/02`, 기준에 같은 테스트) | **0/6** · 결과 모양이 달라 `null`, 접수 건 6 (중복), 빈 글 · 목록 아님 · `flaky` 모두 500 |
|
||||||
|
| 고친 뒤 (`answers/03`) | **6/6** · 다시 보낸 3건 `duplicates: 3`, `flaky` 2건 재시도로 성공 (2.4초), 빈 글만 `rejected` (1.7초) |
|
||||||
|
|
||||||
|
## 예제 3 · 결과 (에이전트 없이, `answers/04`)
|
||||||
|
|
||||||
|
| 고장 | 시간 | 결과 | 이유 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `flaky` | 2.4초 | `created: 2` | 첫 시도 500 → 두 번째 성공 |
|
||||||
|
| `error` | 3.4초 | `failed: 3` | `500 - tickets service is failing (fault switch)` 세 번 |
|
||||||
|
| `slow` | **12.4초** | `failed: 3` | `timeout of 3000ms exceeded` 세 번 |
|
||||||
|
| 8초 뒤 접수 건 | | **5** (2 + 3) | 실패라던 R-101~103이 늦게 만들어짐. 시도 9번 · 키가 같아 3건 |
|
||||||
|
| 다시 보냄 | 2.9초 | `duplicates: 3` | 키가 이미 있음 |
|
||||||
|
|
||||||
|
- 이 예제의 답 두 가지: ① 재시도는 **잠깐의 실패에만** 약이고, 멈춤 · 느림에는 시간만 늘린다 (`tretry --deadline`으로 이어짐). ② 시간 초과는 **"모름"**이다 — `failed`라고 말한 것이 만들어져 있다. 그래서 시간 초과 뒤 재시도 · 재처리에는 멱등 키가 꼭 있어야 한다 (Lab 03 예제 3).
|
||||||
|
|
||||||
|
## 과제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 에서 Classify(AI Agent) 노드가 실패해도 워크플로우가 멈추지 않게 onError continueRegularOutput 로 해 줘.
|
||||||
|
Parse 에서 그 항목에 error 가 있거나 output 이 JSON 이 아니면 category "미분류", priority "보통" 으로.
|
||||||
|
결과의 unclassified 에 미분류로 접수한 수를 세어 줘. 나머지는 그대로.
|
||||||
|
기대 결과 cases/lab01-challenge.json, cases/lab01.json 도 그대로 통과.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `continueRegularOutput` | `Create ticket`과 같은 값. 실패한 항목이 다음으로 |
|
||||||
|
| ② | `error` | 실패한 항목은 `output` 대신 `error`를 가짐 |
|
||||||
|
| ③ | `미분류` | 팀장의 말 |
|
||||||
|
| ④ | `unclassified` | AGENTS.md 3절 결과 모양 |
|
||||||
|
|
||||||
|
정답: [answers/76-lab01-challenge-fallback.json](answers/76-lab01-challenge-fallback.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/05`) | **1/3** · `llm down`에서 0.8초에 500, 접수 0 |
|
||||||
|
| 고친 뒤 (`answers/06`) | **3/3** · `created: 3, unclassified: 3` (0.9초), `lab01` 6/6 |
|
||||||
|
|
||||||
|
- 풀이 때: 72일차 "간단 답변"과 같은 생각(덧붙인 일은 줄여서라도 핵심 일을 끝냄)이고, `unclassified` 수가 "얼마나 줄였는지를 숨기지 않기"라는 점을 짚습니다.
|
||||||
|
|
||||||
|
## 과제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 에서 requests 가 목록이어도 10 건이 넘으면 HTTP 400 {"error": "too many requests (max 10)"} 로, 하나도 처리하지 말아 줘.
|
||||||
|
목록이 아닐 때의 400 글은 그대로 "requests must be a list".
|
||||||
|
Check items 에서 같은 요청 안에 같은 id 가 또 나오면 두 번째부터 rejected, 이유 "duplicate id in this request".
|
||||||
|
앞에서 본 id 는 Set 에 기억해 둔다. 나머지 규칙(빠진 칸 거절 · 재시도 · 키 · 미분류)은 그대로.
|
||||||
|
기대 결과 cases/lab01-challenge2.json, cases/lab01.json · lab01-challenge.json 도 통과.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `10` | "10건까지만" — 10은 받고 11부터 400 |
|
||||||
|
| ② | `두` | 처음 나온 것은 받음 |
|
||||||
|
| ③ | `duplicate id in this request` | 조건 표 그대로 (테스트가 `duplicate id`를 찾음) |
|
||||||
|
| ④ | `Set` | 본 id를 빨리 찾는 모음 (배열 `includes`도 되지만 Set이 알맞음) |
|
||||||
|
|
||||||
|
정답: [answers/76-lab01-challenge2-rules.json](answers/76-lab01-challenge2-rules.json) (`Is a list?`의 조건에 `length <= 10`, `Reject body`의 글을 두 경우로)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/07`) | **1/4** · 같은 id: `created: 3, duplicates: 1` (분류 세 번), 11건: **11.0초**에 200 · 접수 건 13 |
|
||||||
|
| 고친 뒤 (`answers/08`) | **4/4** · 같은 id 두 번째만 `rejected`, 11건 **0.2초**에 400 · 접수 건 2 그대로, `lab01` 6/6 · `lab01-challenge` 3/3 |
|
||||||
|
|
||||||
|
## 2교시에 볼 것
|
||||||
|
|
||||||
|
| 볼 것 | 막혔을 때 한마디 |
|
||||||
|
| --- | --- |
|
||||||
|
| 재시도 설정을 `parameters` 안에 씀 | "`check`가 알려 준 대로 노드 바로 아래로" (`flow.py check`가 오류로 잡음) |
|
||||||
|
| 거절한 항목이 결과에 없음 | "Merge 두 번째 입력(`index: 1`)" |
|
||||||
|
| `duplicates`가 0 | "키가 실행 번호인지 요청 id인지" |
|
||||||
|
| `failed`와 `created`가 엉뚱한 항목에 | "Mark results가 `Parse`의 `.all()`과 같은 순서인지" |
|
||||||
|
| `slow` 케이스를 오래 기다림 | "정상. 12초가 바로 이 예제의 숫자" |
|
||||||
|
| 같은 id를 멱등 키로 충분하다고 함 | "결과가 세 건이라고 말하고 분류를 세 번 했다" |
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
# Lab 01 · 실패를 겪어 보고, 항목마다 처리하고, 중복 없이 다시 해 보기
|
||||||
|
|
||||||
|
- 관련 강의: [1단계 · 실패의 종류](../../Lecture/76/01-STAGE1-FAILURE-TYPES.md)
|
||||||
|
- 소요 시간: 50분 (2교시 한 시간) · 예제 1 10분 · 예제 2 15분 · 예제 3 7분 · 과제 1 8분 · 과제 2 10분
|
||||||
|
- 준비: [실습 준비](../../Lecture/76/00-DAY76-PREP.md)의 `doctor` → `ready`, `base-intake` · `nightly-report` 게시
|
||||||
|
- 목표: 여러 건을 한 번에 처리할 때 **한 건의 실패가 전체를 망치지 않게** 한다. 잘못된 항목은 그 항목만 거절하고, 잠깐의 실패는 다시 해 보고, 다시 해도 **접수 건이 두 번 생기지 않게** 한다. 고장 종류마다 재시도가 무엇을 하는지 재 보고, LLM이 멈춰도 접수는 하고, 요청 안의 규칙(같은 id · 10건 상한)을 지킨다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
E1["예제 1<br/>기준을 망가뜨려 보기"] --> E2["예제 2<br/>항목 확인 · 재시도 ·<br/>멱등 키"] --> E3["예제 3<br/>고장 종류마다<br/>재시도가 하는 일"] --> T1["과제 1<br/>LLM이 멈춰도 접수"] --> T2["과제 2<br/>요청 안의 규칙"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py ops --reset`.
|
||||||
|
|
||||||
|
## 시작하기 · 터미널 두 개
|
||||||
|
|
||||||
|
| 터미널 | 하는 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/76` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 |
|
||||||
|
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/76` 에서 `call` · `push` · `test` · `ops` · `fault`를 **내가** 실행한다 |
|
||||||
|
|
||||||
|
`cp templates/RUNBOOK-TEMPLATE.md my-work/RUNBOOK.md` 후 표를 채워 갑니다. 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다.
|
||||||
|
|
||||||
|
## 예제 1 · 기준을 망가뜨려 보기 (10분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 오늘 고칠 기준 워크플로우(`base-intake`)가 실패할 때 **무엇을 숨기는지** 직접 봅니다. 고치는 것은 예제 2부터이고, 여기서는 **망가뜨려 보고 적기만** 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 마트 계산대.** 계산원이 "결제 실패"라고만 했는데 영수증을 보니 앞의 물건은 이미 결제돼 있다면, 다시 계산하면 두 번 냅니다. 예제 1은 일부러 이런 상황을 만들어, **들은 말**(워크플로우의 답, `call`)과 **실제로 일어난 일**(영수증, `ops`)을 나란히 보는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** `base-intake`는 문의 여러 건(`requests[]`)을 한 번에 받아, 한 건씩 LLM으로 분류하고 데이터 API에 접수 건을 만듭니다. 정상 요청이면 잘 됩니다. 그런데 운영하다 보면 이런 일이 생깁니다.
|
||||||
|
|
||||||
|
- 보낸 문의 중 하나가 잘못됨 (글이 비어 있음)
|
||||||
|
- 데이터 API가 잠깐 끊김
|
||||||
|
- 사용자가 답을 못 받았다며 같은 요청을 다시 보냄
|
||||||
|
|
||||||
|
이 셋을 하나씩 일부러 일으켜, **사용자가 받은 답**과 **데이터 API에 실제로 생긴 접수 건**을 나란히 봅니다. 둘이 다르면 그것이 오늘 고칠 문제입니다.
|
||||||
|
|
||||||
|
**실험 셋.**
|
||||||
|
|
||||||
|
| 실험 | 일으키는 것 | 보낼 요청 | 볼 것 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ① 잘못된 항목 | 3건 중 가운데 글이 빔 | `batch-bad-item` (R-201 · 빈 R-202 · R-203) | 답은 성공인가 실패인가? 그런데 `ops`에 접수 건이 생겼나, 몇 개? |
|
||||||
|
| ② 잠깐의 끊김 | `fault tickets flaky`: 접수 건 만들기가 키마다 첫 시도만 실패 | `batch-two` (R-301 · R-302) | 한 번만 더 했으면 됐을 실패를 기준은 어떻게 끝내나? |
|
||||||
|
| ③ 다시 보내기 | 고장을 끄고 정상 3건을 한 번 더 | `batch-ok` (R-101~103) 두 번째 | 같은 문의의 접수 건이 몇 개가 되나? `ops`의 `key` 칸이 첫 번째와 같나? |
|
||||||
|
|
||||||
|
`call`은 웹훅을 한 번 불러 **워크플로우의 답**을 보여 주고, `ops`는 데이터 API에 지금 있는 접수 건 · DLQ · 알림 · 고장 스위치를 보여 줍니다. `ops`는 답이 아니라 **실제로 생긴 것**을 보는 창입니다. 두 화면을 번갈아 봅니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 기준은 ① 한 건 때문에 전체를 실패라고 하면서 다른 건은 이미 만들었고, ② 잠깐의 끊김도 바로 실패로 끝내고, ③ 같은 문의를 다시 보내면 또 만듭니다. 예제 2에서 이 셋을 **항목 확인 · 재시도 · 멱등 키**로 하나씩 고칩니다.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/RUNBOOK.md` 1.1 표 (한 일 · 사용자가 본 것 · 실제로 생긴 것), 실험마다 한 줄.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py ops --reset # 접수 건 · DLQ · 알림 비우기, 고장 스위치 모두 ok
|
||||||
|
|
||||||
|
# 0. 정상일 때 (비교 기준)
|
||||||
|
uv run tools/flow.py call base-intake requests/batch-ok.json # 정상 3건
|
||||||
|
|
||||||
|
# ① 잘못된 항목
|
||||||
|
uv run tools/flow.py call base-intake requests/batch-bad-item.json # 가운데 항목의 text가 비어 있음
|
||||||
|
uv run tools/flow.py ops # 답은 실패였는데, 접수 건은 몇 개?
|
||||||
|
|
||||||
|
# ② 잠깐의 끊김
|
||||||
|
uv run tools/flow.py fault tickets flaky # 접수 건 만들기가 키마다 첫 시도에 실패
|
||||||
|
uv run tools/flow.py call base-intake requests/batch-two.json
|
||||||
|
uv run tools/flow.py fault tickets ok # 고장 끄기
|
||||||
|
|
||||||
|
# ③ 다시 보내기
|
||||||
|
uv run tools/flow.py call base-intake requests/batch-ok.json # 0번과 같은 요청을 다시
|
||||||
|
uv run tools/flow.py ops # VPN 문의(R-101)의 접수 건은 몇 개? key는?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**여러 건을 한 번에 처리하는 일(일괄 처리, batch)에는 "반쯤 된" 상태가 있습니다.** 한 건만 처리하면 결과는 됐다 · 안 됐다 둘 중 하나입니다. 세 건을 처리하다 두 번째에서 멈추면 첫 번째는 이미 끝났고, 세 번째는 했는지 안 했는지 모릅니다. 그런데 사용자에게는 `500` 하나만 갑니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Q(["requests 3건"]) --> S["하나씩 나누기"] --> C{{"LLM 분류"}} --> T["접수 건 만들기<br/>key = 실행 번호"]
|
||||||
|
T -->|1번째 · 3번째| OK["만들어짐"]
|
||||||
|
T -->|2번째: 빈 글 → 400| X["노드 오류 → 전체 500"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**오늘 기준이 가진 문제 셋.**
|
||||||
|
|
||||||
|
| 문제 | 겉으로 | 실제로 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 한 건의 실패가 전체를 멈춤 | HTTP 500 · `Error in workflow` | 다른 건은 이미 접수됨 (부분 처리) |
|
||||||
|
| 잠깐의 실패도 바로 실패 | 500 | 1초 뒤에 다시 했으면 됐을 것 |
|
||||||
|
| 다시 보내면 또 만듦 | 200 · 접수 건 번호 3개 | 같은 문의의 접수 건이 둘 (중복) |
|
||||||
|
|
||||||
|
**왜 중복이 생기나.** 기준은 접수 건을 만들 때 키를 `$execution.id + '-' + $itemIndex`(이번 실행 번호와 순서)로 정합니다. 같은 문의를 다시 보내면 실행 번호가 다르니 데이터 API는 다른 요청으로 봅니다. "같은 문의"를 알아보게 하려면 **실행마다 바뀌지 않는 것**(요청의 `id`)이 키여야 합니다 (예제 2).
|
||||||
|
|
||||||
|
**고장 스위치.** 오늘 데이터 API에는 접수 건 만들기(`tickets`)와 DLQ(`dlq`)에 고장 스위치가 있습니다. `flaky`는 키마다 첫 시도만 실패시킵니다 (잠깐의 끊김), `error`는 늘 500, `slow`는 8초를 기다린 뒤 답합니다. 연습이 끝나면 `ops --reset`이 접수 건 · DLQ · 알림을 비우고 스위치를 모두 `ok`로 돌립니다.
|
||||||
|
|
||||||
|
**기대 결과.** 빈 글 하나가 섞인 3건 → 500인데 접수 건 2개가 생깁니다. `flaky`에서는 500. 같은 정상 3건을 두 번 보내면 VPN 문의의 접수 건이 두 개가 됩니다. `RUNBOOK.md` 1절에 "사용자가 본 것 / 실제로 생긴 것"을 나란히 적습니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 실패를 나누기</summary>
|
||||||
|
|
||||||
|
| 실패 | 다시 하면 되나 |
|
||||||
|
| --- | --- |
|
||||||
|
| 입력이 잘못됨 (빈 글) | 아니오 — 몇 번을 해도 같음 |
|
||||||
|
| API가 잠깐 실패 | 예 |
|
||||||
|
| API가 오래 멈춤 | 지금은 아니오, 나중에는 예 |
|
||||||
|
|
||||||
|
이 셋을 똑같이 "500"으로 끝내면 무엇을 잃을까요? 사용자는 무엇을 할까요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 중복은 왜 생기나</summary>
|
||||||
|
|
||||||
|
`ops`의 `key` 칸을 봅니다. 두 번째 `batch-ok`의 키는 첫 번째와 같나요? 같은 요청을 알아보게 하려면 무엇을 키로 써야 할까요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 실행 결과 · 기준을 망가뜨려 보기
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다 (줄임). LLM 분류 · 시간 · 접수 건 번호의 순서는 실행마다 조금씩 다를 수 있고, 500 · 접수 건 수는 같아야 합니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ call base-intake requests/batch-ok.json
|
||||||
|
HTTP 200 · 5.2s {"processed": 3, "tickets": ["T-2001", "T-2002", "T-2003"]}
|
||||||
|
|
||||||
|
$ call base-intake requests/batch-bad-item.json
|
||||||
|
HTTP 500 · 3.2s {"message": "Error in workflow"}
|
||||||
|
|
||||||
|
$ ops
|
||||||
|
created tickets: 5
|
||||||
|
T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요
|
||||||
|
T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요
|
||||||
|
|
||||||
|
$ fault tickets flaky → call base-intake requests/batch-two.json
|
||||||
|
HTTP 500 · 2.5s {"message": "Error in workflow"}
|
||||||
|
|
||||||
|
$ fault tickets ok → call base-intake requests/batch-ok.json
|
||||||
|
HTTP 200 · 3.0s {"processed": 3, "tickets": ["T-2006", "T-2008", "T-2007"]}
|
||||||
|
|
||||||
|
$ ops
|
||||||
|
created tickets: 8
|
||||||
|
T-2006 key 10060-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 빈 글이 섞인 요청은 **500인데 접수 건이 2개(T-2004 · T-2005) 생겼습니다.** 접수 건 만들기 노드가 1번째 · 3번째는 만들고, 2번째에서 데이터 API가 `title`이 비었다고 400을 주자 노드가 오류로 멈췄기 때문입니다. 사용자는 "실패"로 알고 다시 보낼 것이고, 그러면 두 건이 또 생깁니다.
|
||||||
|
- `flaky`에서 500: 첫 시도만 실패하는 **잠깐의 끊김**인데도 기준에는 재시도가 없어 바로 멈췄습니다.
|
||||||
|
- 같은 정상 3건을 다시 보내자 **새 번호 3개(T-2006~2008)** 가 생겼습니다. VPN 문의 접수 건이 T-2001 · T-2006 둘입니다. 키가 `10057-0` → `10060-0`처럼 실행 번호라서, 데이터 API는 같은 문의인 줄 모릅니다.
|
||||||
|
|
||||||
|
## 예제 2 · 항목 확인 · 재시도 · 멱등 키 (15분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 예제 1에서 본 세 문제를 내 워크플로우(`my-intake`)에서 고칩니다. Lab 01의 중심 실습입니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 택배 배송 조회 + 송장 번호.** 좋은 택배 회사는 ① 상자마다 "배달 완료 / 주소 불명 반송"을 따로 보여 주고(부분 성공), ② 벨을 눌러 대답이 없으면 한 번 더 눌러 보고(재시도), ③ 같은 송장 번호의 상자는 두 번 배달하지 않습니다(멱등 키). 예제 2는 `my-intake`를 이런 택배 회사로 만드는 실습입니다.
|
||||||
|
|
||||||
|
| 예제 1의 문제 | 고치는 방법 | 확인하는 테스트 케이스 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① 한 건이 잘못되면 전체 500, 다른 건은 이미 접수 | 항목마다 확인해 잘못된 것만 `rejected`, 결과는 늘 200에 항목마다 상태 (부분 성공) | 잘못된 항목 · 목록이 아님 |
|
||||||
|
| ② 잠깐의 끊김도 바로 실패 | 접수 건 만들기 노드에 재시도 3번, 그래도 실패면 `failed`로 적고 계속 | 가끔 실패 (`flaky`) |
|
||||||
|
| ③ 다시 보내면 또 만듦 | 접수 건의 `key`를 실행 번호 대신 요청 `id`(R-101)로 | 같은 요청을 다시 · 접수 건 수 |
|
||||||
|
|
||||||
|
**왜 셋을 한 번에.** 셋은 서로 기대고 있습니다. 재시도(②)를 넣으면 n8n은 노드에 들어온 항목을 **모두** 다시 보냅니다. 이미 만든 접수 건도 또 보내므로, 멱등 키(③) 없이는 재시도가 중복을 만듭니다. 그리고 재시도 뒤에도 실패한 항목을 `failed`로 남기려면 항목마다 상태를 담는 결과 모양(①)이 먼저 있어야 합니다.
|
||||||
|
|
||||||
|
**순서.** 기준을 복사 → 같은 테스트를 **기준에 먼저** 돌려 0/6을 봄 (무엇이 틀렸는지 케이스마다 확인) → 요청문을 써서 에이전트에게 고치게 함 → push → 6/6.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** "일부가 실패해도 200 + 항목마다 상태"가 왜 500보다 정직한지, n8n 재시도가 노드의 어디에 붙고 무엇을 다시 보내는지, 멱등 키가 재시도를 어떻게 안전하게 만드는지.
|
||||||
|
|
||||||
|
**만들 것**: 기준을 복사해, 항목마다 확인하고, 접수 건 만들기를 다시 해 보게 하고, 요청 `id`를 멱등 키로. 결과는 늘 200 (부분 성공).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp reference/baseline-intake.json my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab01.json --webhook base-intake -v # 고치기 전 (기준에 같은 테스트)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 파일 · 웹훅 | `my-work/intake.json` · `my-intake` (경로 · 이름을 바꿈) |
|
||||||
|
| 거절 | id · user · text가 없는 항목 → `rejected`와 이유, 나머지는 처리 |
|
||||||
|
| 재시도 | timeout 3초, 3번, 0.3초 간격 · 그래도 실패면 `failed` (멈추지 않음) |
|
||||||
|
| 결과 · 400 | [AGENTS.md](AGENTS.md) 3절 (`requests`가 목록이 아니면 400) |
|
||||||
|
| 기대 결과 | `cases/lab01.json` |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**지금 무엇이 문제인가.** 예제 1의 세 문제(한 건이 전체를 멈춤 · 잠깐의 실패도 실패 · 다시 보내면 중복)를 한 번에 고칩니다. 셋은 따로 보이지만 서로 이어져 있습니다. 재시도를 넣으면 **이미 만든 건을 또 보내게** 되므로, 재시도와 멱등 키는 늘 함께 갑니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 지금
|
||||||
|
A1(["3건"]) --> B1["하나씩"] --> C1{{"분류"}} --> D1["접수<br/>key = 실행 번호<br/>재시도 없음"] -->|하나라도 실패| X1(["500"])
|
||||||
|
end
|
||||||
|
subgraph 바꾼 뒤
|
||||||
|
A2(["3건"]) --> V2{"항목 확인"}
|
||||||
|
V2 -->|맞음| C2{{"분류"}} --> D2["접수<br/>key = 요청 id<br/>재시도 3 · 계속"] --> M2["모으기"]
|
||||||
|
V2 -->|빠짐| R2["거절 · 이유"] --> M2
|
||||||
|
M2 --> O2(["200 · 항목마다 상태"])
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**부분 성공.** 결과는 늘 HTTP 200이고, 항목마다 `created` · `failed` · `rejected`와 이유를 담습니다. "모두 되거나 500" 대신 "무엇이 되고 무엇이 안 됐는지"를 알려 줍니다. 받는 쪽은 `rejected`는 고쳐서, `failed`는 나중에 다시 보내면 됩니다.
|
||||||
|
|
||||||
|
**n8n의 재시도는 노드 단위입니다.** `retryOnFail` · `maxTries` · `waitBetweenTries`는 노드 **바로 아래**(`parameters` 밖)에 둡니다. 그리고 그 노드에 들어온 **항목을 모두** 다시 보냅니다. 세 건 중 하나만 실패해도 세 건을 다시 보냅니다. 그래서 키가 필요합니다.
|
||||||
|
|
||||||
|
**멱등 키(idempotency key).** 같은 요청을 몇 번 보내도 결과가 한 번 한 것과 같게 하는 표시입니다. 오늘 데이터 API는 `POST /tickets`의 `key`가 같으면 새로 만들지 않고 처음 번호를 돌려줍니다 (`duplicate: true`).
|
||||||
|
|
||||||
|
| 키 | 같은 요청을 다시 보내면 |
|
||||||
|
| --- | --- |
|
||||||
|
| 실행 번호 (`$execution.id`) | 매번 새 키 → 중복 |
|
||||||
|
| **요청 `id`** (`R-101`) | "이미 만든 건" → 같은 번호, `duplicate: true` |
|
||||||
|
|
||||||
|
**실패해도 다음으로.** 재시도 뒤에도 실패한 항목에서 멈추면 안 됩니다. `onError: continueRegularOutput`이면 실패한 항목이 `error`를 가진 채 다음 Code로 갑니다. 다음 Code는 앞 노드(`Parse`)의 항목과 **같은 순서**로 맞춰 `created` / `failed`를 정합니다.
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 보내는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 정상 3건 | `batch-ok` | `created: 3` |
|
||||||
|
| 같은 요청을 다시 | `batch-ok` 또 | `created: 3, duplicates: 3` |
|
||||||
|
| 접수 건 수 | `/tickets-created/list` | `count: 3` (중복 없음) |
|
||||||
|
| 잘못된 항목 | `batch-bad-item` | `created: 2, rejected: 1`, 이유 `text is required` |
|
||||||
|
| 목록이 아님 | `not-a-list` | HTTP 400 |
|
||||||
|
| 가끔 실패 | `flaky` + `batch-two` | `created: 2, failed: 0` (재시도로 성공) |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전(기준에 같은 테스트) 0/6, 고친 뒤 6/6.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 거절한 항목은 어디로</summary>
|
||||||
|
|
||||||
|
거절한 항목은 LLM · API로 보내지 않습니다. 하지만 결과에는 있어야 합니다. 두 갈래(처리 · 거절)를 다시 하나로 모으는 노드가 [AGENTS.md](AGENTS.md) 5절에 있습니다. 두 번째 입력은 `index: 1`.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 재시도와 중복</summary>
|
||||||
|
|
||||||
|
n8n 노드의 재시도는 그 노드에 들어온 **항목을 모두** 다시 보냅니다. 이미 성공한 항목도요. 멱등 키가 있으면 데이터 API가 "이미 만든 건"이라며 같은 번호를 돌려줍니다 (`duplicate: true`).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 3단계 · 실패한 항목도 다음으로</summary>
|
||||||
|
|
||||||
|
재시도 뒤에도 실패한 항목에서 워크플로우가 멈추면 안 됩니다. 실패한 항목이 `error`를 가진 채 다음 노드로 가게 하는 설정과, 다음 Code에서 앞 노드 항목과 순서를 맞추는 방법을 요청문에 적습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 항목 확인과 거절 이유 ③ 재시도 설정과 실패해도 계속 ④ 멱등 키 ⑤ 두 갈래 모으기 ⑥ 결과 모양 · 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json (baseline 을 복사한 것) 의 웹훅 경로를 my-intake 로, 이름을 "76 Intake (my)" 로 바꿔 줘.
|
||||||
|
requests 가 목록이 아니면 400 {"error": "requests must be a list"}.
|
||||||
|
항목마다 id · user · text 를 확인해서, 빠진 것이 있으면 LLM 과 API 로 보내지 말고 status rejected 와 이유("text is required" 등)로.
|
||||||
|
Create ticket 노드에 timeout 3000ms, 재시도 ____① 번, 간격 300ms, 그리고 실패해도 다음으로 가게 onError ____② .
|
||||||
|
접수 건의 key 는 ____③ (실행 번호가 아님).
|
||||||
|
처리한 갈래와 거절한 갈래를 Merge 로 모아 AGENTS.md 3절 결과 모양으로, HTTP ____④ .
|
||||||
|
기대 결과 cases/lab01.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||||
|
|
||||||
|
- ① 위 조건 표의 "재시도"입니다. 처음 한 번을 포함한 수입니다.
|
||||||
|
- ② AGENTS.md 5절 접수 건 만들기 노드의 설정입니다. 멈추지 않고 실패한 항목이 다음으로 가는 값.
|
||||||
|
- ③ 같은 문의를 다시 보내도 **바뀌지 않는** 것입니다. 예제 1의 `ops`에서 키가 왜 바뀌었는지 떠올립니다.
|
||||||
|
- ④ 부분 성공의 상태 코드입니다. 일부가 실패해도 이것.
|
||||||
|
|
||||||
|
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab01.json -v
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 항목 확인 · 재시도 · 멱등 키
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다.
|
||||||
|
|
||||||
|
고치기 전 (`test cases/lab01.json --webhook base-intake`)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 정상 3건 · 2.7s · created = null (expected 3); failed = null (expected 0); rejected = null (expected 0)
|
||||||
|
✘ 같은 요청을 다시 보내도 중복 없음 · 3.1s · created = null (expected 3); duplicates = null (expected 3)
|
||||||
|
✘ 만들어진 접수 건은 3개 · 0.0s · count = 6 (expected 3)
|
||||||
|
✘ 잘못된 항목은 그 항목만 거절 · 3.4s · status 500 (expected 200); ...
|
||||||
|
✘ 목록이 아니면 400 · 0.1s · status 500 (expected 400)
|
||||||
|
✘ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.0s · status 500 (expected 200); ...
|
||||||
|
Passed 0/6
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤 (`test cases/lab01.json`)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 정상 3건 · 3.9s
|
||||||
|
✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s
|
||||||
|
✔ 만들어진 접수 건은 3개 · 0.0s
|
||||||
|
✔ 잘못된 항목은 그 항목만 거절 · 1.7s
|
||||||
|
✔ 목록이 아니면 400 · 0.0s
|
||||||
|
✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s
|
||||||
|
Passed 6/6
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전에 `created = null`인 것은 기준의 결과가 `{processed, tickets}` 모양이라 오늘 계약의 칸이 없기 때문입니다. 접수 건이 **6개**(3이 기대)인 것은 같은 3건을 두 번 보내 중복이 생겼기 때문입니다. 목록이 아닌 본문도 확인 없이 하나씩 나누다 500이 되었습니다.
|
||||||
|
- 고친 뒤 두 번째 `batch-ok`는 `created: 3, duplicates: 3`입니다. 키가 `R-101`처럼 요청 `id`라서 데이터 API가 처음 번호를 돌려줬습니다. 접수 건은 3개 그대로.
|
||||||
|
- `flaky`에서 2건 모두 `created`: 첫 시도가 500이었지만 0.3초 뒤 두 번째 시도에서 성공했습니다. 재시도는 두 건을 **모두** 다시 보냈지만, 키 덕분에 중복이 생기지 않았습니다.
|
||||||
|
- 잘못된 항목 케이스가 1.7초로 빠른 것은 빈 글 하나를 LLM에 보내지 않고 거절했기 때문입니다.
|
||||||
|
|
||||||
|
## 예제 3 · 고장 종류마다 재시도가 하는 일 (7분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 예제 2에서 넣은 재시도가 **어떤 고장에는 도움이 되고, 어떤 고장에는 시간만 늘리는지** 재 봅니다. 워크플로우는 고치지 않고(에이전트 없이), 데이터 API의 고장 종류만 바꿔 가며 같은 `my-intake`를 부릅니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 전화 다시 걸기.** 통화 중이면 다시 걸면 됩니다(`flaky`). 상대 전화국이 고장이면 열 번 걸어도 시간만 갑니다(`error`). 상대가 받았는데 말소리가 늦게 들려 내가 먼저 끊었다면, 상대는 사실 내 말을 다 들었을 수 있습니다(`slow`). 예제 3은 세 경우에 "다시 걸기"가 각각 무엇을 하는지 재 봅니다.
|
||||||
|
|
||||||
|
**왜 하나.** 재시도를 넣으면 "이제 실패는 해결"이라고 생각하기 쉽습니다. 하지만 고장은 종류가 다릅니다.
|
||||||
|
|
||||||
|
| 고장 스위치 | 현실에서는 | 물어볼 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `flaky` | 네트워크가 잠깐 끊김 · 서버가 한 번 버벅임 | 재시도가 구해 주나? |
|
||||||
|
| `error` | 데이터 API가 죽음 (몇 분 · 몇 시간) | 세 번 다시 하면 달라지나? 무엇이 늘어나나? |
|
||||||
|
| `slow` | 데이터 API가 느림 (8초) — 우리는 3초만 기다림 | 우리가 "실패"라고 포기한 요청은 정말 실패했나? |
|
||||||
|
|
||||||
|
`cases/lab01-faults.json`이 이 셋을 차례로 켜고, 마지막에 8초를 기다린 뒤 접수 건 목록을 봅니다. **`slow`에서 포기한 3건이 나중에 생겼는지**가 이 예제의 핵심 관찰입니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 재시도는 잠깐의 실패에만 약이고, 오래 멈춘 고장에는 다른 그물(Lab 02의 DLQ)이 필요하며, 시간 초과는 "실패"가 아니라 "모름"이라서 멱등 키가 꼭 있어야 한다는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/RUNBOOK.md` 1.2 표 (고장 · 걸린 시간 · 결과 · 실제로 생긴 접수 건).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-faults.json -v
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**재시도는 잠깐의 실패에만 약입니다.**
|
||||||
|
|
||||||
|
| 고장 | 무엇 | 재시도를 하면 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `flaky` (잠깐 끊김) | 키마다 첫 시도만 500 | 두 번째에 성공 → **좋아짐** |
|
||||||
|
| `error` (멈춤) | 늘 500 | 세 번 다 실패 → 결과는 같고 **시간만 더** |
|
||||||
|
| `slow` (느림) | 8초 뒤에 답 | 3초마다 끊고 다시 → 세 번 × 3초 + 간격, 그래도 실패 |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant W as my-intake
|
||||||
|
participant A as 데이터 API (slow)
|
||||||
|
W->>A: 1번째 (key R-101)
|
||||||
|
Note over W: 3초 timeout → 포기
|
||||||
|
W->>A: 2번째 (같은 key)
|
||||||
|
Note over W: 3초 timeout → 포기
|
||||||
|
W->>A: 3번째 (같은 key)
|
||||||
|
Note over W: 3초 timeout → failed
|
||||||
|
A-->>A: 8초 뒤: 접수 건 R-101 만들기 (늦게)
|
||||||
|
```
|
||||||
|
|
||||||
|
**시간 초과(timeout)는 "실패"가 아니라 "모름"입니다.** 3초 안에 답이 오지 않아 포기했지만, 데이터 API는 요청을 받았고 8초 뒤에 **접수 건을 만들었습니다.** 워크플로우는 `failed`라고 말하지만 실제로는 만들어져 있습니다. 이때 같은 키로 다시 보내면 `duplicate: true`로 처음 번호를 받습니다. 멱등 키가 없었다면 두 번째 접수 건이 생깁니다. 이것이 **"시간 초과 뒤 재시도"에 멱등 키가 꼭 필요한** 까닭입니다 (Lab 03 예제 3에서 재처리로 다시 봄).
|
||||||
|
|
||||||
|
**재시도는 사용자를 기다리게 합니다.** `slow`에서 사용자는 3초 × 3번 + 간격 + LLM 분류 = 12초쯤 기다리고 `failed`를 받습니다. "재시도를 늘리면 더 잘 된다"가 아니라, **기다릴 수 있는 시간 안에서** 몇 번 할지 정합니다 (바이브 코딩 `tretry`의 마감 `--deadline`).
|
||||||
|
|
||||||
|
**테스트 케이스.** 이 파일은 내 워크플로우를 바꾸지 않고 **고장만 바꿔 가며** 잽니다.
|
||||||
|
|
||||||
|
| 케이스 | 고장 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| flaky | `tickets flaky` · `batch-two` | `created: 2` |
|
||||||
|
| error | `tickets error` · `batch-ok` | `failed: 3` |
|
||||||
|
| slow | `tickets slow` · `batch-ok` | `failed: 3` (오래 걸림) |
|
||||||
|
| 늦게 만들어짐 | 8초 기다린 뒤 `/tickets-created/list` | `count: 5` (flaky 2 + 실패라던 3) |
|
||||||
|
| 다시 보냄 | `ok` · `batch-ok` | `created: 3, duplicates: 3` |
|
||||||
|
|
||||||
|
**기대 결과.** 5/5. 시간은 flaky < error < slow이고, slow는 10초가 넘습니다. 실패라던 3건이 접수 건 목록에 있습니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · error에서 3번은 좋은가</summary>
|
||||||
|
|
||||||
|
데이터 API가 한 시간 멈췄다면, 요청마다 세 번씩 보내는 것은 무엇을 늘릴까요? 그 사이 다른 워크플로우도 똑같이 세 번씩 보낸다면? (강의 1단계 "재시도 폭풍")
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · failed인데 만들어진 건</summary>
|
||||||
|
|
||||||
|
결과의 `failed` 3건과 `ops`의 접수 건 목록을 나란히 봅니다. 사용자에게 "실패"라고 말한 것이 사실은 됐다면, 사용자는 무엇을 할까요? 그때 무엇이 중복을 막나요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 실행 결과 · 고장 종류마다
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 예제 2 정답으로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다.
|
||||||
|
|
||||||
|
`test cases/lab01-faults.json` (결과 줄과 이유만)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ flaky · 다시 해 보면 됨 · 2.4s
|
||||||
|
✔ error · 세 번 해도 실패 · 3.4s
|
||||||
|
"reason": "500 - \"{\"error\": \"tickets service is failing (fault switch)\"}\""
|
||||||
|
✔ slow · 3초씩 세 번 기다리다 실패 · 12.4s
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
… before: wait 8s
|
||||||
|
✔ 실패라던 3건이 늦게 만들어짐 (2 + 3) · 0.0s
|
||||||
|
✔ 같은 요청을 다시 · 멱등 키로 중복 없음 · 2.9s
|
||||||
|
Passed 5/5
|
||||||
|
|
||||||
|
$ ops
|
||||||
|
created tickets: 5
|
||||||
|
T-2001 key R-301 · T-2002 key R-302 · T-2003 key R-103 · T-2004 key R-102 · T-2005 key R-101
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- `flaky` 2.4초: 첫 시도 500 → 0.3초 뒤 두 번째 성공. 재시도가 **도움이 된** 경우입니다.
|
||||||
|
- `error` 3.4초: 세 번 모두 500이라 `failed: 3`. 재시도가 결과를 바꾸지 못하고 시간만 더했습니다 (분류 약 2.5초 + 시도 셋과 간격 둘).
|
||||||
|
- `slow` 12.4초: 시도마다 3초에 끊겨(`timeout of 3000ms exceeded`) 세 번 실패했습니다. 사용자는 12초를 기다리고 실패를 받았습니다.
|
||||||
|
- 그런데 8초 뒤 접수 건이 **5개**입니다. 실패라던 R-101 · R-102 · R-103이 늦게 만들어졌습니다 (데이터 API가 8초 뒤 처리). 시도는 9번 갔지만 키가 같아 3건만 생겼습니다.
|
||||||
|
- 마지막에 같은 3건을 다시 보내자 `duplicates: 3`: 이미 있는 것을 알아보고 새로 만들지 않았습니다.
|
||||||
|
|
||||||
|
## 과제 1 · LLM이 멈춰도 접수는 (8분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** LLM(OpenAI)이 멈춰도 **문의 접수는 계속** 되게 합니다. 분류만 "미분류"로 줄여서.
|
||||||
|
|
||||||
|
> **생활 속 예 · 카드 단말기가 고장 난 편의점.** 단말기가 안 된다고 문을 닫지 않고 "오늘은 현금만"으로 판매를 계속합니다. 과제 1은 LLM(단말기)이 멈춰도 접수(판매)는 "미분류"로 계속하게 하는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 지금 `my-intake`는 분류(LLM) → 접수 순서입니다. 키가 잘못되거나 OpenAI에 장애가 나면 분류 노드에서 멈추고 500이 나며, 접수 건은 하나도 생기지 않습니다. 그 사이 들어온 문의는 모두 사라집니다. 분류는 나중에 사람이 해도 되지만, 접수되지 않은 문의는 아무도 모릅니다.
|
||||||
|
|
||||||
|
**어떻게 시험하나.** 실제 OpenAI 장애를 기다릴 수는 없으니 "LLM이 멈춘 상태"를 만듭니다. `cases/lab01-challenge.json`의 한 케이스가 `llm down`으로 n8n의 OpenAI Credential을 일부러 틀린 키로 바꾸고, 끝나면 `llm up`으로 진짜 키를 되돌립니다.
|
||||||
|
|
||||||
|
| 볼 것 | 고치기 전 | 고친 뒤 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| LLM이 멈췄을 때 답 | 직접 확인 | 200, `created: 3, unclassified: 3` |
|
||||||
|
| 접수 건의 분류 | 직접 확인 | `미분류` |
|
||||||
|
| 평소 요청 | 분류됨 | 그대로 분류됨 |
|
||||||
|
|
||||||
|
**예제 2와의 관계.** 예제 2에서 접수 건 만들기 노드에 쓴 "실패해도 계속"을 이번에는 **분류 노드**에 씁니다. 같은 설정, 다른 노드입니다. 그리고 분류 결과를 읽는 Code가 "실패한 분류"를 알아보고 대신 넣을 값을 정합니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 핵심 일(접수)과 덧붙인 일(분류)을 나누고, 덧붙인 일이 실패하면 줄여서라도 핵심 일을 끝내는 법(우아한 성능 저하), 그리고 줄인 양(`unclassified`)을 숨기지 않는 법.
|
||||||
|
|
||||||
|
팀장이 요청했습니다.
|
||||||
|
|
||||||
|
> "AI 분류가 안 된다고 문의 접수까지 막히면 안 돼요. 분류는 나중에 사람이 해도 되니, 일단 '미분류'로 받아 주세요."
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json -v # 고치기 전
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| LLM이 실패하면 | `category: 미분류`, `priority: 보통`으로 접수 (결과의 `unclassified`에 셈) |
|
||||||
|
| 기대 결과 | `cases/lab01-challenge.json` (케이스 하나가 `llm down`, 끝나면 `llm up`) |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**핵심 일과 덧붙인 일.** 접수는 이 워크플로우의 **핵심 일**입니다. 분류는 접수를 돕는 **덧붙인 일**입니다. 덧붙인 일이 실패했다고 핵심 일까지 멈추면, LLM이 멈춘 동안 들어온 문의가 모두 사라집니다. 덧붙인 일은 **줄여서라도** 핵심 일을 끝내게 합니다 (graceful degradation, 우아한 성능 저하).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 지금
|
||||||
|
C1{{"분류 (LLM 멈춤)"}} -->|오류| X1(["500 · 접수 0"])
|
||||||
|
end
|
||||||
|
subgraph 바꾼 뒤
|
||||||
|
C2{{"분류 (LLM 멈춤)<br/>실패해도 계속"}} -->|error| P2["Parse: 미분류 · 보통"] --> T2["접수"] --> O2(["200 · created 3<br/>unclassified 3"])
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**72일차와 무엇이 다른가.** 72일차에 LLM이 멈췄을 때는 FAQ 원문으로 "간단 답변"을 했습니다. 오늘은 "미분류로 접수"입니다. 둘 다 같은 생각입니다: LLM 없이도 할 수 있는 몫은 한다. 그리고 **얼마나 줄였는지를 숨기지 않습니다** — `unclassified` 수가 있어야 나중에 사람이 몇 건을 분류해야 하는지 압니다.
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 평소 | `batch-two` | `created: 2, unclassified: 0` |
|
||||||
|
| LLM 멈춤 | `llm down` · `batch-ok` · `llm up` | `created: 3, unclassified: 3, failed: 0` |
|
||||||
|
| 접수 건 확인 | `/tickets-created/list` | 분류에 `미분류`가 있음 |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 1/3 (LLM 멈춤에서 500), 고친 뒤 3/3. `lab01`도 그대로 6/6.
|
||||||
|
|
||||||
|
**먼저 생각해 보기 (2분)**: 어떤 일은 LLM 없이도 해야 하고, 어떤 일은 LLM 없이는 할 수 없나요? 오늘 문의 접수에서 LLM이 없으면 무엇을 잃나요?
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 실패한 분류를 알아보기</summary>
|
||||||
|
|
||||||
|
분류 노드가 실패한 항목은 답 글자(`output`) 대신 `error`를 가집니다. 노드가 실패한 항목을 다음으로 넘기게 설정하고, 다음 Code가 `error`를 보고 "미분류"로 정하게 합니다. LLM이 JSON이 아닌 글을 돌려준 경우도 같이 생각합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 고칠 파일 ② 분류 노드가 실패해도 계속 ③ 실패를 알아보는 법과 대신 넣을 값 ④ 결과에 셀 칸 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 에서 Classify(AI Agent) 노드가 실패해도 워크플로우가 멈추지 않게 onError ____① 로 해 줘.
|
||||||
|
Parse 에서 그 항목에 ____② 가 있거나 output 이 JSON 이 아니면 category "____③", priority "보통" 으로.
|
||||||
|
결과의 ____④ 에 미분류로 접수한 수를 세어 줘. 나머지는 그대로.
|
||||||
|
기대 결과 cases/lab01-challenge.json, cases/lab01.json 도 그대로 통과.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 예제 2의 `Create ticket`에 쓴 것과 같은 값입니다 (AGENTS.md 5절 AI Agent).
|
||||||
|
- ② 실패한 항목이 답 글자 대신 가지는 칸입니다 (▶ 1단계).
|
||||||
|
- ③ 팀장의 말에 있습니다.
|
||||||
|
- ④ AGENTS.md 3절 결과 모양에서 미분류 수를 세는 칸입니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
uv run tools/flow.py test cases/lab01.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · LLM이 멈춰도 접수
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
고치기 전 (예제 2 정답)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 평소에는 분류됨 · 2.1s
|
||||||
|
✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up
|
||||||
|
✘ LLM이 멈추면 미분류로 접수 · 0.8s · status 500 (expected 200); created = null (expected 3); unclassified = null (expected 3); failed = null (expected 0)
|
||||||
|
✔ OpenAI credential: real key from .env restored
|
||||||
|
✘ 미분류도 접수 건으로 · 0.0s · tickets has none of ['미분류']
|
||||||
|
Passed 1/3
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 평소에는 분류됨 · 3.5s
|
||||||
|
✔ LLM이 멈추면 미분류로 접수 · 0.9s
|
||||||
|
✔ 미분류도 접수 건으로 · 0.0s
|
||||||
|
Passed 3/3
|
||||||
|
(lab01 6/6)
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전 0.8초 만에 500: 키가 틀려 OpenAI가 호출을 거절하자 분류 노드가 오류로 멈췄습니다 (n8n은 `Error in workflow`). 접수 건 만들기까지 가지 못해 3건 모두 사라졌습니다.
|
||||||
|
- 고친 뒤 0.9초에 `created: 3, unclassified: 3`: 분류는 실패했지만 `Parse`가 `error`를 보고 미분류 · 보통으로 정해 접수했습니다. LLM을 기다리지 않아 오히려 빨랐습니다.
|
||||||
|
- `llm up`으로 진짜 키를 되돌린 뒤의 `lab01` 6/6은 평소 분류가 그대로라는 뜻입니다.
|
||||||
|
|
||||||
|
## 과제 2 · 요청 안의 규칙 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 비싼 일(LLM 분류 · 접수)을 하기 **전에** 요청 전체를 보고, 규칙에 맞지 않는 것을 입구에서 막습니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 접수 창구 직원.** 같은 서류를 두 번 내밀면 "방금 접수하셨는데요" 하고 그 자리에서 돌려주고, 서류를 수백 장 한꺼번에 내밀면 "한 번에 10장까지만 받아요"라고 합니다. 안쪽 사무실까지 들고 가서 처리하지 않습니다. 과제 2는 워크플로우 입구에 이 창구 직원을 세우는 실습입니다.
|
||||||
|
|
||||||
|
**상황 둘.**
|
||||||
|
|
||||||
|
| 상황 | 지금은 | 바라는 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 엑셀에서 복사하다 같은 줄(같은 `id`)이 두 번 | 둘 다 분류 · 접수 시도. 키 덕분에 접수 건은 하나지만 답은 "둘 다 접수" | 두 번째 줄만 `rejected` (`duplicate id in this request`) |
|
||||||
|
| 한 번에 수백 건 | 모두 처리, 그동안 n8n이 붙잡힘 | 10건이 넘으면 요청 전체를 400으로 거절, 하나도 처리하지 않음 |
|
||||||
|
|
||||||
|
**예제 2와의 차이.** 예제 2의 항목 확인은 "이 한 줄에 id · user · text가 있나"였습니다. 이번 규칙은 **다른 줄과 비교해야**(같은 id) 또는 **요청 전체를 봐야**(몇 건인가) 알 수 있습니다.
|
||||||
|
|
||||||
|
**왜 멱등 키로 충분하지 않나.** [교안 1단계 8절](../../Lecture/76/01-STAGE1-FAILURE-TYPES.md)의 "멱등 키가 있어도 입구 규칙이 필요한 까닭"을 먼저 읽습니다. 키는 중복 접수 건이라는 **피해**를 막고, 입구 규칙은 쓸데없는 분류와 틀린 숫자라는 **일**을 막습니다.
|
||||||
|
|
||||||
|
**시험하는 것.** `batch-dup-id`(R-401 두 번 · R-402), `batch-eleven`(11건), 11건이 400일 때 접수 건이 정말 하나도 안 생겼는지, 평소 요청은 그대로인지.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 항목 규칙과 요청 전체 규칙의 차이, 상한을 넘으면 일부가 아니라 전체를 거절하는 까닭, 입구에서 막으면 얼마나 빨라지는지 (11건을 다 처리할 때와 비교).
|
||||||
|
|
||||||
|
> "어떤 팀은 엑셀에서 복사하다 같은 줄을 두 번 넣어 보내요. 그리고 한 번에 수백 건을 보내서 몇 분씩 걸린 적도 있어요. 같은 요청 안에 같은 번호가 또 있으면 두 번째는 받지 말고, 한 번에는 10건까지만 받아 주세요."
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge2.json -v # 고치기 전
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 같은 `id`가 또 | 두 번째부터 `rejected`, 이유 `duplicate id in this request` |
|
||||||
|
| 10건 넘음 | HTTP 400 `{"error": "too many requests (max 10)"}`, 하나도 처리하지 않음 |
|
||||||
|
| 기대 결과 | `cases/lab01-challenge2.json`, `lab01` · `lab01-challenge`도 그대로 |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**입구에서 막기 (validate at the edge).** 잘못된 요청은 비싼 일(LLM · API)을 하기 **전에** 막는 것이 가장 쌉니다. 예제 2의 항목 확인이 "항목 하나"의 규칙이었다면, 이번에는 **요청 전체**의 규칙입니다.
|
||||||
|
|
||||||
|
| 규칙 | 어느 수준 | 어기면 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| id · user · text가 있어야 | 항목 | 그 항목만 거절 (나머지는 처리) |
|
||||||
|
| 같은 요청 안에 같은 id가 또 | 항목 (두 번째) | 두 번째만 거절 |
|
||||||
|
| 한 번에 10건까지 | 요청 전체 | 요청 전체 400 (하나도 처리하지 않음) |
|
||||||
|
|
||||||
|
**왜 같은 id는 멱등 키로 충분하지 않나.** 같은 id 두 개는 키가 같아 접수 건은 하나만 생깁니다. 하지만 결과에는 `created` 2건(하나는 `duplicate: true`)으로 나와, 사용자는 두 건이 접수된 줄 압니다. 그리고 LLM 분류를 두 번 합니다. **입구에서 두 번째를 거절**하면 결과가 정직하고 비용도 덜 듭니다.
|
||||||
|
|
||||||
|
**왜 상한인가.** 상한이 없으면 한 요청이 몇 분씩 n8n을 붙잡고, 그동안 다른 요청이 기다립니다. 많은 API가 한 번에 받을 수 있는 수를 정해 둡니다 (예: AWS SQS의 묶음 보내기는 한 번에 10개). 상한을 넘으면 일부만 처리하지 않고 **전체를 거절**합니다. 일부만 처리하면 어디까지 됐는지 사용자가 알기 어렵기 때문입니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Q(["requests"]) --> L{"목록이고<br/>10건 이하?"}
|
||||||
|
L -->|아니오| E(["400 · 이유"])
|
||||||
|
L -->|예| V{"항목마다:<br/>빠진 것 · 앞에 나온 id?"}
|
||||||
|
V -->|맞음| P["분류 · 접수"]
|
||||||
|
V -->|아님| R["rejected · 이유"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 보내는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 같은 id 두 번 | `batch-dup-id` (R-401 둘 · R-402) | `created: 2, rejected: 1, duplicates: 0`, 이유 `duplicate id` |
|
||||||
|
| 11건 | `batch-eleven` | 400, `error`에 `too many` |
|
||||||
|
| 400이면 아무것도 | `/tickets-created/list` | `count: 2` |
|
||||||
|
| 평소 | `batch-ok` | `created: 3, rejected: 0` |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 1/4, 고친 뒤 4/4. 11건은 LLM을 부르지 않아 빠르게 끝납니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 어디에 넣나</summary>
|
||||||
|
|
||||||
|
"목록인가"를 보는 곳이 이미 있습니다 (예제 2의 400). 상한은 그 자리에, 같은 id는 항목을 확인하는 Code에서 **앞에서 본 id를 기억해** 둡니다. 400의 이유가 둘(목록이 아님 · 너무 많음)이 되므로 응답 글도 나눠야 합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 요청 전체 규칙과 응답 ② 항목 규칙과 이유 ③ 어느 것이 거절되나 ④ 이미 있는 규칙은 그대로 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 에서 requests 가 목록이어도 ____① 건이 넘으면 HTTP 400 {"error": "too many requests (max 10)"} 로, 하나도 처리하지 말아 줘.
|
||||||
|
목록이 아닐 때의 400 글은 그대로 "requests must be a list".
|
||||||
|
Check items 에서 같은 요청 안에 같은 id 가 또 나오면 ____② 번째부터 rejected, 이유 "____③".
|
||||||
|
앞에서 본 id 는 ____④ 에 기억해 둔다. 나머지 규칙(빠진 칸 거절 · 재시도 · 키 · 미분류)은 그대로.
|
||||||
|
기대 결과 cases/lab01-challenge2.json, cases/lab01.json · lab01-challenge.json 도 통과.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 팀장의 말에 있습니다. "넘으면"이므로 그 수 자체는 받습니다.
|
||||||
|
- ② 처음 나온 것은 받습니다.
|
||||||
|
- ③ 위 조건 표의 이유 글 그대로입니다 (테스트가 `duplicate id`를 찾음).
|
||||||
|
- ④ 이미 본 것을 빨리 찾을 수 있는 JavaScript 모음입니다 (목록도 되지만 더 알맞은 것).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab01.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge.json
|
||||||
|
uv run tools/flow.py ops --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 요청 안의 규칙
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
고치기 전 (과제 1 정답)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 같은 id가 두 번이면 두 번째는 거절 · 2.8s · created = 3 (expected 2); rejected = 0 (expected 1); duplicates = 1 (expected 0); results has none of ['duplicate id']
|
||||||
|
✘ 11건은 400 (한 번에 10건까지) · 11.0s · status 200 (expected 400); error has none of ['too many']
|
||||||
|
✘ 400이면 하나도 만들지 않음 · 0.0s · count = 13 (expected 2)
|
||||||
|
✔ 평소 요청은 그대로 · 3.2s
|
||||||
|
Passed 1/4
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 같은 id가 두 번이면 두 번째는 거절 · 3.4s
|
||||||
|
✔ 11건은 400 (한 번에 10건까지) · 0.2s
|
||||||
|
✔ 400이면 하나도 만들지 않음 · 0.0s
|
||||||
|
✔ 평소 요청은 그대로 · 3.7s
|
||||||
|
Passed 4/4
|
||||||
|
(lab01 6/6 · lab01-challenge 3/3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전 같은 id 두 번은 `created: 3, duplicates: 1`: 멱등 키 덕분에 접수 건은 둘만 생겼지만, 결과는 세 건을 접수했다고 말하고 LLM 분류도 세 번 했습니다.
|
||||||
|
- 고치기 전 11건은 **11.0초** 걸려 모두 처리됐고, 접수 건이 13개(2 + 11)가 되었습니다. 상한이 없으면 한 요청이 그만큼 n8n을 붙잡습니다.
|
||||||
|
- 고친 뒤 11건은 **0.2초**에 400: LLM과 API를 부르기 전에 입구에서 막았습니다. 접수 건은 2개 그대로입니다.
|
||||||
|
- 평소 요청 3건은 그대로 `created: 3`: 새 규칙이 정상 요청을 막지 않았습니다.
|
||||||
|
|
||||||
|
## 막혔을 때
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| 거절한 항목이 결과에 없음 | 거절 갈래가 모으는 노드(Merge)의 두 번째 입력(`index: 1`)에 이어졌는지 |
|
||||||
|
| `flaky`에서 실패 | 접수 건 만들기 노드에 재시도 설정이 있는지 (`parameters` 밖, 노드 바로 아래). `check`가 안에 쓴 것을 오류로 알려 줌 |
|
||||||
|
| 다시 보냈더니 `duplicates`가 0 | `key`가 요청의 `id`인지 (`$execution.id`가 아님) |
|
||||||
|
| 결과의 순서 · 이유가 엉킴 | Code에서 앞 노드의 `.all()`과 같은 순서로 맞추는지 |
|
||||||
|
| 테스트 뒤에도 LLM이 모두 실패 | `uv run tools/flow.py llm up` |
|
||||||
|
| `slow` 케이스가 너무 오래 | 정상 (약 12초). 접수 건 만들기의 timeout이 3초인지 |
|
||||||
|
| 11건이 400인데 이유가 `must be a list` | 400 응답 글을 두 경우로 나눴는지 |
|
||||||
|
| 계속 안 됨 | `cp reference/lab01-example2-intake.json my-work/intake.json` 후 push (예제 2까지) |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 예제 1: 기준의 500 · 이미 만들어진 건 · 중복을 `RUNBOOK.md`에 적었다.
|
||||||
|
- [ ] 예제 2: `cases/lab01.json` 6/6 (고치기 전 0/6).
|
||||||
|
- [ ] 예제 3: `cases/lab01-faults.json` 5/5, 고장마다 시간 · 늦게 생긴 접수 건을 적었다.
|
||||||
|
- [ ] 과제 1: `cases/lab01-challenge.json` 3/3, `lab01`도 통과.
|
||||||
|
- [ ] 과제 2: `cases/lab01-challenge2.json` 4/4, `lab01` · `lab01-challenge`도 통과.
|
||||||
|
- [ ] `my-work/PROMPTS.md`, 끝나면 `ops --reset`.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Lab 02 · 실패를 모으고(DLQ), 알리기 (Error Workflow)
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-02-Q-COLLECT-AND-ALERT.md](LAB-02-Q-COLLECT-AND-ALERT.md).
|
||||||
|
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 2026-10-29에 정답 워크플로우를 학생 순서대로 1회씩 실행한 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||||
|
|
||||||
|
## 예제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 의 Summarize 뒤에 Any failed?(If) 를 넣어 failed 가 1 이상일 때만 Send to DLQ(HTTP POST http://data-api:8800/ dlq ) 로.
|
||||||
|
본문은 {items: [...]}, 실패한 항목마다 { item: 원래 요청 {id, user, text}, reason, workflow: "intake" }. rejected 는 넣지 않는다 .
|
||||||
|
DLQ 저장이 실패해도 응답은 나가게 options.response 에 neverError: true , 두 갈래 모두 Public result 로 가서 같은 결과를 응답.
|
||||||
|
기대 결과 cases/lab02.json 앞 두 케이스.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `dlq` | `POST /dlq` (AGENTS.md 2절) |
|
||||||
|
| ② | 원래 요청 | 재처리 때 그대로 `my-intake`로 보냄. 결과의 `request` |
|
||||||
|
| ③ | 넣지 않는다 | 다시 해도 같은 실패 |
|
||||||
|
| ④ | `neverError: true` | HTTP 500을 노드 오류로 만들지 않음 (예제 3에서 이것이 구멍이 됨) |
|
||||||
|
|
||||||
|
정답: [reference/lab02-example1-dlq.json](reference/lab02-example1-dlq.json) (Lab 01 과제 1 · 2 포함)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/09`) | **2/4** · `failed: 2`인데 DLQ 0 · 알림 0 |
|
||||||
|
| 고친 뒤 (`answers/10`) | **3/4** · DLQ D-1 · D-2 (R-301 · R-302, `attempts 1`, 이유 `500 - tickets service is failing`) · 남은 ✘는 예제 2 |
|
||||||
|
|
||||||
|
## 예제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/error-handler.json 을 새로 만들어 줘. 파일 맨 위에 "id": "errHandler76001", 첫 노드는 errorTrigger .
|
||||||
|
Make alert(Code) 가 workflow.name · workflow.id · execution.lastNodeExecuted · execution.error.message · execution.id 를
|
||||||
|
{workflow, workflow_id, node, message, execution_id, severity: "critical", reason: "unexpected"} 로 만들어 POST http://data-api:8800/alerts.
|
||||||
|
my-work/intake.json 의 settings 에 errorWorkflow: "errHandler76001" 을 넣어 줘.
|
||||||
|
기대 결과 cases/lab02.json 전체.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `errHandler76001` | `nightly-report`의 `settings.errorWorkflow`가 이미 이 id |
|
||||||
|
| ② | `errorTrigger` | `n8n-nodes-base.errorTrigger` (AGENTS.md 5절) |
|
||||||
|
| ③ | `critical` | 처리하지 못한 오류 (강의 2단계 등급 표) |
|
||||||
|
| ④ | `errorWorkflow` | `settings` 안 이름 (AGENTS.md 4절) |
|
||||||
|
|
||||||
|
정답: [reference/lab02-example2-error-handler.json](reference/lab02-example2-error-handler.json) · [reference/lab02-example2-intake.json](reference/lab02-example2-intake.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고친 뒤 (`answers/11`) | **4/4** · 알림 `[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]`, `lab01` 6/6 |
|
||||||
|
|
||||||
|
- 수업 준비 때 확인: 처리기를 **게시 해제**하면 n8n이 부르지 않습니다 (알림 0). 학생이 "push했는데 알림이 없다"면 처리기의 게시 여부부터.
|
||||||
|
- 풀이 때: 오류 문장 `rows is not iterable [line 4]`만 보고 버그(`$json.items` → `tickets`)를 찾게 해 봅니다. 고치지는 않습니다.
|
||||||
|
|
||||||
|
## 예제 3 · 결과 (에이전트 없이, `answers/12`)
|
||||||
|
|
||||||
|
| 실패 | 그물 | 10/29 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 빈 글 | 결과 `rejected` | ✔ `rejected: 1, created: 2` |
|
||||||
|
| API 오류 | `failed` → DLQ | ✔ DLQ 2 |
|
||||||
|
| 버그 | Error Workflow → critical | ✔ 3초 뒤 `unexpected` |
|
||||||
|
| **API 오류 + DLQ 오류** | **없음** | ✘ 200 · `failed: 3`, DLQ 2 그대로 (R-101~103 사라짐), 결과에 `dlq_saved` 없음, 알림 없음 |
|
||||||
|
|
||||||
|
- **5/7**이 맞는 답입니다. 6 · 7번 ✘는 과제 1에서 고칩니다. 풀이 때: "예제 1에서 둔 `neverError`가 무엇을 숨겼나?" → DLQ 저장 실패. "처리하는 장치가 실패할 때"를 생각하게 합니다.
|
||||||
|
|
||||||
|
## 과제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 의 Send to DLQ 뒤에 Make alert(Code) 와 Send alert(HTTP POST http://data-api:8800/alerts) 를 넣어 줘.
|
||||||
|
DLQ 응답에 stored 가 있으면 {workflow: "intake", severity: "high", reason: "dlq", count: failed 수, message},
|
||||||
|
없으면(DLQ 저장 실패) severity "critical", reason " dlq_down ", message 에 잃을 뻔한 요청 id 들.
|
||||||
|
알림은 요청 하나에 하나 (항목마다가 아님). 실패가 없으면 알림을 보내지 않는다.
|
||||||
|
결과에 dlq_saved(실패가 있을 때 true/false) 와 alert("sent" / "none") 를 더해 줘.
|
||||||
|
기대 결과 cases/lab02-challenge.json, cases/lab02-nets.json, cases/lab02.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `stored` | DLQ 응답 `{"stored", "size"}`. 고장이면 `{"error"}`뿐 |
|
||||||
|
| ② | `high` | 처리한 실패 (critical 아래) |
|
||||||
|
| ③ | `dlq_down` | 조건 표 그대로 |
|
||||||
|
| ④ | `alert` | 조건 표의 결과 칸 |
|
||||||
|
|
||||||
|
정답: [answers/76-lab02-challenge-alert.json](answers/76-lab02-challenge-alert.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/13`) | **0/6** |
|
||||||
|
| 고친 뒤 (`answers/14`) | **6/6** · high `count 2` 하나 · 실패 없으면 `alert: none` · DLQ 고장 시 `dlq_saved: false` + critical `DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203` · `lab02-nets` **7/7** · `lab02` 4/4 |
|
||||||
|
|
||||||
|
## 과제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 의 Make alert 뒤에 Recent alerts(HTTP POST http://data-api:8800/alerts/list) 를 넣어
|
||||||
|
본문 {workflow: "intake", reason: 이번 알림의 reason, within: 600 } 로 10분 안의 같은 알림 수를 받아 줘.
|
||||||
|
Decide alert(Code): severity 가 high 이고 그 수가 1 이상이면 suppressed true. critical 은 늘 보낸다 .
|
||||||
|
Send it?(If) 로 suppressed 가 아닐 때만 Send alert, 결과의 alert 는 "sent" / "suppressed" / "none".
|
||||||
|
기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `600` | 10분 = 600초 (`within`은 초) |
|
||||||
|
| ② | `high` | 줄이라고 한 등급 |
|
||||||
|
| ③ | 늘 보낸다 | "정말 급한 건 늘" |
|
||||||
|
| ④ | `suppressed` | 조건 표 그대로 |
|
||||||
|
|
||||||
|
정답: [answers/76-lab02-challenge2-throttle.json](answers/76-lab02-challenge2-throttle.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/15`) | **4/6** · 두 번째에도 `sent`, dlq 알림 2 |
|
||||||
|
| 고친 뒤 (`answers/16`) | **6/6** · 두 번째 `suppressed`, dlq 알림 1, **DLQ 5건 모두**, critical은 `sent` · `lab02-challenge` 6/6 · `lab01-challenge2` 4/4 |
|
||||||
|
|
||||||
|
## 4교시에 볼 것
|
||||||
|
|
||||||
|
| 볼 것 | 막혔을 때 한마디 |
|
||||||
|
| --- | --- |
|
||||||
|
| 처리기 파일에 `id`가 없음 | "`check`의 경고 — 다른 워크플로우가 id로 부름" |
|
||||||
|
| 처리기를 push하지 않음 | "게시를 내린 처리기는 불리지 않음" |
|
||||||
|
| 알림 확인을 `POST /alerts`로 함 | "그건 알림을 만든다. 읽기는 `/alerts/list`" |
|
||||||
|
| `dlq_saved`가 늘 true | "`neverError`라 상태 코드는 늘 성공. 본문의 `stored`" |
|
||||||
|
| 알림을 항목마다 보냄 | "요청 하나에 하나, `count`" |
|
||||||
|
| suppressed인데 DLQ에도 안 넣음 | "줄이는 것은 알림이지 기록이 아님" |
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
# Lab 02 · 실패를 모으고(DLQ), 알리기 (Error Workflow)
|
||||||
|
|
||||||
|
- 관련 강의: [2단계 · 모으고 알리기](../../Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md)
|
||||||
|
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 12분 · 예제 3 6분 · 과제 1 10분 · 과제 2 10분
|
||||||
|
- 준비: Lab 01의 `my-work/intake.json` (없으면 `cp reference/lab01-example2-intake.json my-work/intake.json` 후 push. 그 파일은 Lab 01 예제 2까지)
|
||||||
|
- 목표: 다시 하면 될 실패는 **잃지 않게 모아 두고**(DLQ), 예상하지 못한 오류는 **n8n이 대신 알리게**(Error Workflow) 한다. 어느 실패가 어느 그물에 걸리는지 보고 그물 사이의 구멍을 막고, 처리한 실패도 사람에게 알리되 같은 알림이 쏟아지지 않게 한다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
E1["예제 1<br/>실패한 요청은 DLQ"] --> E2["예제 2<br/>Error Workflow"] --> E3["예제 3<br/>어느 실패가<br/>어느 그물에"] --> T1["과제 1<br/>처리한 실패도 알림<br/>DLQ가 멈추면?"] --> T2["과제 2<br/>알림 폭주 막기"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 연습이 끝나면 `uv run tools/flow.py ops --reset`.
|
||||||
|
|
||||||
|
## 예제 1 · 실패한 요청은 DLQ에 (12분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 데이터 API가 멈춘 동안 들어온 문의를 **잃지 않게 따로 모아 두는 곳**(DLQ)을 만듭니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 택배 보관소.** 집에 아무도 없으면 기사는 상자를 버리지 않고 보관소에 상자 그대로 · 못 전한 까닭과 함께 둡니다. 지금 `my-intake`는 "배달 실패"라고 말만 하고 상자를 길에 두고 가는 기사와 같습니다. 예제 1은 보관소(DLQ)를 만드는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** Lab 01까지 고친 `my-intake`는 API가 멈추면 그 문의를 `failed`로 표시하고 200으로 답합니다. 정직한 답이지만, 그 답을 받은 화면이 닫히면 끝입니다. 문의 내용은 데이터 API에도, n8n에도, 어디에도 남지 않습니다. 원인을 고친 뒤 "아까 실패한 것 다시 해 줘"라고 할 대상이 없습니다.
|
||||||
|
|
||||||
|
**먼저 볼 것 → 고칠 것.**
|
||||||
|
|
||||||
|
| 순서 | 명령 | 볼 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `fault tickets error` → `call my-intake batch-two` | 답은 `failed: 2`. 여기까지는 Lab 01과 같음 |
|
||||||
|
| 2 | `fault tickets ok` → `ops` | **DLQ가 0** — 실패한 두 문의가 어디에도 없음 |
|
||||||
|
| 3 | `test cases/lab02.json` (고치기 전) | DLQ 케이스가 ✘ |
|
||||||
|
| 4 | 요청문 → push → 다시 test | DLQ에 R-301 · R-302가 원래 요청 그대로 |
|
||||||
|
|
||||||
|
**무엇을 넣고 무엇을 안 넣나.** 다시 하면 될 실패(`failed`)만, **처음 받은 요청 그대로** 넣습니다. 그래야 Lab 03에서 같은 요청을 `my-intake`에 다시 보낼 수 있습니다. 거절(`rejected`)은 다시 해도 같으니 넣지 않습니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 실패를 "알리는 것"과 "잃지 않는 것"은 다르다는 것, DLQ에 무엇을 담아야 나중에 다시 돌릴 수 있는지. 이 DLQ는 Lab 03의 재처리가 읽어 갑니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py ops --reset
|
||||||
|
uv run tools/flow.py fault tickets error
|
||||||
|
uv run tools/flow.py call my-intake requests/batch-two.json # failed 2 — 그다음은?
|
||||||
|
uv run tools/flow.py fault tickets ok
|
||||||
|
uv run tools/flow.py ops # DLQ는?
|
||||||
|
uv run tools/flow.py test cases/lab02.json -v # 고치기 전
|
||||||
|
```
|
||||||
|
|
||||||
|
API가 멈춘 동안 들어온 두 문의는 결과에 `failed`로만 남았습니다. 사용자 화면을 닫으면 사라집니다.
|
||||||
|
|
||||||
|
**만들 것**: 실패한(`failed`) 요청을 **원래 요청 그대로** 데이터 API의 DLQ(`POST /dlq`)에 넣기.
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 넣는 것 | 본문 `{items: [...]}`, 실패한 항목마다 `{item: 원래 요청 {id, user, text}, reason, workflow: "intake"}` |
|
||||||
|
| 거절(`rejected`)은 | 넣지 않는다 (다시 해도 같으니까) |
|
||||||
|
| 실패가 없으면 | DLQ를 부르지 않는다 |
|
||||||
|
| 기대 결과 | `cases/lab02.json` 앞 두 케이스 |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**DLQ(dead letter queue)는 "배달하지 못한 편지 보관함"입니다.** 우체국은 주소가 틀린 편지를 버리지 않고 따로 모아 둡니다. 메시지 시스템(Amazon SQS · RabbitMQ · Kafka)도 처리하지 못한 메시지를 버리지 않고 DLQ로 옮깁니다. 나중에 원인을 고친 사람이 **같은 메시지를 다시** 처리할 수 있게요.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 지금
|
||||||
|
S1["Summarize<br/>failed 2"] --> O1(["응답 · 그리고 끝"])
|
||||||
|
end
|
||||||
|
subgraph 바꾼 뒤
|
||||||
|
S2["Summarize"] --> F2{"failed > 0?"}
|
||||||
|
F2 -->|예| D2[("POST /dlq<br/>원래 요청 · 이유")] --> O2(["응답"])
|
||||||
|
F2 -->|아니오| O2
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**무엇을 넣나.**
|
||||||
|
|
||||||
|
| 넣는 것 | 까닭 |
|
||||||
|
| --- | --- |
|
||||||
|
| **원래 요청 그대로** (`id` · `user` · `text`) | 나중에 같은 요청으로 다시 돌림 (Lab 03). 분류 결과 같은 중간 값이 아니라 처음 받은 것 |
|
||||||
|
| 이유 (`reason`) | 사람이 원인을 압니다 (`500 - tickets service is failing`, `timeout`) |
|
||||||
|
| 어느 워크플로우 (`workflow`) | 여러 워크플로우가 같은 DLQ를 쓸 때 |
|
||||||
|
|
||||||
|
**거절은 넣지 않습니다.** 빈 글은 다시 해도 빈 글입니다. DLQ는 "**나중에 다시 하면 될 것**"만 모읍니다. 거절까지 넣으면 재처리할 때마다 같은 실패가 되풀이되고, 정말 살려야 할 것이 묻힙니다.
|
||||||
|
|
||||||
|
**같은 요청이 또 오면.** 오늘 데이터 API는 같은 `item.id`가 다시 들어오면 새 줄을 만들지 않고 `attempts`를 하나 올립니다. 재처리(Lab 03)에서 또 실패할 때 쓰입니다.
|
||||||
|
|
||||||
|
**테스트 케이스 (예제 1 몫).**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| API가 멈추면 | `tickets error` · `batch-two` | 200 · `created: 0, failed: 2` |
|
||||||
|
| DLQ 확인 | `/dlq/list` | `size: 2`, `R-301` · `R-302` |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 `lab02` 2/4, 예제 1 뒤 3/4 (남은 ✘는 예제 2의 알림).
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 왜 원래 요청 그대로</summary>
|
||||||
|
|
||||||
|
나중에 다시 돌릴 때(3단계) 같은 요청을 그대로 `my-intake`에 보내야 합니다. Lab 01의 결과에서 실패한 항목은 `request`(원래 요청)를 가지고 있습니다. 그것을 모아 DLQ 본문으로.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · DLQ에 넣다가 실패하면</summary>
|
||||||
|
|
||||||
|
DLQ 저장마저 실패하면? 최소한 사용자 응답은 나가야 합니다. 어떤 설정을 둘까요? (그다음은 예제 3에서 직접 봅니다)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 언제 DLQ로 가나 ② DLQ 주소와 본문 모양 ③ 넣지 않는 것 ④ DLQ가 실패해도 응답 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 의 Summarize 뒤에 Any failed?(If) 를 넣어 failed 가 1 이상일 때만 Send to DLQ(HTTP POST http://data-api:8800/ ____① ) 로.
|
||||||
|
본문은 {items: [...]}, 실패한 항목마다 { item: ____② {id, user, text}, reason, workflow: "intake" }. rejected 는 ____③ .
|
||||||
|
DLQ 저장이 실패해도 응답은 나가게 options.response 에 ____④ , 두 갈래 모두 Public result 로 가서 같은 결과를 응답.
|
||||||
|
기대 결과 cases/lab02.json 앞 두 케이스.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① AGENTS.md 2절 데이터 API 표에서 실패한 요청을 넣는 주소입니다.
|
||||||
|
- ② 나중에 다시 돌릴 때 그대로 보낼 것입니다 (▶ 1단계).
|
||||||
|
- ③ 다시 해도 같은 실패입니다.
|
||||||
|
- ④ AGENTS.md 5절 "DLQ · 알림" 노드의 응답 설정입니다. HTTP 오류를 노드 오류로 만들지 않는 값.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 실패한 요청은 DLQ에
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다.
|
||||||
|
|
||||||
|
고치기 전 (Lab 01 과제 2 정답)
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ fault tickets error → call my-intake requests/batch-two.json
|
||||||
|
HTTP 200 · 2.8s ( "failed": 2 )
|
||||||
|
$ ops
|
||||||
|
created tickets: 0
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 0
|
||||||
|
|
||||||
|
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.8s
|
||||||
|
✘ 예제 1 · 실패한 2건은 DLQ에 · 0.0s · size = 0 (expected 2); items has none of ['R-301', 'R-302']
|
||||||
|
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s
|
||||||
|
✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); ...
|
||||||
|
Passed 2/4
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.1s
|
||||||
|
✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s
|
||||||
|
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s
|
||||||
|
✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); ...
|
||||||
|
Passed 3/4
|
||||||
|
|
||||||
|
$ ops
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전 `failed: 2`는 Lab 01에서 이미 있었습니다. 하지만 `ops`의 DLQ가 0입니다. 결과를 받은 화면이 닫히면 두 문의는 **아무 데도 남지 않습니다.**
|
||||||
|
- 고친 뒤 DLQ에 R-301 · R-302가 원래 요청 그대로, 실패 이유 · `attempts 1`과 함께 들어갔습니다. 이제 원인을 고친 뒤 다시 돌릴 수 있습니다 (Lab 03).
|
||||||
|
- 남은 ✘는 `nightly-report`의 오류를 아무도 알리지 않아서입니다 (예제 2).
|
||||||
|
|
||||||
|
## 예제 2 · Error Workflow (12분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 내가 **예상하지 못한** 오류로 워크플로우가 멈췄을 때, n8n이 대신 알림을 남기게 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 자동차 경고등.** 운전자는 모든 고장을 미리 알 수 없지만, 무슨 일이 생기면 계기판에 경고등이 켜져 정비소에 가게 됩니다. 경고등이 고장을 고치지는 않습니다. 예제 2는 `nightly-report`의 버그(고장)를 고치지 않고, 버그가 나면 켜지는 경고등(Error Workflow → 알림)을 다는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 예제 1의 DLQ는 "API가 멈출 수 있다"를 **예상하고** 미리 길을 만들어 둔 것입니다. 하지만 버그, 모르는 응답 모양, 누가 바꾼 설정 같은 오류는 미리 길을 만들 수 없습니다. 그런 오류로 워크플로우가 멈추면 부른 쪽에 500 하나가 갈 뿐, **운영하는 사람은 아무도 모릅니다.**
|
||||||
|
|
||||||
|
**연습 대상.** `nightly-report`는 일부러 버그를 넣어 둔 제공 워크플로우입니다. 버그는 **고치지 않습니다.** 오늘의 목표는 버그를 고치는 것이 아니라 "버그가 나면 누가 아느냐"를 바꾸는 것입니다.
|
||||||
|
|
||||||
|
| 순서 | 명령 | 볼 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `call nightly-report` | 500 |
|
||||||
|
| 2 | `ops` | `alerts: 0` — 아무도 모름 |
|
||||||
|
| 3 | 에러 처리기를 만들어 push → `test cases/lab02.json` | 3초 뒤 critical 알림: 어느 워크플로우 · 어느 노드 · 무슨 오류 |
|
||||||
|
|
||||||
|
**n8n이 해 주는 것과 내가 할 것.** 워크플로우 설정에 `errorWorkflow`(처리기 id)가 있으면, 그 워크플로우가 멈출 때 n8n이 처리기를 부릅니다. 나는 처리기(Error Trigger로 시작 → 알림 남기기)를 만들어 **정해진 id로 게시**하고, 내 `my-intake`에도 같은 설정을 넣습니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** "처리한 실패"(DLQ)와 "처리하지 못한 실패"(Error Workflow)는 받는 곳이 다르고 둘 다 있어야 한다는 것, 알림 한 줄에 무엇이 있어야 고칠 곳을 찾을 수 있는지.
|
||||||
|
|
||||||
|
이제 확인합니다. `nightly-report`는 일부러 버그가 있는 워크플로우입니다 (고치지 않습니다).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py call nightly-report # HTTP 500 — 누가 알까?
|
||||||
|
uv run tools/flow.py ops # alerts: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**만들 것**: 처리하지 못한 오류로 워크플로우가 멈추면 n8n이 부르는 **에러 처리기**. 알림을 데이터 API에 남긴다. 그리고 내 접수 워크플로우도 이 처리기를 쓰게.
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 파일 | `my-work/error-handler.json`, 맨 위에 `"id": "errHandler76001"` ([AGENTS.md](AGENTS.md) 3 · 4절) |
|
||||||
|
| 알림 | `POST /alerts` `{workflow, workflow_id, node, message, execution_id, severity: "critical", reason: "unexpected"}` |
|
||||||
|
| 내 접수 워크플로우 | `settings.errorWorkflow: "errHandler76001"` |
|
||||||
|
| 기대 결과 | `cases/lab02.json` 전체, `lab01`도 그대로 |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**처리한 실패와 처리하지 못한 실패.** 예제 1의 DLQ는 내가 **예상한** 실패(API가 멈춤)를 그 자리에서 처리한 것입니다. 하지만 모든 실패를 예상할 수는 없습니다. 버그, 모르는 응답 모양, 누가 바꾼 설정. 그런 오류로 워크플로우가 멈추면, 응답 500 하나만 남고 아무도 모릅니다.
|
||||||
|
|
||||||
|
| | 처리한 실패 | 처리하지 못한 실패 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 예 | API가 멈춤 (예상함) | `nightly-report`의 버그 |
|
||||||
|
| 워크플로우 | 계속, 200 | **멈춤**, 500 |
|
||||||
|
| 받는 곳 | DLQ + 알림 (내가 만듦, 과제 1) | **Error Workflow** (n8n이 부름) |
|
||||||
|
|
||||||
|
**Error Workflow는 마지막 그물입니다.** n8n은 워크플로우 설정에 `errorWorkflow`가 있으면, 그 워크플로우가 처리하지 못한 오류로 멈출 때 지정한 워크플로우를 부릅니다. 받는 쪽은 **Error Trigger** 노드로 시작하고, 어느 워크플로우 · 어느 실행 · 어느 노드 · 무슨 오류인지를 받습니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W["nightly-report<br/>settings.errorWorkflow"] -->|처리 못 한 오류로 멈춤| E["On error<br/>(Error Trigger)"] --> C["Make alert"] --> A[("POST /alerts<br/>critical · unexpected")]
|
||||||
|
W -.->|응답 500| U(["부른 사람"])
|
||||||
|
```
|
||||||
|
|
||||||
|
| Error Trigger가 넘기는 것 | 알림에 |
|
||||||
|
| --- | --- |
|
||||||
|
| `workflow.name` · `workflow.id` | 어느 워크플로우 |
|
||||||
|
| `execution.lastNodeExecuted` | 어느 노드 |
|
||||||
|
| `execution.error.message` | 무슨 오류 |
|
||||||
|
| `execution.id` | n8n 화면 Executions에서 그 실행을 연다 |
|
||||||
|
|
||||||
|
**처리기의 id는 고정합니다.** 다른 워크플로우가 처리기를 **id로** 부르므로, 파일 맨 위에 `"id": "errHandler76001"`을 씁니다 (70일차 모듈과 같음). 처리기도 **게시(push)**해야 합니다. 게시를 내린 처리기는 n8n이 부르지 않습니다 (수업 준비 때 확인).
|
||||||
|
|
||||||
|
**알림은 늦게 생깁니다.** Error Workflow는 웹훅 응답(500)이 나간 **뒤에** 돕니다. 테스트는 3초를 기다린 뒤(`{"sleep": 3}`) 알림을 봅니다.
|
||||||
|
|
||||||
|
**테스트 케이스 (예제 2 몫).**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 예상 못 한 오류 | `nightly-report` | 500 |
|
||||||
|
| 알림 | 3초 뒤 `/alerts/latest` | `severity: critical, reason: unexpected`, 워크플로우 이름, 노드 `Count by category` |
|
||||||
|
|
||||||
|
**기대 결과.** `lab02` 4/4. `ops`에 critical 알림 하나.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 둘 다 있어야 하는 이유</summary>
|
||||||
|
|
||||||
|
DLQ만 있으면? 예상 못 한 버그는 아무도 모릅니다. Error Workflow만 있으면? API가 멈출 때마다 워크플로우가 멈춰야 알림이 오고, 멈추면 그 실행의 다른 항목도 함께 잃습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 알림에 무엇을</summary>
|
||||||
|
|
||||||
|
받는 사람이 "어느 워크플로우의 어느 노드에서 무슨 오류"를 바로 알아야 합니다. 실행 번호가 있으면 n8n 화면 Executions에서 그 실행을 엽니다. Error Trigger가 넘기는 값은 AGENTS.md 5절.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 파일과 고정 id ② 시작 노드 ③ 알림에 담을 값과 등급 ④ 부르는 쪽 설정 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/error-handler.json 을 새로 만들어 줘. 파일 맨 위에 "id": "____①", 첫 노드는 ____② .
|
||||||
|
Make alert(Code) 가 workflow.name · workflow.id · execution.lastNodeExecuted · execution.error.message · execution.id 를
|
||||||
|
{workflow, workflow_id, node, message, execution_id, severity: "____③", reason: "unexpected"} 로 만들어 POST http://data-api:8800/alerts.
|
||||||
|
my-work/intake.json 의 settings 에 ____④ : "errHandler76001" 을 넣어 줘.
|
||||||
|
기대 결과 cases/lab02.json 전체.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 위 조건 표의 파일 줄입니다. `nightly-report`의 설정에 이미 적혀 있는 값과 같아야 합니다.
|
||||||
|
- ② AGENTS.md 5절 "Error Workflow"의 노드 종류(`type`의 끝 부분)입니다.
|
||||||
|
- ③ 처리하지 못한 오류의 등급입니다. 강의 2단계 알림 등급 표.
|
||||||
|
- ④ AGENTS.md 4절 워크플로우 파일 규칙의 `settings` 안 이름입니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/error-handler.json my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
uv run tools/flow.py test cases/lab01.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · Error Workflow
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.8s
|
||||||
|
✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s
|
||||||
|
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s
|
||||||
|
Passed 4/4
|
||||||
|
|
||||||
|
$ ops
|
||||||
|
alerts: 1
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
|
(lab01 6/6)
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- `nightly-report`는 여전히 500입니다 (버그를 고치지 않음). 달라진 것은 **누가 아느냐**입니다. 3초 뒤 critical 알림이 생겼고, 어느 워크플로우 · 어느 노드(`Count by category`) · 무슨 오류(`rows is not iterable [line 4]`)인지 한 줄로 보입니다.
|
||||||
|
- 오류 문장은 버그를 가리킵니다: 응답의 `tickets`를 `items`로 잘못 읽어 `rows`가 비어(`undefined`) 있었습니다. 알림만 보고 고칠 곳을 찾을 수 있습니다.
|
||||||
|
- `nightly-report`의 설정에 이미 `errorWorkflow: errHandler76001`이 있었기 때문에, 처리기를 그 id로 **게시하는 것만으로** 알림이 시작되었습니다.
|
||||||
|
- 내 `my-intake`도 같은 처리기를 씁니다. 하지만 오늘의 실패는 모두 처리하므로(200) 처리기가 불리지 않습니다. `lab01` 6/6.
|
||||||
|
|
||||||
|
## 예제 3 · 어느 실패가 어느 그물에 (6분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 지금까지 만든 그물 셋이 각각 어떤 실패를 잡는지 확인하고, **그물 사이로 빠지는 구멍**을 찾습니다. 고치지 않고(에이전트 없이) 재기만 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 건전지가 다 된 화재경보기.** 경보기가 있어도 건전지가 다 되면 불이 나도 조용합니다. 예제 3은 그물(거절 · 보관소 · 경고등)을 하나씩 시험하다가, 보관소(DLQ) 문까지 잠겼을 때 아무 소리도 나지 않는 것을 직접 확인하는 실습입니다.
|
||||||
|
|
||||||
|
**지금 있는 그물 셋.**
|
||||||
|
|
||||||
|
| 그물 | 언제 만들었나 | 잡는 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 결과의 `rejected` | Lab 01 예제 2 · 과제 2 | 잘못된 항목 |
|
||||||
|
| DLQ | 예제 1 | API가 멈춰 처리 못 한 항목 |
|
||||||
|
| Error Workflow | 예제 2 | 워크플로우를 멈추게 한 예상 못 한 오류 |
|
||||||
|
|
||||||
|
**실패 넷을 일으킵니다.** 빈 글 · API 오류 · 버그, 그리고 마지막으로 **API와 DLQ가 함께 멈춘** 경우. 앞의 셋은 각자 그물에 걸립니다. 네 번째는 어디에 걸릴까요? DLQ에 넣지 못했고, 워크플로우는 멈추지 않았으니(`neverError`) Error Workflow도 불리지 않습니다.
|
||||||
|
|
||||||
|
**`lab02-nets.json`의 6 · 7번은 지금 ✘가 맞습니다.** 이 예제는 구멍을 **보여 주는** 것이고, 막는 것은 과제 1입니다. 5/7이 기대 결과입니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 실패를 처리하는 장치(DLQ)도 실패할 수 있고, 그때 요청이 소리 없이 사라진다는 것 ("누가 감시자를 감시하나"). 그물 지도를 표로 그려 두면 구멍이 보인다는 것.
|
||||||
|
|
||||||
|
**적을 곳.** `my-work/RUNBOOK.md` 1.3 그물 지도 (실패 · 어떻게 알아채나 · 자동으로 하는 일 · 사람이 할 일).
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-nets.json -v
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**그물 지도.**
|
||||||
|
|
||||||
|
| 실패 | 걸리는 그물 | 사람이 아는 방법 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 잘못된 항목 (빈 글) | 결과의 `rejected` + 이유 | 부른 쪽이 결과를 보고 고쳐 보냄 |
|
||||||
|
| API 오류 | `failed` → **DLQ** | (아직 없음 → 과제 1) |
|
||||||
|
| 버그 (`nightly-report`) | **Error Workflow** → critical 알림 | 알림 |
|
||||||
|
| **DLQ도 멈춤** | ? | ? |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
F1["빈 글"] --> R["rejected"]
|
||||||
|
F2["API 오류"] --> D[("DLQ")]
|
||||||
|
F3["버그"] --> E["Error Workflow"] --> A[("알림")]
|
||||||
|
F4["API 오류 +<br/>DLQ 오류"] -.->|neverError로<br/>조용히 지나감| X(["200 · failed 3<br/>어디에도 없음"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**구멍은 그물 사이에 있습니다.** 예제 1에서 DLQ 저장이 실패해도 응답은 나가게 `neverError`를 두었습니다. 그래서 DLQ가 멈추면 워크플로우는 **멈추지 않고(→ Error Workflow도 안 불림)**, DLQ에도 없고, 결과는 `failed`만 말합니다. 실패를 처리하는 장치 자체가 실패할 때를 생각해야 합니다. 이것을 "누가 감시자를 감시하나"라고 부르기도 합니다.
|
||||||
|
|
||||||
|
**테스트 케이스.** 지금 워크플로우를 바꾸지 않고 재기만 합니다. 6 · 7번은 **지금은 ✘가 맞습니다** (과제 1에서 고침).
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `batch-bad-item` | `rejected: 1, created: 2` |
|
||||||
|
| 2 · 3 | `tickets error` · `batch-two`, `/dlq/list` | `failed: 2`, DLQ `size: 2` |
|
||||||
|
| 4 · 5 | `nightly-report`, 3초 뒤 알림 | 500, critical · unexpected |
|
||||||
|
| 6 | `tickets error` + `dlq error` · `batch-ok` | `failed: 3, dlq_saved: false` |
|
||||||
|
| 7 | `/alerts/latest` | critical · `dlq_down` |
|
||||||
|
|
||||||
|
**기대 결과.** 5/7. 6번은 결과에 `dlq_saved`가 없어서, 7번은 마지막 알림이 5번의 `unexpected`라서 ✘. `ops`의 DLQ는 2건 그대로 (3건이 사라짐).
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 사라진 3건을 찾을 수 있나</summary>
|
||||||
|
|
||||||
|
6번 뒤 `ops`를 봅니다. DLQ는 몇 건인가요? n8n 화면 Executions에서 그 실행을 열면 `Send to DLQ` 노드의 응답에 무엇이 있나요? 사람이 매번 Executions를 열어 볼 수 있을까요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 실행 결과 · 어느 실패가 어느 그물에
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 예제 2 정답으로 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 1.7s
|
||||||
|
✔ 2 · API 오류 → failed · 2.8s
|
||||||
|
✔ 3 · failed는 DLQ에 · 0.0s
|
||||||
|
✔ 4 · 버그 → 500 · 0.0s
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 5 · Error Workflow → critical 알림 · 0.0s
|
||||||
|
✘ 6 · DLQ마저 멈추면 결과가 알려야 · 4.1s · dlq_saved = null (expected false)
|
||||||
|
✘ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s · reason = unexpected (expected dlq_down)
|
||||||
|
Passed 5/7
|
||||||
|
|
||||||
|
$ ops
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 … D-2 R-302 …
|
||||||
|
alerts: 1
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 1~5: 그물 셋이 각자 일했습니다. 빈 글은 결과에, API 오류는 DLQ에, 버그는 알림으로.
|
||||||
|
- 6: 응답은 200 · `failed: 3`. 그런데 DLQ는 **2건 그대로**입니다. `batch-ok`의 세 문의(R-101~103)는 DLQ 저장이 500으로 실패했고, `neverError` 때문에 워크플로우는 아무 일 없다는 듯 끝났습니다. 결과에도 "DLQ에 못 넣었다"는 말이 없습니다.
|
||||||
|
- 7: 마지막 알림은 여전히 5번의 버그 알림입니다. **세 문의를 잃었다는 것을 아무도 모릅니다.** 과제 1에서 이 구멍을 막습니다.
|
||||||
|
|
||||||
|
## 과제 1 · 처리한 실패도 알리기, DLQ가 멈추면 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** DLQ에 쌓인 것을 사람이 **바로 알게** 하고, 예제 3에서 찾은 구멍(DLQ도 멈춤)을 **더 큰 알림**으로 막습니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 응급실과 "건전지 부족" 삑 소리.** 응급실은 급한 정도에 따라 환자를 나누고, 화재경보기는 건전지가 떨어지면 따로 삑 소리를 냅니다. 과제 1은 보관소에 넣었으면 "확인해 주세요"(high), 보관소에도 못 넣었으면 "지금 바로"(critical, 잃을 뻔한 송장 번호와 함께)로 등급을 나눠 알리는 실습입니다.
|
||||||
|
|
||||||
|
**문제 둘.**
|
||||||
|
|
||||||
|
| 문제 | 지금 | 바라는 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| DLQ에 쌓여도 아무도 모름 | 다음 날 아침에 `ops`를 쳐야 앎 | DLQ에 넣을 때 `high` 알림 하나 (몇 건인지) |
|
||||||
|
| DLQ도 멈추면 요청이 사라짐 | 결과는 `failed`만, 알림 없음 | `critical` 알림, 메시지에 **잃을 뻔한 요청 id** |
|
||||||
|
|
||||||
|
**왜 등급을 나누나.** DLQ에 넣었으면 요청은 안전합니다. 원인을 고친 뒤 재처리하면 됩니다(`high`). DLQ에도 못 넣었으면 요청은 지금 **어디에도 없습니다.** 알림에 적힌 id가 유일한 단서라 바로 봐야 합니다(`critical`).
|
||||||
|
|
||||||
|
**지킬 것.** 실패가 없으면 알림을 보내지 않습니다. 실패한 항목마다가 아니라 **요청 하나에 알림 하나**(개수를 담아)입니다. 그리고 결과에 `dlq_saved` · `alert`를 더해, 부른 쪽도 "DLQ에 들어갔나"를 알게 합니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 알림 등급을 무엇으로 정하나(요청을 잃을 수 있나), DLQ에 저장됐는지를 상태 코드가 아니라 응답 내용으로 판단하는 법. 고치면 예제 3의 `lab02-nets`가 7/7이 됩니다.
|
||||||
|
|
||||||
|
> "DLQ에 쌓이는 걸 아무도 몰라서 다음 날 아침에야 봤어요. DLQ에 넣었으면 바로 알려 주세요. 그리고 아까처럼 DLQ에도 못 넣었으면 그건 정말 급한 거니 더 크게 알려 주세요. 대신 문제가 없을 때는 알림을 보내지 마세요."
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| DLQ에 넣었으면 | `POST /alerts` `{workflow: "intake", severity: "high", reason: "dlq", count, message}` |
|
||||||
|
| DLQ에 못 넣었으면 | `{severity: "critical", reason: "dlq_down", count, message}` — message에 잃을 뻔한 요청 id들 |
|
||||||
|
| 실패가 없으면 | 알림 없음 |
|
||||||
|
| 결과에 | `dlq_saved` (실패가 있을 때 true/false), `alert` (`sent` · `none`) |
|
||||||
|
| 기대 결과 | `cases/lab02-challenge.json`, `lab02-nets` 7/7, `lab02` 그대로 |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**알림 등급.** 모든 알림이 같은 무게면 받는 사람은 무엇부터 볼지 모릅니다.
|
||||||
|
|
||||||
|
| 등급 | 오늘 | 받으면 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `critical` | 예상 못 한 오류(버그), **DLQ가 멈춤 (요청을 잃을 수 있음)** | 바로 본다 |
|
||||||
|
| `high` | DLQ에 쌓임, parked (Lab 03) | 원인(데이터 API 등)을 확인하고 재처리 |
|
||||||
|
| (알림 없음) | 거절, 미분류 | 결과 · 보고서로 셈 |
|
||||||
|
|
||||||
|
**요청 하나에 알림 하나.** 실패한 항목마다 알림을 보내면 50건 실패에 알림 50개입니다. 요청 하나에 알림 하나, 개수(`count`)를 담습니다.
|
||||||
|
|
||||||
|
**구멍 막기.** DLQ의 응답에 `stored`가 있으면 저장된 것, 없으면(500의 `error`) 저장되지 않은 것입니다. 저장되지 않았다면 **잃을 뻔한 요청의 id를 알림에 직접 적습니다.** 그러면 DLQ가 멈춰도 사람이 알림만 보고 그 요청들을 살릴 수 있습니다. 결과의 `dlq_saved: false`는 부른 쪽에게도 "이건 아직 아무 데도 없다"고 알려 줍니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
F{"failed > 0?"} -->|아니오| N(["alert: none"])
|
||||||
|
F -->|예| D[("POST /dlq")] --> S{"stored?"}
|
||||||
|
S -->|예| H["high · dlq · count"] --> A[("POST /alerts")]
|
||||||
|
S -->|아니오| C["critical · dlq_down<br/>잃을 뻔한 id들"] --> A
|
||||||
|
A --> R(["dlq_saved · alert: sent"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| API가 멈춤 | `tickets error` · `batch-two` | `failed: 2, dlq_saved: true, alert: sent` |
|
||||||
|
| high 알림 | `/alerts/latest` | `high · dlq · count 2 · intake` |
|
||||||
|
| 실패 없음 | `batch-ok` | `alert: none` |
|
||||||
|
| 알림은 그대로 | `/alerts/list` | `count: 1` |
|
||||||
|
| DLQ도 멈춤 | `tickets error` + `dlq error` · `batch-bad-item` | `failed: 2, dlq_saved: false` |
|
||||||
|
| critical 알림 | `/alerts/latest` | `critical · dlq_down · count 2`, message에 `R-201` |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 0/6, 고친 뒤 6/6. 예제 3의 `lab02-nets`도 7/7.
|
||||||
|
|
||||||
|
**먼저 생각해 보기 (2분)**: 알림이 너무 많으면 어떻게 될까요? `critical`과 `high`는 받는 사람에게 무엇이 달라야 할까요? (런북 1절)
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 한 번에 하나</summary>
|
||||||
|
|
||||||
|
DLQ 노드는 요청 하나에 한 번 돕니다 (`items` 목록 하나). 그 뒤에 알림을 하나 만들면 저절로 "요청 하나에 하나"가 됩니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 저장됐는지 어떻게 아나</summary>
|
||||||
|
|
||||||
|
`curl -s -XPOST localhost:8800/dlq -d '{"items": [{"item": {"id": "X-1"}, "reason": "test"}]}'`과, `fault dlq error` 뒤 같은 명령을 해 봅니다. 응답에 무엇이 있고 무엇이 없나요? (끝나면 `ops --reset`)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 어디에 붙이나 ② 저장됐을 때 알림 ③ 저장 못 했을 때 알림 ④ 알림 수 ⑤ 결과에 더할 칸 ⑥ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 의 Send to DLQ 뒤에 Make alert(Code) 와 Send alert(HTTP POST http://data-api:8800/alerts) 를 넣어 줘.
|
||||||
|
DLQ 응답에 ____① 가 있으면 {workflow: "intake", severity: "____②", reason: "dlq", count: failed 수, message},
|
||||||
|
없으면(DLQ 저장 실패) severity "critical", reason " ____③ ", message 에 잃을 뻔한 요청 id 들.
|
||||||
|
알림은 요청 하나에 하나 (항목마다가 아님). 실패가 없으면 알림을 보내지 않는다.
|
||||||
|
결과에 dlq_saved(실패가 있을 때 true/false) 와 ____④ ("sent" / "none") 를 더해 줘.
|
||||||
|
기대 결과 cases/lab02-challenge.json, cases/lab02-nets.json, cases/lab02.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① ▶ 2단계에서 본 DLQ 응답의 칸입니다. 저장된 수.
|
||||||
|
- ② 팀장 말의 "바로 알려 주세요"는 critical보다 한 단계 아래입니다 (위 등급 표).
|
||||||
|
- ③ 위 조건 표의 reason 그대로입니다 (테스트가 찾음).
|
||||||
|
- ④ 위 조건 표의 결과 칸 이름입니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
uv run tools/flow.py test cases/lab02-nets.json
|
||||||
|
uv run tools/flow.py test cases/lab02.json
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 처리한 실패도 알리기
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
고치기 전 (예제 2 정답)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ API가 멈춤 → DLQ · 2.7s · dlq_saved = null (expected true); alert = null (expected sent)
|
||||||
|
✘ 처리한 실패도 알림 (high · 개수) · 0.0s · severity = null (expected high); reason = null (expected dlq); ...
|
||||||
|
✘ 실패가 없으면 알림 없음 · 2.8s · alert = null (expected none)
|
||||||
|
✘ 알림은 그대로 1개 · 0.0s · count = 0 (expected 1)
|
||||||
|
✘ DLQ도 멈추면 critical · 2.4s · dlq_saved = null (expected false)
|
||||||
|
✘ 잃어버린 id를 알림에 · 0.0s · severity = null (expected critical); ... message has none of ['R-201']
|
||||||
|
Passed 0/6
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ API가 멈춤 → DLQ · 4.3s
|
||||||
|
✔ 처리한 실패도 알림 (high · 개수) · 0.0s
|
||||||
|
✔ 실패가 없으면 알림 없음 · 2.9s
|
||||||
|
✔ 알림은 그대로 1개 · 0.0s
|
||||||
|
✔ DLQ도 멈추면 critical · 2.4s
|
||||||
|
✔ 잃어버린 id를 알림에 · 0.0s
|
||||||
|
Passed 6/6
|
||||||
|
(lab02-nets 7/7 · lab02 4/4)
|
||||||
|
|
||||||
|
알림 두 개 (응답에서):
|
||||||
|
[high] intake · dlq · count 2 · "2 request(s) could not be created and wait in the DLQ"
|
||||||
|
[critical] intake · dlq_down · count 2 · "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203"
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전에는 DLQ에는 넣지만 알림이 0개라 모두 ✘입니다. 결과에 `dlq_saved` · `alert` 칸도 없습니다.
|
||||||
|
- 고친 뒤 실패 2건 → high 알림 **하나**(`count 2`). 실패가 없는 `batch-ok`에는 `alert: none`, 알림은 1개 그대로.
|
||||||
|
- DLQ가 멈추면 `dlq_saved: false` + critical `dlq_down` 알림, 메시지에 `lost ids R-201, R-203`. DLQ에 없어도 사람이 알림만 보고 두 문의를 살릴 수 있습니다. 예제 3의 구멍이 막혀 `lab02-nets` 7/7.
|
||||||
|
|
||||||
|
## 과제 2 · 알림 폭주 막기 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 같은 원인의 `high` 알림은 **10분에 한 번만** 보내, 사람이 알림을 꺼 버리지 않게 합니다. 급한 것(`critical`)과 실패의 기록(DLQ)은 줄이지 않습니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 알림을 꺼 버린 단톡방.** 같은 공지가 백 번 오면 사람들은 단톡방 알림을 끄고, 정말 중요한 공지도 못 봅니다. 그래도 대화 기록은 남아 있습니다. 과제 2는 같은 원인의 알림은 10분에 한 번만 보내되(알림 줄이기), DLQ(대화 기록)는 그대로 두고, 정말 급한 것(critical)은 늘 보내는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 과제 1로 "DLQ에 넣으면 알림"이 생겼습니다. 그런데 API가 30분 멈추면, 그동안 들어온 요청마다 알림이 하나씩 갑니다. 같은 원인을 백 번 알리는 셈이고, 받는 사람은 결국 알림을 끕니다. 그러면 정말 급한 알림도 못 봅니다 (알림 피로).
|
||||||
|
|
||||||
|
| 줄이는 것 | 줄이지 않는 것 |
|
||||||
|
| --- | --- |
|
||||||
|
| 10분 안에 같은 `reason`의 `high` 알림이 이미 있으면 보내지 않음 (`alert: suppressed`) | DLQ에는 모두 남음 |
|
||||||
|
| | `critical`은 늘 보냄 (요청을 잃을 수 있으니) |
|
||||||
|
|
||||||
|
**어떻게 아나.** 알림을 보내기 **바로 전에** 데이터 API에 "10분 안에 같은 알림이 있었나"를 묻습니다 (`/alerts/list`의 `within`).
|
||||||
|
|
||||||
|
**시험하는 것.** 첫 실패 → 보냄, 10분 안에 또 실패 → 보내지 않음, 그래도 DLQ에는 모두, DLQ가 멈춘 critical은 그래도 보냄.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 알림이 많은 것이 안전한 것은 아니라는 것, 운영 도구의 묶기 · 반복 간격 · 중복 없애기가 무엇을 하는지 (그 가장 작은 판), 줄이는 것은 알림이지 기록이 아니라는 것.
|
||||||
|
|
||||||
|
> "어제 API가 30분 멈췄는데, 그동안 요청마다 알림이 와서 휴대폰이 백 번 울렸어요. 그다음부터는 다들 알림을 꺼 버렸고요. 같은 문제면 10분에 한 번만 알려 주세요. 단, 정말 급한 건(critical)은 늘 보내 주세요."
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 같은 알림 | `workflow: intake`이고 같은 `reason`의 알림이 10분(600초) 안에 이미 있으면 |
|
||||||
|
| 그때 high는 | 보내지 않고 결과에 `alert: "suppressed"` |
|
||||||
|
| critical은 | 늘 보냄 |
|
||||||
|
| 10분 안의 알림 수 | `POST /alerts/list` 본문 `{workflow, reason, within: 600}` (AGENTS.md 2절) |
|
||||||
|
| 기대 결과 | `cases/lab02-challenge2.json`, `lab02-challenge` 그대로 |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**알림 피로 (alert fatigue).** 알림이 너무 많으면 사람은 알림을 보지 않게 됩니다. 그러면 정말 급한 알림도 묻힙니다. 알림이 많다는 것은 안전하다는 뜻이 아니라, **아무도 보지 않는다**는 뜻이 되기 쉽습니다.
|
||||||
|
|
||||||
|
**같은 문제는 한 번, 대신 숨기지 않기.**
|
||||||
|
|
||||||
|
| 하는 것 | 하지 않는 것 |
|
||||||
|
| --- | --- |
|
||||||
|
| 같은 문제의 high 알림은 10분에 한 번 | 실패를 DLQ에서 빼기 (DLQ에는 모두 남음) |
|
||||||
|
| 결과에 `alert: suppressed` (보내지 않은 것도 기록) | critical 줄이기 (급한 것은 늘 보냄) |
|
||||||
|
|
||||||
|
운영 도구들(Prometheus Alertmanager · PagerDuty 등)은 이것을 묶기(grouping) · 반복 간격(repeat interval) · 중복 없애기(deduplication)라고 부릅니다. 오늘은 그 가장 작은 판입니다: "**같은 이유의 알림이 10분 안에 있었나?**"
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
M["Make alert<br/>high · dlq"] --> L[("POST /alerts/list<br/>intake · dlq · within 600")] --> D{"high이고<br/>이미 있음?"}
|
||||||
|
D -->|예| S(["alert: suppressed"])
|
||||||
|
D -->|아니오| A[("POST /alerts")] --> O(["alert: sent"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 첫 실패 | `tickets error` · `batch-two` | `alert: sent` |
|
||||||
|
| 10분 안에 또 | `batch-ok` | `failed: 3, alert: suppressed` |
|
||||||
|
| dlq 알림 수 | `/alerts/list {reason: dlq}` | `count: 1` |
|
||||||
|
| DLQ에는 모두 | `/dlq/list` | `size: 5` |
|
||||||
|
| critical은 늘 | `dlq error` · `batch-bad-item` | `dlq_saved: false, alert: sent` |
|
||||||
|
| 고친 뒤 | `batch-dup-id` | `failed: 0, alert: none` |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 4/6 (두 번째에도 보내서), 고친 뒤 6/6.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 어디서 세나</summary>
|
||||||
|
|
||||||
|
알림을 보내기 **바로 전에** 데이터 API에 "10분 안에 같은 알림이 있나"를 묻습니다. `curl -s -XPOST localhost:8800/alerts/list -d '{"workflow": "intake", "reason": "dlq", "within": 600}'`로 모양을 봅니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 왜 critical은 예외인가</summary>
|
||||||
|
|
||||||
|
DLQ가 멈춘 알림을 10분 동안 한 번만 보내면, 그 10분 동안 들어온 다른 요청의 id는 어디에 남나요? (critical 메시지에 id를 적은 까닭)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 묻는 곳과 본문 ② 보내지 않는 조건 ③ 예외 ④ 결과 값 ⑤ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/intake.json 의 Make alert 뒤에 Recent alerts(HTTP POST http://data-api:8800/alerts/list) 를 넣어
|
||||||
|
본문 {workflow: "intake", reason: 이번 알림의 reason, within: ____① } 로 10분 안의 같은 알림 수를 받아 줘.
|
||||||
|
Decide alert(Code): severity 가 ____② 이고 그 수가 1 이상이면 suppressed true. critical 은 ____③ .
|
||||||
|
Send it?(If) 로 suppressed 가 아닐 때만 Send alert, 결과의 alert 는 "sent" / "____④" / "none".
|
||||||
|
기대 결과 cases/lab02-challenge2.json, cases/lab02-challenge.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 10분을 초로.
|
||||||
|
- ② 팀장이 줄이라고 한 등급입니다.
|
||||||
|
- ③ 팀장 말의 "단," 뒤입니다.
|
||||||
|
- ④ 위 조건 표의 값 그대로입니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/intake.json
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab02-challenge.json
|
||||||
|
uv run tools/flow.py test cases/lab01-challenge2.json
|
||||||
|
uv run tools/flow.py ops --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 알림 폭주 막기
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
고치기 전 (과제 1 정답)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 첫 실패 → 알림 보냄 · 2.9s
|
||||||
|
✘ 10분 안에 또 실패 → 보내지 않음 · 3.7s · alert = sent (expected suppressed)
|
||||||
|
✘ dlq 알림은 1개 · 0.0s · count = 2 (expected 1)
|
||||||
|
✔ 그래도 DLQ에는 5건 모두 · 0.0s
|
||||||
|
✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.5s
|
||||||
|
✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.0s
|
||||||
|
Passed 4/6
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 첫 실패 → 알림 보냄 · 4.5s
|
||||||
|
✔ 10분 안에 또 실패 → 보내지 않음 · 3.7s
|
||||||
|
✔ dlq 알림은 1개 · 0.0s
|
||||||
|
✔ 그래도 DLQ에는 5건 모두 · 0.0s
|
||||||
|
✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.4s
|
||||||
|
✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.8s
|
||||||
|
Passed 6/6
|
||||||
|
(lab02-challenge 6/6 · lab01-challenge2 4/4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전에는 두 번째 실패에도 알림을 또 보내 dlq 알림이 2개입니다. 장애가 30분 이어지면 요청마다 하나씩 쌓입니다.
|
||||||
|
- 고친 뒤 두 번째는 `suppressed`, dlq 알림 1개. 그래도 **DLQ에는 5건 모두** 있습니다. 줄인 것은 알림이지 실패의 기록이 아닙니다.
|
||||||
|
- DLQ가 멈춘 critical은 10분 안이라도 `sent`. 급한 것은 줄이지 않았습니다.
|
||||||
|
- 마지막 `batch-dup-id`는 `failed: 0` · `alert: none`이고, Lab 01 과제 2의 같은 id 거절도 그대로입니다.
|
||||||
|
|
||||||
|
## 막혔을 때
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| DLQ가 비어 있음 | 실패가 있을 때만 가는 If의 true 쪽에 DLQ 노드가 있는지, 본문이 `{items: [...]}`인지 |
|
||||||
|
| 알림이 안 생김 | 에러 처리기를 push(게시)했는지, 파일의 `id`가 `errHandler76001`인지, 부르는 쪽 `settings.errorWorkflow` |
|
||||||
|
| 알림이 늦게 생김 | 정상. 응답이 나간 뒤에 돈다 (테스트는 `{"sleep": 3}`) |
|
||||||
|
| 알림이 두 개 | 테스트의 `url` 케이스가 `/alerts`에 POST하면 알림이 생김 — 읽기는 `/alerts/list` · `/alerts/latest` |
|
||||||
|
| `dlq_saved`가 늘 true | DLQ 응답의 `stored`를 보는지 (상태 코드는 `neverError`라 늘 성공처럼 보임) |
|
||||||
|
| 늘 `suppressed` | 앞 테스트의 알림이 남았는지 (`ops --reset`), `within`이 초 단위인지 |
|
||||||
|
| `fault dlq error` 뒤 다른 테스트가 이상함 | `uv run tools/flow.py fault dlq ok` 또는 `ops --reset` |
|
||||||
|
| 계속 안 됨 | `reference/lab02-example1-dlq.json`, `reference/lab02-example2-*.json` (예제까지) |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 예제 1: `cases/lab02.json` 앞 두 케이스, `ops`로 DLQ 확인.
|
||||||
|
- [ ] 예제 2: `cases/lab02.json` 4/4, `nightly-report`의 알림을 봤다.
|
||||||
|
- [ ] 예제 3: `cases/lab02-nets.json` 5/7, 그물 지도와 구멍을 `RUNBOOK.md`에 적었다.
|
||||||
|
- [ ] 과제 1: `cases/lab02-challenge.json` 6/6, `lab02-nets` 7/7, `lab02`도 통과.
|
||||||
|
- [ ] 과제 2: `cases/lab02-challenge2.json` 6/6, `lab02-challenge`도 통과.
|
||||||
|
- [ ] `RUNBOOK.md` 1절 (알아채는 법 · 자동으로 하는 일 · 알림 등급).
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Lab 03 · 다시 돌리기, 런북과 장애 연습, 끝없이 돌리지 않기
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-03-Q-REPLAY-AND-RUNBOOK.md](LAB-03-Q-REPLAY-AND-RUNBOOK.md).
|
||||||
|
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 2026-10-29에 정답 워크플로우를 학생 순서대로 1회씩 실행한 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
|
||||||
|
|
||||||
|
## 예제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/replay.json 을 새로 만들어 줘. 웹훅 POST my-replay .
|
||||||
|
Get DLQ(HTTP GET http://data-api:8800/dlq) → Pick items(Code): parked 가 아닌 것만, 비어도 다음으로 가게 alwaysOutputData true.
|
||||||
|
Has items? 가 없으면 바로 {replayed: 0, ok: 0, still_failed: 0, parked: 0}.
|
||||||
|
있으면 항목마다 Run intake again: POST http://localhost:5678/webhook/my-intake 본문 {requests: [ item ]} (DLQ 의 원래 요청).
|
||||||
|
my-intake 결과의 created 가 1 이면 성공 → POST /dlq/update {dlq_id, remove: true}. 실패면 지우지 않는다.
|
||||||
|
끝에 AGENTS.md 3절 {replayed, ok, still_failed, parked, duplicates}. 기대 결과 cases/lab03.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `my-replay` | AGENTS.md 3절 재처리 |
|
||||||
|
| ② | `alwaysOutputData` | 빈 결과에서도 다음 노드로 (AGENTS.md 5절 그 밖) |
|
||||||
|
| ③ | `item` | DLQ 항목의 원래 요청 칸 |
|
||||||
|
| ④ | `created` | `my-intake`는 늘 200이라 결과 칸으로 판단 |
|
||||||
|
|
||||||
|
정답: [reference/lab03-example1-replay.json](reference/lab03-example1-replay.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/17`) | **1/5** · `my-replay` 404, DLQ 2 · 접수 건 0 |
|
||||||
|
| 고친 뒤 (`answers/18`) | **5/5** · `replayed: 2, ok: 2` (1.1초), DLQ 0, 접수 건 2, 한 번 더 `replayed: 0` |
|
||||||
|
|
||||||
|
## 예제 2 · 결과 (에이전트 없이, `answers/19`)
|
||||||
|
|
||||||
|
| 단계 | 본 것 |
|
||||||
|
| --- | --- |
|
||||||
|
| 장애 시작 | `my-intake` 200 · `failed: 2` (2.8초), `nightly-report` 500 |
|
||||||
|
| 알아챔 (`ops`) | DLQ 2 (R-301 · R-302) · 알림 2: `[high] intake · dlq · count 2`, `[critical] … unexpected · rows is not iterable` |
|
||||||
|
| 고침 · 재처리 | `fault tickets ok` → `call my-replay` 200 (1.4초) |
|
||||||
|
| 확인 | DLQ 0 · 접수 건 2. **critical 알림은 남음** (버그는 재처리로 안 고쳐짐) |
|
||||||
|
| `lab03-drill` | **7/7** · 접수 건 5 (평소 3 + 재처리 2) |
|
||||||
|
|
||||||
|
런북 2절 예 (짝 연습 뒤 비교용):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
1. `uv run tools/flow.py ops` — 고장 스위치 · DLQ · 알림을 본다. (또는 `curl -s -XPOST localhost:5678/webhook/my-ops-status -d '{}'`)
|
||||||
|
2. 알림을 나눈다: `high · dlq`는 데이터 API 쪽 → 3번. `critical · unexpected`는 코드 버그 → 알림의 workflow · node · message와
|
||||||
|
execution_id로 n8n 화면 Executions에서 그 실행을 열고 코드 담당에게. `critical · dlq_down`은 알림의 lost ids를 먼저 적어 둔다.
|
||||||
|
3. 데이터 API가 되살아났는지: 데이터 API 담당에게 확인 (연습에서는 `uv run tools/flow.py fault tickets ok` 뒤 `ops`의 `fault switch: all ok`). `/health`는 고장 스위치와 관계없이 ok라 이것만으로는 모름.
|
||||||
|
4. 재처리: `curl -s -XPOST localhost:5678/webhook/my-replay -d '{}'` → `ok`가 DLQ 수와 같은지.
|
||||||
|
5. 확인: `ops`의 DLQ 0, 접수 건 수가 재처리한 만큼 늘었고 같은 key가 둘이 아닌지.
|
||||||
|
하지 말 것: DLQ를 비우지 말 것 (`/dlq-clear`, `ops --reset` — 재처리할 요청이 사라짐). parked를 자동으로 돌리지 말 것. nightly-report를 급히 고쳐 배포하지 말 것 (74일차 배포 절차).
|
||||||
|
```
|
||||||
|
|
||||||
|
- 짝 연습에서 흔히 빠지는 단계: ① 알림을 **원인별로 나누기** (critical이 재처리 뒤에도 남아 당황함) ② 재처리 **전에** 데이터 API가 살아났는지 확인 (아니면 재처리가 또 실패해 `attempts`만 오름) ③ 확인에서 **중복** 보기.
|
||||||
|
|
||||||
|
## 예제 3 · 결과 (에이전트 없이, `answers/20`)
|
||||||
|
|
||||||
|
| 케이스 | 10/29 |
|
||||||
|
| --- | --- |
|
||||||
|
| `slow` · `batch-two` | 11.6초 · `failed: 2`, DLQ 2 |
|
||||||
|
| 8초 뒤 접수 건 | **2** (이미 있음) |
|
||||||
|
| 재처리 | `replayed: 2, ok: 2, duplicates: 2` (1.1초) |
|
||||||
|
| 그 뒤 | 접수 건 2 그대로, DLQ 0 · **6/6** |
|
||||||
|
|
||||||
|
- 이 예제의 답: DLQ의 요청은 "반쯤 처리됐을" 수 있다. 재처리가 안전한 것은 멱등 키 덕분 (최소 한 번 + 멱등 = 결과는 한 번).
|
||||||
|
|
||||||
|
## 과제 1 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/replay.json 의 Pick items 에서 body.max_attempts(없으면 3 ) 를 읽어,
|
||||||
|
attempts 가 그 수 이상 인 항목은 action "park", 나머지는 "replay" 로 나눠 줘.
|
||||||
|
Replay it?(If): replay 는 지금처럼 다시 보내고, park 는 POST /dlq/update {dlq_id, parked: true , reason} 뒤
|
||||||
|
POST /alerts {workflow: "replay", severity: "high", reason: "parked", dlq_id, message}.
|
||||||
|
두 갈래를 Merge 로 모아 요약의 parked 에 수를. parked 항목은 지우지 않는다.
|
||||||
|
기대 결과 cases/lab03-challenge.json, cases/lab03.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `3` | 조건 표 기본값 |
|
||||||
|
| ② | 이상 (`>=`) | 재처리 3에서 `attempts`가 3. 초과(`>`)면 4번째에야 멈춤 |
|
||||||
|
| ③ | `true` | DLQ 항목의 `parked` |
|
||||||
|
| ④ | `parked` | 조건 표 그대로 |
|
||||||
|
|
||||||
|
정답: [answers/76-lab03-challenge-park.json](answers/76-lab03-challenge-park.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/21`) | **3/7** · 재처리 3도 `replayed: 2, parked: 0`, 고친 뒤 자동으로 다시 보냄 |
|
||||||
|
| 고친 뒤 (`answers/22`) | **7/7** · 재처리 3 **0.1초** `parked: 2`, DLQ `size 2 · parked 2`, `[high] parked`, 고친 뒤 `replayed: 0` · `lab03` 5/5 |
|
||||||
|
|
||||||
|
## 과제 2 · 요청문 예
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-status.json 을 새로 만들어 줘. 웹훅 POST my-ops-status .
|
||||||
|
Get DLQ(GET /dlq) → Recent alerts(POST /alerts/list {within: 600}) → Get created(GET /tickets-created) → Decide status(Code).
|
||||||
|
parked 가 1 이상이거나 10분 안 critical 알림이 있으면 "critical", parked 가 아닌 DLQ 가 있거나 high 알림이 있으면 " warn ", 아니면 "ok".
|
||||||
|
결과 {status, dlq(parked 가 아닌 수), parked, created, alerts: {critical, high}, reasons: [한국어로 까닭]}.
|
||||||
|
읽기만 한다: 이 워크플로우에서 DLQ · 알림을 바꾸지 않는다 . 기대 결과 cases/lab03-challenge2.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
**빈칸 답**
|
||||||
|
|
||||||
|
| 빈칸 | 답 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① | `my-ops-status` | 조건 표 |
|
||||||
|
| ② | `600` | 10분 = 600초 |
|
||||||
|
| ③ | `warn` | 가운데 신호 |
|
||||||
|
| ④ | 바꾸지 않는다 | 확인이 상태를 바꾸면 안 됨 (`/alerts` POST 사고) |
|
||||||
|
|
||||||
|
정답: [answers/76-lab03-challenge2-ops-status.json](answers/76-lab03-challenge2-ops-status.json)
|
||||||
|
|
||||||
|
| 10/29 결과 | |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (`answers/23`) | **3/7** · 상태 케이스 넷 404 |
|
||||||
|
| 고친 뒤 (`answers/24`) | **7/7** · `ok` → `warn`(DLQ 2) → `critical`(critical 1) → `ok`, `lab03-challenge` 7/7 · `lab02-challenge2` 6/6 |
|
||||||
|
| `call my-ops-status` (비우기 전) | `critical` · DLQ 5 · critical 1 · high 1 · 까닭 셋 (`critical 알림 1건 (10분)`, `DLQ 5건: 원인을 고친 뒤 재처리`, `high 알림 1건 (10분)`) |
|
||||||
|
|
||||||
|
## 6교시에 볼 것
|
||||||
|
|
||||||
|
| 볼 것 | 막혔을 때 한마디 |
|
||||||
|
| --- | --- |
|
||||||
|
| 재처리가 접수 건을 직접 만듦 | "`my-intake`를 다시 불러 같은 규칙으로" |
|
||||||
|
| DLQ가 비면 응답이 안 옴 | "`alwaysOutputData` · 할 일 없음 갈래" |
|
||||||
|
| HTTP 상태로 성공 판단 | "`my-intake`는 늘 200 — `created`" |
|
||||||
|
| parked를 지움 | "사람이 볼 것. 지우지 않기" |
|
||||||
|
| 런북에 "확인한다"만 씀 | "무엇을 보면 되는지까지 (DLQ 0 · 같은 key 없음)" |
|
||||||
|
| 상태 웹훅이 알림을 만듦 | "읽기만" |
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
# Lab 03 · 다시 돌리기, 런북과 장애 연습, 끝없이 돌리지 않기
|
||||||
|
|
||||||
|
- 관련 강의: [3단계 · 재처리와 운영 체계](../../Lecture/76/03-STAGE3-REPLAY-AND-RUNBOOK.md)
|
||||||
|
- 소요 시간: 50분 (6교시 한 시간) · 예제 1 12분 · 예제 2 12분 · 예제 3 6분 · 과제 1 10분 · 과제 2 10분
|
||||||
|
- 준비: Lab 02의 `my-work/intake.json` · `my-work/error-handler.json` (없으면 `reference/lab02-example2-*.json`을 복사 후 push)
|
||||||
|
- 목표: 원인을 고친 뒤 DLQ의 요청을 **중복 없이** 다시 돌리고, 장애가 났을 때 할 일을 **런북**으로 써서 연습하고, 반쯤 처리된 요청도 안전하게 다시 돌리는 것을 보고, 계속 실패하는 요청은 끝없이 돌리지 않고 멈추고, 지금 괜찮은지를 한 번에 알려 주는 상태 웹훅을 만든다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
E1["예제 1<br/>재처리 my-replay"] --> E2["예제 2<br/>런북 · 장애 연습"] --> E3["예제 3<br/>반쯤 처리된 요청"] --> T1["과제 1<br/>세 번 실패하면 멈춤"] --> T2["과제 2<br/>운영 상태 한눈에"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
|
||||||
|
|
||||||
|
## 예제 1 · 재처리 (12분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 원인을 고친 뒤 DLQ에 모아 둔 요청을 **다시 돌려 일을 끝내는** 재처리 워크플로우(`my-replay`)를 만듭니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 택배 재배송.** 보관소에 둔 택배는 다음 날 다시 배송하고, 전하면 목록에서 지워야 일이 끝납니다. 재배송도 따로 지름길로 하지 않고 **평소와 같은 배송 절차**를 거칩니다. 예제 1은 DLQ(보관소)의 요청을 같은 입구(`my-intake`)로 다시 보내는 재배송 실습입니다.
|
||||||
|
|
||||||
|
**상황.** Lab 02에서 API가 멈춘 동안의 문의를 DLQ에 모았습니다. 하지만 모아 두기만 하면 일은 끝나지 않습니다. 문의한 사람에게 접수 건은 아직 없습니다. 장애가 끝나면 누군가 DLQ의 요청을 다시 처리해야 합니다.
|
||||||
|
|
||||||
|
**핵심 결정: 새로 만들지 말고, 이미 있는 입구로 다시 보내기.** 재처리가 접수 건을 직접 만들면 항목 확인 · 분류 · 키 · DLQ · 알림 규칙을 한 벌 더 만들어야 하고, 언젠가 두 곳이 달라집니다. 그래서 DLQ의 원래 요청을 **`my-intake`에 그대로 다시 보냅니다.** 같은 규칙, 같은 멱등 키를 거칩니다.
|
||||||
|
|
||||||
|
| 재처리가 하는 일 | 까닭 |
|
||||||
|
| --- | --- |
|
||||||
|
| DLQ에서 `parked`가 아닌 것을 읽음 | 다시 돌릴 대상 |
|
||||||
|
| 하나씩 `my-intake`로 | 어느 것이 실패했는지 가리려고 |
|
||||||
|
| 성공하면 DLQ에서 지움, 실패하면 그대로 | 다음 재처리가 또 시도 (`attempts`는 `my-intake`가 올림) |
|
||||||
|
| 비었으면 바로 "할 일 없음" | 몇 번을 불러도 안전하게 |
|
||||||
|
|
||||||
|
**시험하는 것.** 장애 동안 2건 → DLQ, 고친 뒤 재처리 → DLQ 0 · 접수 건 2 (중복 없음), 한 번 더 돌려도 할 일 없음.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** DLQ는 끝이 아니라 중간이라는 것, 재처리를 원래 입구로 보내면 규칙이 하나로 유지된다는 것.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03.json -v # 고치기 전 (my-replay가 아직 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
**만들 것**: `my-work/replay.json` (`my-replay`). DLQ의 요청을 `my-intake`에 하나씩 다시 보내고, 성공하면 DLQ에서 지우기.
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 계약 | [AGENTS.md](AGENTS.md) 3절 재처리 `{replayed, ok, still_failed, parked, duplicates}` |
|
||||||
|
| 다시 보내는 곳 | `POST http://localhost:5678/webhook/my-intake` 본문 `{requests: [DLQ의 원래 요청]}` (AGENTS.md 5절) |
|
||||||
|
| 성공이면 | `POST /dlq/update {dlq_id, remove: true}`. 실패면 지우지 않음 |
|
||||||
|
| DLQ가 비었으면 | 할 일 없음 `{replayed: 0, ...}` |
|
||||||
|
| 기대 결과 | `cases/lab03.json` (재처리 뒤 DLQ 0, 접수 건 2개 — 중복 없음) |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**DLQ는 모으는 곳이지 끝이 아닙니다.** 원인(데이터 API 장애)을 고친 뒤 모아 둔 요청을 다시 돌려야 일이 끝납니다. 이것을 재처리(replay, redrive)라고 부릅니다. Amazon SQS에는 DLQ의 메시지를 원래 큐로 되돌리는 "redrive" 기능이 있습니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 지금
|
||||||
|
D1[("DLQ 2건")] --> X1(["그대로 쌓임"])
|
||||||
|
end
|
||||||
|
subgraph 바꾼 뒤
|
||||||
|
W2["my-replay"] --> G2[("GET /dlq")] --> P2{"parked 아닌 것"}
|
||||||
|
P2 -->|하나씩| I2["POST my-intake<br/>{requests: [원래 요청]}"] --> O2{"created 1?"}
|
||||||
|
O2 -->|예| R2[("dlq/update remove")]
|
||||||
|
O2 -->|아니오| K2["그대로 둠<br/>(my-intake가 attempts +1)"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로 만들지 말고 다시 보내기.** 재처리 워크플로우가 접수 건을 직접 만들면, 항목 확인 · 분류 · 키 · DLQ · 알림을 또 만들어야 하고, 두 곳의 규칙이 언젠가 달라집니다. 이미 있는 `my-intake`를 다시 부르면 **같은 규칙**으로 처리됩니다 (70일차 모듈과 같은 생각).
|
||||||
|
|
||||||
|
**다시 실패하면.** `my-intake`가 또 실패하면 그 요청을 다시 DLQ에 넣습니다. 데이터 API는 같은 `item.id`면 새 줄을 만들지 않고 `attempts`를 올립니다. 그래서 재처리는 실패한 것을 지우지 않고 그대로 두면 됩니다 (과제 1에서 `attempts`를 씀).
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 장애 동안 2건 | `tickets error` · `batch-two` | `failed: 2` |
|
||||||
|
| 고친 뒤 재처리 | `ok` · `my-replay` | `replayed: 2, ok: 2, still_failed: 0` |
|
||||||
|
| DLQ | `/dlq/list` | `size: 0` |
|
||||||
|
| 접수 건 | `/tickets-created/list` | `count: 2` (중복 없음) |
|
||||||
|
| 한 번 더 | `my-replay` | `replayed: 0` |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 1/5 (404), 고친 뒤 5/5.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 새로 만들지 말고 다시 보내기</summary>
|
||||||
|
|
||||||
|
DLQ 항목의 `item`이 원래 요청입니다. `my-intake`가 받는 모양(`{requests: [...]}`)으로 감싸 하나씩 보냅니다. 한 번에 다 보내면 하나가 실패할 때 어느 것인지 가리기 어렵습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 성공은 무엇으로 아나</summary>
|
||||||
|
|
||||||
|
`my-intake`의 결과에서 `created`가 1이면 성공입니다 (이미 있던 건이면 `duplicates`도 1). HTTP 상태는 늘 200이라(부분 성공) 상태 코드로는 알 수 없습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 3단계 · DLQ가 비었을 때</summary>
|
||||||
|
|
||||||
|
DLQ가 비면 `Pick items`가 항목 0개를 내놓고, n8n은 그 뒤 노드를 돌리지 않습니다. 응답이 나가지 않으면? 비어도 다음으로 가게 하는 설정과 "할 일 없음" 갈래가 필요합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 웹훅 ② DLQ에서 고르기와 비었을 때 ③ 다시 보내는 곳과 본문 ④ 성공 판단과 지우기 ⑤ 요약 · 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/replay.json 을 새로 만들어 줘. 웹훅 POST ____① .
|
||||||
|
Get DLQ(HTTP GET http://data-api:8800/dlq) → Pick items(Code): parked 가 아닌 것만, 비어도 다음으로 가게 ____② true.
|
||||||
|
Has items? 가 없으면 바로 {replayed: 0, ok: 0, still_failed: 0, parked: 0}.
|
||||||
|
있으면 항목마다 Run intake again: POST http://localhost:5678/webhook/my-intake 본문 {requests: [ ____③ ]} (DLQ 의 원래 요청).
|
||||||
|
my-intake 결과의 ____④ 가 1 이면 성공 → POST /dlq/update {dlq_id, remove: true}. 실패면 지우지 않는다.
|
||||||
|
끝에 AGENTS.md 3절 {replayed, ok, still_failed, parked, duplicates}. 기대 결과 cases/lab03.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① AGENTS.md 3절 재처리 줄의 웹훅 이름입니다.
|
||||||
|
- ② ▶ 3단계. AGENTS.md 5절 "그 밖"에 있는 노드 설정입니다.
|
||||||
|
- ③ DLQ 항목에서 원래 요청이 든 칸입니다 (`curl -s localhost:8800/dlq`로 확인).
|
||||||
|
- ④ ▶ 2단계.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/replay.json
|
||||||
|
uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 재처리
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 시간은 실행마다 조금씩 다르고, ✔ · ✘는 같아야 합니다.
|
||||||
|
|
||||||
|
고치기 전
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ API가 멈춘 동안 2건 → DLQ · 2.9s
|
||||||
|
✘ 고친 뒤 다시 돌리기 · 0.0s · status 404 (expected 200); replayed = null (expected 2); ok = null (expected 2); still_failed = null (expected 0)
|
||||||
|
✘ DLQ가 비었음 · 0.0s · size = 2 (expected 0)
|
||||||
|
✘ 접수 건은 2개 (중복 없음) · 0.0s · count = 0 (expected 2)
|
||||||
|
✘ 한 번 더 돌려도 할 일 없음 · 0.0s · status 404 (expected 200); replayed = null (expected 0)
|
||||||
|
Passed 1/5
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ API가 멈춘 동안 2건 → DLQ · 4.4s
|
||||||
|
✔ 고친 뒤 다시 돌리기 · 1.1s
|
||||||
|
✔ DLQ가 비었음 · 0.0s
|
||||||
|
✔ 접수 건은 2개 (중복 없음) · 0.0s
|
||||||
|
✔ 한 번 더 돌려도 할 일 없음 · 0.0s
|
||||||
|
Passed 5/5
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전에는 `my-replay` 웹훅이 없어 404. DLQ의 2건은 쌓인 채 접수 건 0개입니다.
|
||||||
|
- 고친 뒤 1.1초에 `ok: 2`: 두 요청을 `my-intake`로 다시 보내 접수 건을 만들고, DLQ에서 지웠습니다. 다시 보낸 요청도 `my-intake`의 확인 · 분류 · 키 규칙을 똑같이 거쳤습니다.
|
||||||
|
- 한 번 더 돌리면 DLQ가 비어 `replayed: 0` (0.0초). 비었을 때 갈래가 바로 답했습니다.
|
||||||
|
|
||||||
|
## 예제 2 · 런북과 장애 연습 (12분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 장애가 났을 때 할 일을 **런북**으로 써 두고, 짝과 함께 **일부러 장애를 내서** 그 런북이 정말 쓸모 있는지 시험합니다. 워크플로우는 고치지 않습니다(에이전트 없이).
|
||||||
|
|
||||||
|
> **생활 속 예 · 소방 훈련.** 대피 안내문은 붙여 두기만 하면 맞는지 모릅니다. 일부러 경보를 울리고 모두 대피해 봐야 "비상구 앞에 상자가 있다" 같은 문제가 드러납니다. 예제 2는 런북(대피 안내문)을 쓰고, 짝이 일부러 장애를 낸 상태에서 런북만 보고 대응해 보는 훈련입니다.
|
||||||
|
|
||||||
|
**왜 하나.** 지금까지 실패를 모으고(DLQ) 알리고(알림) 다시 돌리는(재처리) 장치를 만들었습니다. 하지만 알림을 받은 **사람**이 무엇을 할지 모르면 장치는 소용없습니다. 장애는 대개 밤에, 만든 사람이 없을 때 납니다. 그때 처음 보는 사람이 따라 할 수 있는 순서가 런북입니다.
|
||||||
|
|
||||||
|
**진행 셋.**
|
||||||
|
|
||||||
|
| 순서 | 누가 | 하는 일 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 · 쓰기 | 각자 | `RUNBOOK.md` 2절 "알림을 받으면": 확인 → 원인 → 고침 → 재처리 → 확인을 **명령 그대로** |
|
||||||
|
| 2 · 짝 연습 | 한 사람은 장애를 냄, 다른 사람은 **런북만 보고** 대응 | 막힌 곳 · 빠진 단계 · 걸린 시간을 적음 |
|
||||||
|
| 3 · 자동 연습 | 테스트 | `lab03-drill.json`이 같은 연습을 한 번에 (7/7) |
|
||||||
|
|
||||||
|
**볼 것.** 장애를 내면 알림이 **둘**(high · dlq, critical · unexpected) 생깁니다. 원인이 다른 알림입니다. 고장 스위치를 되돌리고 재처리하면 DLQ는 비지만, 버그 알림은 남습니다. 런북이 이 둘을 나눠 다루는지 확인합니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 런북은 써 두기만 하면 틀린 줄 모르고, 다른 사람이 따라 해 봐야 빈 곳이 보인다는 것 (장애 연습, game day).
|
||||||
|
|
||||||
|
**적을 곳.** `RUNBOOK.md` 2절 (순서), 3절 (연습 기록: 걸린 시간 · 빠진 단계).
|
||||||
|
|
||||||
|
이제 런북 2절 "알림을 받으면"을 순서대로 씁니다. **새벽 3시에 처음 보는 사람**이 따라 할 수 있어야 합니다. 그다음 옆 사람과 짝을 지어, 한 사람이 장애를 내고 다른 사람이 런북만 보고 대응합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 장애를 내는 사람
|
||||||
|
uv run tools/flow.py ops --reset
|
||||||
|
uv run tools/flow.py fault tickets error
|
||||||
|
uv run tools/flow.py call my-intake requests/batch-two.json
|
||||||
|
uv run tools/flow.py call nightly-report
|
||||||
|
# 대응하는 사람: 런북 순서대로 (ops → 원인 → 고침 → 재처리 → 확인)
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
uv run tools/flow.py fault tickets ok # "고침" (오늘은 고장 스위치를 되돌리는 것)
|
||||||
|
uv run tools/flow.py call my-replay
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
마지막으로 같은 연습을 테스트로 돌려 봅니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-drill.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
`RUNBOOK.md` 3절에 걸린 시간을 적습니다. 런북에서 빠진 단계는 무엇이었나요?
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**런북(runbook)은 "그때 할 일을 미리 써 둔 것"입니다.** 장애는 대개 바쁠 때, 밤에, 그 시스템을 만든 사람이 없을 때 납니다. 그때 머릿속으로 떠올리면 단계를 빠뜨립니다. 런북은 알림 하나에 대해 **확인 → 원인 → 고침 → 되돌리기 → 확인**을 명령 그대로 적습니다. 구글 SRE 책은 런북(플레이북)이 있으면 장애에서 회복하는 시간이 크게 줄어든다고 말합니다.
|
||||||
|
|
||||||
|
**좋은 런북의 네 가지.**
|
||||||
|
|
||||||
|
| 갖출 것 | 예 |
|
||||||
|
| --- | --- |
|
||||||
|
| 명령을 **그대로 복사**해 쓸 수 있게 | `uv run tools/flow.py ops` (설명 말고 명령) |
|
||||||
|
| "확인"은 **무엇을 보면 되는지**까지 | "`ops`의 DLQ가 0이고 접수 건 수가 늘었는지" |
|
||||||
|
| **하지 말 것** | "DLQ를 비우지 말 것 — 재처리할 요청이 사라짐", "parked를 자동으로 돌리지 말 것" |
|
||||||
|
| **누구 · 언제까지** | parked 건은 누가 몇 시간 안에 보나 (4절) |
|
||||||
|
|
||||||
|
**장애 연습(game day).** 런북은 써 두기만 하면 틀린 줄을 모릅니다. 일부러 장애를 내고 **다른 사람이 런북만 보고** 대응해 봐야 빠진 단계가 드러납니다. Netflix의 Chaos Monkey처럼 운영 중에 일부러 고장을 내는 곳도 있습니다 (카오스 엔지니어링). 오늘은 고장 스위치로 작게 합니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant A as 장애를 내는 사람
|
||||||
|
participant S as 시스템
|
||||||
|
participant B as 대응하는 사람 (런북)
|
||||||
|
A->>S: fault tickets error · my-intake · nightly-report
|
||||||
|
S-->>B: 알림 high (dlq) · critical (unexpected)
|
||||||
|
B->>S: ops (확인)
|
||||||
|
B->>S: fault tickets ok (고침)
|
||||||
|
B->>S: my-replay (재처리)
|
||||||
|
B->>S: ops (DLQ 0 · 접수 건 · 중복 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 케이스 (`lab03-drill`, 같은 연습을 자동으로).** 평소 3건 → 장애 2건 → DLQ 2 → 보고서 500 → critical 알림 → 고친 뒤 재처리 `ok: 2` → 접수 건 5 (중복 없음).
|
||||||
|
|
||||||
|
**기대 결과.** 7/7. 짝 연습에서 런북에 빠진 단계를 하나 이상 찾아 3절에 적습니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 알림 둘, 무엇부터</summary>
|
||||||
|
|
||||||
|
`ops`에 알림이 둘(high dlq · critical unexpected) 있습니다. 둘은 같은 원인인가요? `nightly-report`의 critical은 고장 스위치를 되돌려도 남습니다. 런북에서 둘을 어떻게 나눌까요?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 실행 결과 · 장애 연습
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다 (줄임).
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ fault tickets error → call my-intake requests/batch-two.json HTTP 200 · 2.8s ("failed": 2)
|
||||||
|
$ call nightly-report HTTP 500 · 0.1s
|
||||||
|
$ ops
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 attempts 1 … D-2 R-302 attempts 1 …
|
||||||
|
alerts: 2
|
||||||
|
[high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
|
$ fault tickets ok → call my-replay HTTP 200 · 1.4s
|
||||||
|
$ ops
|
||||||
|
created tickets: 2
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
|
||||||
|
$ test cases/lab03-drill.json
|
||||||
|
✔ 1 · 평소 · 2.6s
|
||||||
|
✔ 2 · 장애 시작 · API 오류 · 3.0s
|
||||||
|
✔ 3 · DLQ 확인 · 0.0s
|
||||||
|
✔ 4 · 보고서 작업도 실패 · 0.0s
|
||||||
|
✔ 5 · 알림 확인 · 0.0s
|
||||||
|
✔ 6 · 고친 뒤 재처리 · 1.4s
|
||||||
|
✔ 7 · 결과 확인 · 중복 없이 5건 · 0.0s
|
||||||
|
Passed 7/7
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 장애를 낸 뒤 `ops` 한 번으로 DLQ 2건과 알림 둘이 보였습니다. **원인이 다른 알림 둘**입니다: high는 데이터 API 장애(고장 스위치), critical은 `nightly-report`의 버그입니다.
|
||||||
|
- 고장 스위치를 되돌리고 재처리하자 DLQ 0 · 접수 건 2. 하지만 critical 알림은 남아 있습니다. 버그는 재처리로 고쳐지지 않습니다 — 런북에 "critical unexpected는 코드 담당에게"가 따로 있어야 합니다.
|
||||||
|
- 테스트의 접수 건 5건 = 평소 3 + 재처리 2. 같은 연습을 사람이 해도, 테스트가 해도 결과가 같습니다.
|
||||||
|
|
||||||
|
## 예제 3 · 반쯤 처리된 요청 (6분, 에이전트 없이)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** "실패"로 DLQ에 들어갔지만 **사실은 처리된** 요청을 재처리해도, 접수 건이 두 번 생기지 않는지 확인합니다. 고치지 않고(에이전트 없이) 재기만 합니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 엘리베이터 버튼.** 급한 사람이 버튼을 여러 번 눌러도 엘리베이터는 한 대만 옵니다. 버튼이 "이미 불렀음"을 기억하기 때문입니다. 예제 3은 사실은 처리된 요청을 재처리로 "한 번 더 눌러도" 접수 건이 한 번만 생기는지 확인하는 실습입니다.
|
||||||
|
|
||||||
|
**이어지는 이야기.** Lab 01 예제 3에서 `slow`일 때 `my-intake`는 3초씩 세 번 기다리다 `failed`라고 답했지만, 데이터 API는 8초 뒤 접수 건을 **만들었습니다.** Lab 02부터는 `failed`가 DLQ로 갑니다. 그러면 이 순간 같은 문의가 **두 곳에** 있습니다.
|
||||||
|
|
||||||
|
| 곳 | 상태 |
|
||||||
|
| --- | --- |
|
||||||
|
| 데이터 API | 접수 건이 이미 있음 (늦게 만들어짐) |
|
||||||
|
| DLQ | "실패한 요청"으로 기다림 |
|
||||||
|
|
||||||
|
여기서 예제 1의 재처리를 돌리면 같은 문의를 **또** 보냅니다. 접수 건이 둘이 될까요?
|
||||||
|
|
||||||
|
**시험하는 것.** `slow`로 2건 실패 → DLQ 2 → 8초 뒤 접수 건이 이미 2 → 재처리 → 접수 건은? DLQ는?
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 재시도 · 재처리가 있는 시스템에서는 같은 요청이 **최소 한 번 이상** 갈 수 있고, 그래도 결과가 한 번이 되게 하는 것은 받는 쪽의 멱등 키라는 것 (at-least-once + 멱등). Lab 01 예제 2의 키가 재처리까지 지켜 줍니다.
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-half.json -v
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**DLQ의 요청이 꼭 "안 된 것"은 아닙니다.** 시간 초과로 포기한 요청은 사실 **처리됐을 수도** 있습니다. 상대가 늦게 끝냈을 뿐입니다. 그런 요청이 DLQ에 들어가고, 나중에 재처리되면 같은 일을 **두 번** 하게 됩니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant I as my-intake
|
||||||
|
participant A as 데이터 API (slow)
|
||||||
|
participant D as DLQ
|
||||||
|
participant R as my-replay
|
||||||
|
I->>A: R-301 (key R-301)
|
||||||
|
Note over I: 3초 × 3번 timeout → failed
|
||||||
|
I->>D: R-301 넣기
|
||||||
|
A-->>A: 8초 뒤 접수 건 R-301 만들기
|
||||||
|
R->>I: R-301 다시
|
||||||
|
I->>A: key R-301
|
||||||
|
A-->>I: 200 · 처음 번호 · duplicate: true
|
||||||
|
R->>D: 지우기
|
||||||
|
```
|
||||||
|
|
||||||
|
**최소 한 번 + 멱등 = 결과는 한 번.** 재시도 · 재처리가 있는 시스템은 같은 요청이 **최소 한 번(at-least-once)** 이상 갈 수 있습니다. "정확히 한 번(exactly-once)"을 네트워크 너머에서 보장하기는 매우 어렵습니다. 그래서 실무에서는 **받는 쪽을 멱등하게** 만들어 "여러 번 가도 결과는 한 번"을 만듭니다. 오늘 Lab 01 예제 2의 멱등 키가 재처리까지 지켜 줍니다.
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `tickets slow` · `batch-two` | `failed: 2` (약 12초) |
|
||||||
|
| 2 | `/dlq/list` | `size: 2` |
|
||||||
|
| 3 | 8초 기다린 뒤 `ok` · `/tickets-created/list` | `count: 2` (이미 있음) |
|
||||||
|
| 4 | `my-replay` | `replayed: 2, ok: 2, duplicates: 2` |
|
||||||
|
| 5 | `/tickets-created/list` | `count: 2` (그대로) |
|
||||||
|
| 6 | `/dlq/list` | `size: 0` |
|
||||||
|
|
||||||
|
**기대 결과.** 6/6. 재처리가 두 건 모두 "이미 있던 건"(`duplicates: 2`)으로 끝나고 접수 건은 2개 그대로입니다.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 키가 없었다면</summary>
|
||||||
|
|
||||||
|
Lab 01 예제 1의 기준처럼 키가 실행 번호였다면, 4번의 재처리 뒤 접수 건은 몇 개일까요? 그 사이 담당자가 첫 번째 접수 건에 답을 달았다면?
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 실행 결과 · 반쯤 처리된 요청
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 예제 1 정답으로 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 1 · API가 느림 → 기다리다 실패 · 11.6s
|
||||||
|
✔ 2 · DLQ에 2건 · 0.0s
|
||||||
|
… before: wait 8s
|
||||||
|
✔ 3 · 그런데 접수 건은 이미 2개 · 0.0s
|
||||||
|
✔ 4 · 재처리 · 이미 있는 건이라 duplicate · 1.1s
|
||||||
|
← response { "replayed": 2, "ok": 2, "still_failed": 0, "parked": 0, "duplicates": 2 }
|
||||||
|
✔ 5 · 그래도 2개 (중복 없음) · 0.0s
|
||||||
|
✔ 6 · DLQ가 비었음 · 0.0s
|
||||||
|
Passed 6/6
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 1 · 2: `slow`라 3초마다 끊겨 11.6초 뒤 `failed: 2`, 두 요청이 DLQ로.
|
||||||
|
- 3: 8초 뒤 접수 건이 **이미 2개**입니다. 데이터 API가 늦게라도 처리했습니다. 이 순간 DLQ에도 2건, 접수 건도 2건 — 같은 문의가 두 곳에 있습니다.
|
||||||
|
- 4 · 5: 재처리가 두 건을 다시 보냈고, 키(`R-301` · `R-302`)가 같아 데이터 API가 처음 번호를 돌려줬습니다 (`duplicates: 2`). 접수 건은 2개 그대로, DLQ는 0. **반쯤 처리된 요청도 재처리가 안전한 것은 키 덕분입니다.**
|
||||||
|
|
||||||
|
## 과제 1 · 세 번 실패하면 멈추기 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 재처리해도 계속 실패하는 요청은 **세 번에서 멈추고**(`parked`), 사람에게 넘깁니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 비밀번호 잠금.** 은행 앱은 비밀번호를 5번 틀리면 더 시도하게 두지 않고 잠근 뒤, 창구에서 사람이 확인하게 합니다. 과제 1은 세 번 재처리해도 실패한 요청을 잠그고(`parked`) 사람에게 넘기는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 예제 1의 재처리는 실패하면 DLQ에 그대로 두고, 다음 재처리에서 또 시도합니다. 원인이 금방 고쳐지면 좋지만, 하루 종일 고쳐지지 않거나 요청 자체가 영영 처리되지 않는 것이라면? 재처리를 돌릴 때마다 같은 요청이 또 실패하고, API를 두드리고, 알림이 쌓이고, 새로 들어온 실패가 묻힙니다.
|
||||||
|
|
||||||
|
| | 지금 | 바라는 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 세 번째 재처리 | 또 보냄 → 또 실패 (끝없이) | 다시 보내지 않고 `parked: true` + `high · parked` 알림 |
|
||||||
|
| parked 항목 | — | DLQ에 남기고(지우지 않음), 다음 재처리도 건너뜀 |
|
||||||
|
| 원인을 고친 뒤 | — | parked는 **자동으로 돌리지 않음** (사람이 보고 결정) |
|
||||||
|
|
||||||
|
**`attempts`는 어디서 오나.** DLQ는 같은 요청이 다시 들어오면 새 줄 대신 `attempts`를 올립니다 (Lab 02 예제 1). 그 수를 보고 멈춥니다.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** 자동 재처리에도 끝이 있어야 한다는 것 (SQS의 최대 받기 횟수), parked는 포기가 아니라 "자동은 여기까지, 이제 사람이"라는 것, 간격 × 횟수 = 포기하기까지의 시간.
|
||||||
|
|
||||||
|
> "API가 하루 종일 멈춘 날, 재처리를 돌릴 때마다 같은 요청이 또 실패하고 또 실패했어요. 계속 실패하는 건 따로 빼서 사람이 보게 해 주세요."
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json -v # 고치기 전
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 기준 | `attempts`가 `max_attempts`(본문, 기본 3) 이상이면 다시 보내지 않고 `parked: true` (`POST /dlq/update`) |
|
||||||
|
| 알림 | parked 할 때 `{workflow: "replay", severity: "high", reason: "parked", dlq_id, message}` |
|
||||||
|
| parked는 | 다음 재처리에서도 건너뜀 (사람이 보고 결정), DLQ에서 지우지 않음 |
|
||||||
|
| 기대 결과 | `cases/lab03-challenge.json` (재처리 1 · 2는 또 실패, 3에서 parked 2), `lab03` 그대로 |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**끝없이 다시 하면 독이 됩니다.** 원인이 고쳐지지 않은 요청을 계속 다시 보내면 매번 API를 두드리고, 알림이 쌓이고, 정말 새로 들어온 실패가 묻힙니다. 메시지 시스템들은 이것을 "최대 받기 횟수"(Amazon SQS의 `maxReceiveCount`)로 막습니다: 그 수를 넘으면 더 시도하지 않고 따로 옮깁니다. 오늘은 DLQ 안에서 `parked`로 표시합니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
G[("DLQ")] --> P{"parked?"}
|
||||||
|
P -->|예| S["건너뜀"]
|
||||||
|
P -->|아니오| A{"attempts ≥ 3?"}
|
||||||
|
A -->|아니오| R["my-intake로 다시"]
|
||||||
|
A -->|예| K[("parked: true")] --> L[("알림 high · parked")]
|
||||||
|
R & K --> M["모아서 요약<br/>replayed · parked"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**사람에게 넘기기 (human in the loop).** parked는 "포기"가 아니라 "**자동으로는 여기까지, 이제 사람이**"입니다. 요청 자체가 이상한지(데이터 API가 영영 받지 않는 글), 데이터 API의 다른 문제인지, 사람이 보고 고친 뒤 직접 다시 돌립니다. 그래서 parked 항목은 지우지 않고, 런북 4절에 "누가 · 언제까지"를 적습니다.
|
||||||
|
|
||||||
|
**몇 번이 좋은가.** 3은 오늘 정한 숫자입니다. 재처리를 1분마다 자동으로 돌린다면 3번은 3분, 하루에 한 번 돌린다면 3일입니다. **간격 × 횟수 = 포기하기까지 걸리는 시간**으로 생각합니다 (바이브 코딩 `tretry`의 backoff · 마감과 같은 생각).
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 처음 실패 | `tickets error` · `batch-two` | `failed: 2` (attempts 1) |
|
||||||
|
| 재처리 1 · 2 | `my-replay` 두 번 (여전히 error) | `replayed: 2, still_failed: 2, parked: 0` (attempts 2 · 3) |
|
||||||
|
| 재처리 3 | `my-replay` | `replayed: 0, parked: 2` |
|
||||||
|
| DLQ | `/dlq/list` | `size: 2, parked: 2` |
|
||||||
|
| 알림 | `/alerts/latest` | `high · parked` |
|
||||||
|
| 고친 뒤 | `ok` · `my-replay` | `replayed: 0` (parked는 자동으로 돌리지 않음) |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 3/7, 고친 뒤 7/7.
|
||||||
|
|
||||||
|
**먼저 생각해 보기 (2분)**: 재처리를 1분마다 자동으로 돌린다면 몇 번이 좋을까요? 데이터 API 담당자가 출근하기까지 얼마나 걸리나요?
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 두 갈래</summary>
|
||||||
|
|
||||||
|
DLQ 항목을 "다시 보낼 것"과 "멈출 것"으로 나눈 뒤, 두 갈래를 끝에서 모아 요약합니다 (Lab 01의 Merge).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 사람에게 넘기기</summary>
|
||||||
|
|
||||||
|
parked 항목은 지우지 않습니다. 런북에 "parked 건은 누가, 언제까지, 무엇을 보고 결정하나"를 적습니다 (4절).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 기준 수와 어디서 읽나 ② 나누기 ③ 멈출 때 하는 두 가지 ④ 모으기와 요약 ⑤ 지우지 않기 ⑥ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/replay.json 의 Pick items 에서 body.max_attempts(없으면 ____① ) 를 읽어,
|
||||||
|
attempts 가 그 수 ____② 인 항목은 action "park", 나머지는 "replay" 로 나눠 줘.
|
||||||
|
Replay it?(If): replay 는 지금처럼 다시 보내고, park 는 POST /dlq/update {dlq_id, parked: ____③ , reason} 뒤
|
||||||
|
POST /alerts {workflow: "replay", severity: "high", reason: "____④", dlq_id, message}.
|
||||||
|
두 갈래를 Merge 로 모아 요약의 parked 에 수를. parked 항목은 지우지 않는다.
|
||||||
|
기대 결과 cases/lab03-challenge.json, cases/lab03.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 위 조건 표의 기본값입니다.
|
||||||
|
- ② "3번 실패한 것"이 멈추려면 같음을 포함하는지 생각합니다 (재처리 3에서 attempts는 3).
|
||||||
|
- ③ DLQ 항목의 칸 값입니다 (참/거짓).
|
||||||
|
- ④ 위 조건 표의 알림 reason 그대로입니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/replay.json
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
uv run tools/flow.py test cases/lab03.json
|
||||||
|
uv run tools/flow.py ops
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행 결과 · 세 번 실패하면 멈추기
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
고치기 전 (예제 1 정답)
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ API가 오래 멈춤 · 처음 실패 · 3.4s
|
||||||
|
✔ 재처리 1 · 또 실패 · 2.2s
|
||||||
|
✔ 재처리 2 · 또 실패 · 2.4s
|
||||||
|
✘ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 2.0s · replayed = 2 (expected 0); parked = 0 (expected 2)
|
||||||
|
✘ parked로 남음 · 0.0s · parked = 0 (expected 2)
|
||||||
|
✘ 멈춘 것은 알림 · 0.0s · reason = dlq (expected parked)
|
||||||
|
✘ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 1.6s · replayed = 2 (expected 0)
|
||||||
|
Passed 3/7
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ API가 오래 멈춤 · 처음 실패 · 4.3s
|
||||||
|
✔ 재처리 1 · 또 실패 · 1.9s
|
||||||
|
✔ 재처리 2 · 또 실패 · 2.1s
|
||||||
|
✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s
|
||||||
|
✔ parked로 남음 · 0.0s
|
||||||
|
✔ 멈춘 것은 알림 · 0.0s
|
||||||
|
✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s
|
||||||
|
Passed 7/7
|
||||||
|
(lab03 5/5)
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전 재처리 3도 두 건을 또 보냈습니다 (2.0초, `replayed: 2`). 몇 번을 돌려도 같습니다. 마지막 알림은 `my-intake`의 `dlq`라 parked 알림이 없고, 고친 뒤 재처리는 사람이 보기 전에 자동으로 보내 버렸습니다.
|
||||||
|
- 고친 뒤 재처리 3은 **0.1초**: `attempts`가 3이 된 두 건을 다시 보내지 않고 parked로 표시하고 high 알림을 남겼습니다. DLQ에는 `parked: 2`로 남아 사람을 기다립니다.
|
||||||
|
- 고친 뒤 재처리도 `replayed: 0`: parked는 자동으로 돌리지 않습니다. `lab03` 5/5로 평소 재처리는 그대로입니다.
|
||||||
|
|
||||||
|
## 과제 2 · 운영 상태 한눈에 (10분)
|
||||||
|
|
||||||
|
### 이 실습의 의도
|
||||||
|
|
||||||
|
**한 줄로:** 주소 하나를 부르면 지금 상태를 **신호등 셋**(ok · warn · critical)과 **까닭**으로 알려 주는 웹훅을 만듭니다.
|
||||||
|
|
||||||
|
> **생활 속 예 · 미세먼지 앱.** 측정값 여럿을 늘어놓지 않고 "나쁨 · 마스크를 쓰세요"를 먼저 보여 줍니다. 과제 2는 DLQ · parked · 알림 숫자를 신호등(`ok` · `warn` · `critical`)과 할 일(까닭)로 줄여 주는 웹훅을 만드는 실습입니다.
|
||||||
|
|
||||||
|
**상황.** 지금 상태를 알려면 `ops`를 치고 DLQ · parked · 알림을 하나하나 읽어 판단해야 합니다. 만든 사람은 할 수 있지만, 처음 보는 사람은 "그래서 괜찮은 거야?"를 모릅니다. 오늘 하루 만든 것(DLQ · parked · 알림)을 **판단 하나로 줄여** 줍니다.
|
||||||
|
|
||||||
|
| 신호 | 언제 | 사람이 할 일 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ok` | 아무 일 없음 | 없음 |
|
||||||
|
| `warn` | 기다리는 실패(DLQ) · 10분 안 high 알림 | 원인을 고친 뒤 재처리 |
|
||||||
|
| `critical` | parked · 10분 안 critical 알림 | 지금 봄, 담당에게 |
|
||||||
|
|
||||||
|
**지킬 것.** 상태를 묻는 것이 상태를 바꾸면 안 됩니다 (**읽기만**). 그리고 신호만이 아니라 까닭(`reasons`)을 함께 줘서, 런북의 어느 줄로 갈지 바로 알게 합니다.
|
||||||
|
|
||||||
|
**시험하는 것.** 처음 `ok` → 접수 실패 뒤 `warn` → 버그 알림 뒤 `critical` → 재처리하고 알림을 비운 뒤 다시 `ok`. 신호가 실제 상태를 따라가는지.
|
||||||
|
|
||||||
|
**끝나면 알게 되는 것.** "살아 있나"(`/health`)와 "일이 잘 되나"는 다르다는 것, 여러 숫자를 사람이 읽을 판단으로 줄이는 법. 런북 2절 1번을 "`my-ops-status`부터"로 바꿀 수 있습니다.
|
||||||
|
|
||||||
|
> "알림이 오면 `ops` 명령을 치고 알림 · DLQ를 하나하나 읽어야 해서, 처음 보는 사람은 지금 괜찮은 건지 아닌지를 몰라요. 주소 하나를 부르면 '괜찮다 · 주의 · 위험'과 그 까닭을 바로 알려 주세요. 나중에 대시보드에도 붙이려고요."
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge2.json -v # 고치기 전
|
||||||
|
```
|
||||||
|
|
||||||
|
| 조건 | 값 |
|
||||||
|
| --- | --- |
|
||||||
|
| 파일 · 웹훅 | `my-work/ops-status.json` · `POST my-ops-status` |
|
||||||
|
| `critical` | parked가 1 이상이거나, 10분 안 critical 알림이 있음 |
|
||||||
|
| `warn` | parked가 아닌 DLQ가 있거나, 10분 안 high 알림이 있음 |
|
||||||
|
| `ok` | 둘 다 아님 |
|
||||||
|
| 결과 | AGENTS.md 3절 `{status, dlq, parked, created, alerts: {critical, high}, reasons: [...]}` |
|
||||||
|
| 지킬 것 | **읽기만** (DLQ · 알림을 바꾸지 않음) |
|
||||||
|
| 기대 결과 | `cases/lab03-challenge2.json` |
|
||||||
|
|
||||||
|
### 이해하기
|
||||||
|
|
||||||
|
**상태 확인 (health check · status page).** 운영 중인 서비스는 대개 "지금 괜찮은가"를 알려 주는 주소를 둡니다. 쿠버네티스는 그 주소로 컨테이너가 살아 있는지 보고, 많은 회사가 고객용 상태 페이지(status page)를 둡니다. 오늘은 사람이 보는 판입니다: 여러 숫자를 **세 단계 신호등**과 **까닭**으로 줄입니다.
|
||||||
|
|
||||||
|
| 신호 | 뜻 | 런북 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ok` | 할 일 없음 | — |
|
||||||
|
| `warn` | 기다리는 실패가 있음 (DLQ) | 원인을 고친 뒤 재처리 |
|
||||||
|
| `critical` | 사람이 지금 봐야 함 (parked · critical 알림) | 1차 대응 · 담당에게 |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W["my-ops-status"] --> D[("GET /dlq")] --> A[("POST /alerts/list<br/>within 600")] --> T[("GET /tickets-created")] --> S["Decide status"] --> O(["status · reasons"])
|
||||||
|
```
|
||||||
|
|
||||||
|
**읽기만 하는 확인.** 상태를 묻는 것이 상태를 바꾸면 안 됩니다. Lab 02에서 테스트가 `/alerts`에 POST해서 빈 알림을 만든 일이 있었습니다 (`/alerts/list`를 쓰는 까닭). 상태 웹훅은 몇 번을 불러도 DLQ · 알림이 그대로여야 합니다.
|
||||||
|
|
||||||
|
**까닭을 함께.** "`critical`"만 있으면 사람은 다시 `ops`를 칩니다. "`parked 2건: 사람이 보고 결정`"처럼 **무엇을 해야 하는지**까지 알려 주면, 런북의 어느 줄로 갈지 바로 압니다.
|
||||||
|
|
||||||
|
**테스트 케이스.**
|
||||||
|
|
||||||
|
| 케이스 | 하는 것 | 기대 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | (처음) | `ok`, `dlq: 0` |
|
||||||
|
| 2 · 3 | `tickets error` · `my-intake` 2건 실패 → 상태 | `warn`, `dlq: 2`, 까닭에 `재처리` |
|
||||||
|
| 4 · 5 | `nightly-report` 500 → 3초 뒤 상태 | `critical`, `alerts.critical: 1` |
|
||||||
|
| 6 · 7 | 재처리 · 알림 확인 뒤 비움 → 상태 | `ok`, `dlq: 0, created: 2` |
|
||||||
|
|
||||||
|
**기대 결과.** 고치기 전 3/7 (상태 케이스 넷이 404), 고친 뒤 7/7.
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 순서가 있는 규칙</summary>
|
||||||
|
|
||||||
|
`critical` 조건과 `warn` 조건이 둘 다 맞으면? 더 나쁜 쪽을 먼저 봅니다. 까닭(`reasons`)은 맞는 것을 **모두** 적습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||||
|
|
||||||
|
요청문에 꼭 들어갈 것: **① 웹훅 ② 읽을 곳 셋 ③ 규칙과 순서 ④ 결과 모양 ⑤ 읽기만 ⑥ 기대 결과**. 빈칸 4개를 채웁니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/ops-status.json 을 새로 만들어 줘. 웹훅 POST ____① .
|
||||||
|
Get DLQ(GET /dlq) → Recent alerts(POST /alerts/list {within: ____② }) → Get created(GET /tickets-created) → Decide status(Code).
|
||||||
|
parked 가 1 이상이거나 10분 안 critical 알림이 있으면 "critical", parked 가 아닌 DLQ 가 있거나 high 알림이 있으면 " ____③ ", 아니면 "ok".
|
||||||
|
결과 {status, dlq(parked 가 아닌 수), parked, created, alerts: {critical, high}, reasons: [한국어로 까닭]}.
|
||||||
|
읽기만 한다: 이 워크플로우에서 DLQ · 알림을 ____④ . 기대 결과 cases/lab03-challenge2.json.
|
||||||
|
```
|
||||||
|
|
||||||
|
빈칸 힌트:
|
||||||
|
|
||||||
|
- ① 위 조건 표의 웹훅입니다.
|
||||||
|
- ② 10분을 초로 (Lab 02 과제 2와 같음).
|
||||||
|
- ③ 위 조건 표의 가운데 신호입니다.
|
||||||
|
- ④ "읽기만"을 다르게 말한 것입니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run tools/flow.py push my-work/ops-status.json
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
uv run tools/flow.py test cases/lab03-challenge.json
|
||||||
|
uv run tools/flow.py call my-ops-status
|
||||||
|
uv run tools/flow.py ops --reset
|
||||||
|
```
|
||||||
|
|
||||||
|
마지막으로 `RUNBOOK.md` 전체, [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웁니다. 런북 2절 1번에 "`my-ops-status`부터"를 넣어도 됩니다.
|
||||||
|
|
||||||
|
### 실행 결과 · 운영 상태 한눈에
|
||||||
|
|
||||||
|
수업 준비 때(10/29) 강사 노트북에서 실제로 나온 출력입니다.
|
||||||
|
|
||||||
|
고치기 전
|
||||||
|
|
||||||
|
```text
|
||||||
|
✘ 1 · 아무 일 없음 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0)
|
||||||
|
✔ 2 · (접수 실패 2건) · 2.9s
|
||||||
|
✘ 3 · DLQ가 있으면 warn · 0.0s · status 404 (expected 200); ...
|
||||||
|
✔ 4 · (보고서 버그) · 0.0s
|
||||||
|
✘ 5 · critical 알림이 있으면 critical · 0.0s · status 404 (expected 200); ...
|
||||||
|
✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.1s
|
||||||
|
✘ 7 · 고친 뒤 → ok · 0.0s · status 404 (expected 200); ...
|
||||||
|
Passed 3/7
|
||||||
|
```
|
||||||
|
|
||||||
|
고친 뒤
|
||||||
|
|
||||||
|
```text
|
||||||
|
✔ 1 · 아무 일 없음 → ok · 0.9s
|
||||||
|
✔ 2 · (접수 실패 2건) · 3.7s
|
||||||
|
✔ 3 · DLQ가 있으면 warn · 0.1s
|
||||||
|
✔ 4 · (보고서 버그) · 0.1s
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 5 · critical 알림이 있으면 critical · 0.1s
|
||||||
|
✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.2s
|
||||||
|
✔ 7 · 고친 뒤 → ok · 0.0s
|
||||||
|
Passed 7/7
|
||||||
|
(lab03-challenge 7/7)
|
||||||
|
|
||||||
|
$ call my-ops-status (lab02-challenge2 바로 뒤, 비우기 전)
|
||||||
|
HTTP 200 · 0.1s
|
||||||
|
{ "status": "critical", "dlq": 5, "parked": 0, "created": 2, "alerts": { "critical": 1, "high": 1 },
|
||||||
|
"reasons": [ "critical 알림 1건 (10분)", "DLQ 5건: 원인을 고친 뒤 재처리", "high 알림 1건 (10분)" ] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 이렇게 나왔나**
|
||||||
|
|
||||||
|
- 고치기 전에는 웹훅이 없어 상태 케이스 넷이 404. 상태를 바꾸는 케이스 셋(실패 · 버그 · 재처리)만 ✔.
|
||||||
|
- 고친 뒤 `ok` → 실패 2건 뒤 `warn`(DLQ 2, "원인을 고친 뒤 재처리") → 버그 뒤 `critical` → 재처리하고 알림을 확인(비움)한 뒤 다시 `ok`. 신호가 실제 상태를 따라갑니다.
|
||||||
|
- 마지막 `call`은 Lab 02 과제 2 테스트가 남긴 상태를 그대로 읽었습니다: DLQ 5건에 `dlq_down` critical 알림 → `critical`, 까닭 셋. 숫자 여럿을 읽지 않아도 무엇부터 할지 보입니다.
|
||||||
|
- 상태 웹훅은 0.1초 안팎: 데이터 API를 세 번 읽기만 하고 LLM을 부르지 않습니다.
|
||||||
|
|
||||||
|
## 막혔을 때
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| 재처리했는데 DLQ가 그대로 | 성공을 무엇으로 판단하나 (`my-intake` 결과의 `created`), 지울 때 `dlq_id` |
|
||||||
|
| 접수 건이 늘어남 (중복) | `my-intake`의 `key`가 요청 `id`인지 |
|
||||||
|
| DLQ가 비었을 때 응답이 없음 | 빈 결과에서도 다음으로 (`alwaysOutputData`), "할 일 없음" 갈래 |
|
||||||
|
| `attempts`가 안 오름 | DLQ에 넣을 때 `item.id`가 같은지 |
|
||||||
|
| 재처리 3에서도 다시 보냄 | `attempts >= max`인지 (`>`면 4번째에야 멈춤) |
|
||||||
|
| 상태가 늘 `critical` | 앞 테스트의 알림 · DLQ가 남았는지 (`ops --reset`), `within`이 초 단위인지 |
|
||||||
|
| `lab03-half`의 3번이 `count: 0` | 테스트 중에 데이터 API를 다시 켜면(재시작) 늦게 만들 요청이 사라짐 → 처음부터 다시 |
|
||||||
|
| 계속 안 됨 | `cp reference/lab03-example1-replay.json my-work/replay.json` 후 push (예제 1까지) |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 예제 1: `cases/lab03.json` 5/5 (중복 없음).
|
||||||
|
- [ ] 예제 2: 런북으로 짝 연습, `cases/lab03-drill.json` 7/7, 걸린 시간 · 빠진 단계.
|
||||||
|
- [ ] 예제 3: `cases/lab03-half.json` 6/6, `duplicates: 2`를 봤다.
|
||||||
|
- [ ] 과제 1: `cases/lab03-challenge.json` 7/7, `lab03`도 통과.
|
||||||
|
- [ ] 과제 2: `cases/lab03-challenge2.json` 7/7.
|
||||||
|
- [ ] `RUNBOOK.md`, `TEST-REPORT.md`, 끝나면 `ops --reset`.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# 76일차 수강 안내 · 수업 준비부터 마칠 때까지
|
||||||
|
|
||||||
|
- 일자: 2026-10-30 (금) · 76일차
|
||||||
|
- 주제: 에러 처리와 예외 대응, 안정적 운영 체계 (실패의 종류 · 모으고 알리기 · 재처리와 런북)
|
||||||
|
- 실습 폴더: [Last3Mon/76](README.md) · 강의 자료: [Lecture/76](../../Lecture/76/README.md)
|
||||||
|
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`, 분류) + **76일차 판 데이터 API**(75일차 + 접수 건 만들기 · DLQ · 알림 · 고장 스위치) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
|
||||||
|
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
P["전날<br/>준비 · base-intake ·<br/>nightly-report 게시"] --> M["아침<br/>켜기 · doctor"] --> L1["1교시 강의 · 2교시<br/>Lab 01 실습 5개<br/>나누기 · 재시도 · 키"] --> L2["3교시 강의 · 4교시<br/>Lab 02 실습 5개<br/>DLQ · Error Workflow · 알림"] --> L3["5교시 강의 · 6교시<br/>Lab 03 실습 5개<br/>재처리 · 런북 · parked · 상태"] --> V["7~8교시<br/>tretry"] --> E["마친 뒤<br/>ops --reset · 77 준비"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 0. 하루 한눈에
|
||||||
|
|
||||||
|
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 전날 | 약 10분 | 자료 받기 · n8n · 76일차 데이터 API · `base-intake` · `nightly-report` 게시 · doctor | [PREP](../../Lecture/76/00-DAY76-PREP.md) | `ready` |
|
||||||
|
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (실패의 종류) | [개요](../../Lecture/76/00-DAY76-OVERVIEW.md) 1~14장, [1단계](../../Lecture/76/01-STAGE1-FAILURE-TYPES.md) | |
|
||||||
|
| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-FAILURE-TYPES.md) | `lab01-challenge` 3/3 · `lab01-challenge2` 4/4 |
|
||||||
|
| 3 | 11:00~11:50 | 2단계 강의 (모으고 알리기) | 개요 15~21장, [2단계](../../Lecture/76/02-STAGE2-COLLECT-AND-ALERT.md) | |
|
||||||
|
| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-COLLECT-AND-ALERT.md) | `lab02-challenge` 6/6 · `lab02-challenge2` 6/6 |
|
||||||
|
| - | 13:00~14:00 | 점심 | | |
|
||||||
|
| 5 | 14:00~14:50 | 3단계 강의 (재처리와 운영 체계) | 개요 22~28장, [3단계](../../Lecture/76/03-STAGE3-REPLAY-AND-RUNBOOK.md) | |
|
||||||
|
| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-REPLAY-AND-RUNBOOK.md) | `lab03-challenge` 7/7 · `lab03-challenge2` 7/7, `RUNBOOK.md` |
|
||||||
|
| 7 | 16:00~16:50 | 바이브 코딩 `tretry`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TRETRY](VIBE-REQ-TRETRY.md) → [VIBE-Q-TRETRY](VIBE-Q-TRETRY.md) | `Doc/*.md` · R1~R6 · R8 · R11~R14 · `pytest` |
|
||||||
|
| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 77일차 예고 | [4단계 정리](../../Lecture/76/04-STAGE4-WRAPUP.md), [진행](../../Lecture/76/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 |
|
||||||
|
|
||||||
|
## 1. 전날 준비 (약 10분)
|
||||||
|
|
||||||
|
### 1.1 자료 받기
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/aio-01d4-stu && git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 n8n과 **76일차** 데이터 API 켜기 (Ollama는 끔)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/65/n8n
|
||||||
|
docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감)
|
||||||
|
docker compose start
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/76/data-api
|
||||||
|
docker compose up -d --force-recreate # 75일차 판을 76일차 판(+ 접수 건 만들기 · DLQ · 알림)으로 바꿈
|
||||||
|
sleep 3 # 데이터 API가 뜰 때까지
|
||||||
|
curl -s localhost:8800/dlq # {"size": 0, "parked": 0, "items": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 75일차 연습을 되돌리지 않았어도 괜찮습니다: 데이터 API를 새로 띄우면 예산 · 사용량 기록 · 고장 스위치가 처음으로 돌아갑니다.
|
||||||
|
|
||||||
|
### 1.3 기준 게시 · 준비 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/76
|
||||||
|
uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json
|
||||||
|
uv run tools/flow.py call base-intake requests/batch-ok.json # processed 3 이면 됨
|
||||||
|
uv run tools/flow.py ops --reset
|
||||||
|
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 미리 읽기 (선택, 5분)
|
||||||
|
|
||||||
|
[PREP 3절](../../Lecture/76/00-DAY76-PREP.md): 500을 받은 사용자는 무엇을 할까? 그때 서버에는 무엇이 남아 있을까? 그리고 7교시 [요청서](VIBE-REQ-TRETRY.md).
|
||||||
|
|
||||||
|
### 준비물
|
||||||
|
|
||||||
|
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저 (n8n 화면 Executions를 열어 봄), 짝 연습을 할 옆 사람.
|
||||||
|
|
||||||
|
## 2. 수업 당일 아침 (09:00 전)
|
||||||
|
|
||||||
|
1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (접수 건 · DLQ · 알림은 처음으로 돌아갑니다 — 괜찮음).
|
||||||
|
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/76`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`call`·`push`·`test`·`ops`·`fault`).
|
||||||
|
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`, `uv run tools/flow.py ops`.
|
||||||
|
4. `cp templates/RUNBOOK-TEMPLATE.md my-work/RUNBOOK.md`, `my-work/PROMPTS.md`.
|
||||||
|
|
||||||
|
## 3. 실습할 때마다 같은 순서 (오늘은 "망가뜨려 보기 → 고치기 → 다시 망가뜨려 확인")
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["① 망가뜨려 보기<br/>fault · llm down · test(전)"] --> B["② 요청문을<br/>메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test -v · ops"]
|
||||||
|
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
|
||||||
|
E -->|✔| G["⑦ RUNBOOK.md 한 줄"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 고장 스위치는 **내가** `flow.py fault`로 켜고 끕니다. 테스트 파일은 자기가 켠 고장을 끝에서 되돌립니다. 손으로 켰으면 끝나면 `uv run tools/flow.py fault tickets ok` (또는 `ops --reset`).
|
||||||
|
- `ops` 한 번으로 고장 스위치 · 접수 건 · DLQ · 알림이 보입니다. 테스트 사이마다 봅니다.
|
||||||
|
- `llm down`(Lab 01 과제 1)은 테스트가 끝에서 `llm up`으로 되돌립니다. 중간에 멈췄다면 **반드시** `uv run tools/flow.py llm up`.
|
||||||
|
|
||||||
|
## 4. 1~2교시 · 1단계 · 실패의 종류
|
||||||
|
|
||||||
|
1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-FAILURE-TYPES.md) (50분, 실습 5개).
|
||||||
|
|
||||||
|
| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 예제 1 (10분, 에이전트 없이) | 기준을 망가뜨려 보기: 빈 글 · `flaky` · 다시 보내기 | `base-intake` | `call base-intake requests/batch-*.json`, `ops`, `fault tickets flaky` · `ok` → `RUNBOOK.md` 1.1 |
|
||||||
|
| 예제 2 (15분) | 항목 확인 · 재시도 3 · 키 = 요청 id · 부분 성공 200 | `cp reference/baseline-intake.json my-work/intake.json` → 웹훅 `my-intake`로 | `test cases/lab01.json --webhook base-intake -v` (전), `push my-work/intake.json` → `test cases/lab01.json -v` |
|
||||||
|
| 예제 3 (7분, 에이전트 없이) | 고장 종류마다 재시도: `flaky` · `error` · `slow`, 늦게 생긴 접수 건 | (그대로) | `test cases/lab01-faults.json -v`, `ops` → `RUNBOOK.md` 1.2 |
|
||||||
|
| 과제 1 (8분) | LLM이 멈춰도 미분류 · 보통으로 접수 | `my-work/intake.json` 고치기 | `test cases/lab01-challenge.json -v` (자동으로 `llm down` · `up`), `lab01` |
|
||||||
|
| 과제 2 (10분) | 같은 id의 두 번째는 거절, 10건 넘으면 400 | `my-work/intake.json` 고치기 | `test cases/lab01-challenge2.json -v`, `lab01` · `lab01-challenge` |
|
||||||
|
|
||||||
|
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
|
||||||
|
|
||||||
|
## 5. 3~4교시 · 2단계 · 모으고 알리기
|
||||||
|
|
||||||
|
3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-COLLECT-AND-ALERT.md) (50분, 실습 5개).
|
||||||
|
|
||||||
|
| 순서 | 할 일 | 확인 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 예제 1 (12분) | 먼저 겪기: `fault tickets error` → `call my-intake` → `ok` → `ops` (DLQ 0). 그다음 실패한 요청은 원래 그대로 DLQ에 | `test cases/lab02.json -v` (전 · 후), `ops` |
|
||||||
|
| 예제 2 (12분) | 에러 처리기 `errHandler76001` (Error Trigger → critical 알림), 내 접수에 `errorWorkflow` | `push my-work/error-handler.json my-work/intake.json`, `test cases/lab02.json -v`, `ops`, `lab01` |
|
||||||
|
| 예제 3 (6분, 에이전트 없이) | 실패 넷이 어느 그물에: 빈 글 · API 오류 · 버그 · **DLQ도 멈춤** | `test cases/lab02-nets.json -v` (5/7이 맞음), `ops` → `RUNBOOK.md` 1.3 |
|
||||||
|
| 과제 1 (10분) | DLQ에 넣었으면 high 알림, 못 넣었으면 critical `dlq_down` + 잃을 뻔한 id, 결과에 `dlq_saved` · `alert` | `test cases/lab02-challenge.json -v`, `lab02-nets` 7/7, `lab02` |
|
||||||
|
| 과제 2 (10분) | 같은 이유의 high 알림은 10분에 한 번 (`suppressed`), critical은 늘 | `test cases/lab02-challenge2.json -v`, `lab02-challenge`, `lab01-challenge2`, `ops --reset` |
|
||||||
|
|
||||||
|
## 6. 5~6교시 · 3단계 · 재처리와 운영 체계
|
||||||
|
|
||||||
|
5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-REPLAY-AND-RUNBOOK.md) (50분, 실습 5개).
|
||||||
|
|
||||||
|
| 순서 | 할 일 | 확인 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 예제 1 (12분) | 재처리 `my-replay`: DLQ의 요청을 `my-intake`로 하나씩, 성공하면 지우기 | `test cases/lab03.json -v` (전 · 후), `ops` |
|
||||||
|
| 예제 2 (12분, 에이전트 없이) | `RUNBOOK.md` 2절을 쓰고 짝 연습: 한 사람이 장애를 내고 다른 사람이 런북만 보고 대응 | `fault tickets error` · `call my-intake` · `call nightly-report` → `ops` → `fault tickets ok` → `call my-replay` → `ops`, `test cases/lab03-drill.json -v` → `RUNBOOK.md` 3절 |
|
||||||
|
| 예제 3 (6분, 에이전트 없이) | 반쯤 처리된 요청: `slow`로 실패 → 늦게 생긴 접수 건 → 재처리는 `duplicates` | `test cases/lab03-half.json -v`, `ops` |
|
||||||
|
| 과제 1 (10분) | `attempts` 3 이상이면 다시 보내지 않고 parked + high 알림 | `test cases/lab03-challenge.json -v`, `lab03` |
|
||||||
|
| 과제 2 (10분) | 운영 상태 `my-ops-status`: ok · warn · critical과 까닭 (읽기만) | `test cases/lab03-challenge2.json -v`, `lab03-challenge`, `call my-ops-status`, `ops --reset` |
|
||||||
|
|
||||||
|
- 기록: `RUNBOOK.md` 전체 (4절 누가 · 언제까지), `TEST-REPORT.md`.
|
||||||
|
|
||||||
|
## 7. 7~8교시 · 바이브 코딩 `tretry`
|
||||||
|
|
||||||
|
[VIBE-REQ-TRETRY](VIBE-REQ-TRETRY.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TRETRY](VIBE-Q-TRETRY.md)의 순서대로 갑니다: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용. 요구는 14개(R1~R14)이고 50분 안에는 엔진 1 · 2단계 · TUI 1단계 · 설치까지, 나머지(R7 마감 · R9 순위 · R10 회로 차단)는 `Doc/IMP-PLAN.md`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 아무 명령이나 HTTP 호출을 감싸 다시 하기(backoff · 상한 · 흔들기) · 시간 제한 · 대체를 붙이고, 고칠 수 없는 실패는 다시 하지 않고, 같은 멱등 키를 붙이고, 기록 · 순위를 TUI로 보는 도구. 정답본 `VIBE-A-TRETRY.md`는 수업이 끝나면 `git pull`로 받습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/vibe/tretry/Doc && cd ~/vibe/tretry
|
||||||
|
git init
|
||||||
|
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 시간 | 단계 | 확인 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 16:00~16:09 | 0 준비 · 1 요청서 → `Doc/TODO.md` (에이전트 없이) | 할 일 ①~⑫ · 꼭 / 나중 |
|
||||||
|
| 16:09~16:15 | 2 `Doc/SPEC.md` → VIBE-Q 요구 표(R1~R14)와 비교 · commit | 요구마다 확인 방법 (시도 수 · 기다린 시간 · 종료 코드) |
|
||||||
|
| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~3단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 |
|
||||||
|
| 16:20~16:44 | 4 구현: 엔진 1단계(R1~R4 · R12 · R13) → 엔진 2단계(R5 · R6 · R8) → TUI 1단계(R11), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit |
|
||||||
|
| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tretry` | 데이터 API `flaky`에서 `tretry http POST localhost:8800/tickets ... --idem-key x --body-key key` |
|
||||||
|
|
||||||
|
- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/76/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 77일차 예고.
|
||||||
|
|
||||||
|
## 8. 마친 뒤
|
||||||
|
|
||||||
|
| 할 일 | 이유 |
|
||||||
|
| --- | --- |
|
||||||
|
| `uv run tools/flow.py ops --reset`, `llm up`(혹시 모르니), `doctor` → `ready` | 내일 처음 상태로 |
|
||||||
|
| `PROMPTS.md`, `RUNBOOK.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (장애 대응 절차) |
|
||||||
|
| `my-work/*.json`, `~/vibe/tretry` 지우지 않기 | 77일차 로그 분석 · 79일차 `tops` |
|
||||||
|
| 77일차 준비: [00-DAY77-PREP](../../Lecture/77/00-DAY77-PREP.md) | 실행 기록 · 로그 분석 |
|
||||||
|
|
||||||
|
## 9. 막혔을 때 (오늘 공통)
|
||||||
|
|
||||||
|
| 증상 | 할 일 |
|
||||||
|
| --- | --- |
|
||||||
|
| doctor의 `dead letter queue /dlq · alerts · fault switch dlq` ✘ | 데이터 API가 예전 판. 1.2의 76일차 `--force-recreate` · `sleep 3` |
|
||||||
|
| doctor에 `fault switch is on` · `DLQ is not empty` | `uv run tools/flow.py ops --reset` |
|
||||||
|
| `check`가 `retryOnFail ... must be at the node level` | 재시도 설정을 `parameters` 밖, 노드 바로 아래로 |
|
||||||
|
| 테스트 뒤에도 분류가 모두 실패 | `uv run tools/flow.py llm up` |
|
||||||
|
| 알림이 하나도 안 생김 | 에러 처리기를 push(게시)했는지, 파일의 `"id": "errHandler76001"` |
|
||||||
|
| 늘 `suppressed` · 늘 `critical` | 앞 테스트의 알림이 남음 → `ops --reset` |
|
||||||
|
| `call my-intake`가 404 | 복사한 파일의 웹훅 경로를 바꿨는지, push했는지 |
|
||||||
|
| `slow` 테스트가 오래 걸림 | 정상 (한 케이스 10~12초) |
|
||||||
|
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] 전날 76일차 데이터 API, `base-intake` · `nightly-report` 게시, `doctor` → `ready`
|
||||||
|
- [ ] Lab 01: `RUNBOOK.md` 1.1 · 1.2, `lab01` 6/6, `lab01-faults` 5/5, `lab01-challenge` 3/3, `lab01-challenge2` 4/4
|
||||||
|
- [ ] Lab 02: `lab02` 4/4, `lab02-nets` 5/7 → 7/7, `lab02-challenge` 6/6, `lab02-challenge2` 6/6, `RUNBOOK.md` 1.3 · 1.4
|
||||||
|
- [ ] Lab 03: `lab03` 5/5, 짝 연습 · `lab03-drill` 7/7, `lab03-half` 6/6, `lab03-challenge` 7/7, `lab03-challenge2` 7/7, `RUNBOOK.md` 2~4절
|
||||||
|
- [ ] `tretry`: `Doc/TODO.md` · `SPEC.md` · `IMP-PLAN.md` 먼저, 엔진 1 · 2단계 · TUI 1단계, `pytest`, 단계마다 commit, `uv tool install`
|
||||||
|
- [ ] `ops --reset` · `llm up`, 77일차 준비
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 76일차 실습 · 에러 처리와 예외 대응, 안정적 운영 체계 (바이브 코딩)
|
||||||
|
|
||||||
|
- 일자: 2026-10-30 (76일차)
|
||||||
|
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||||
|
- 단원: AI 워크플로우 운영 및 관리 역량 강화
|
||||||
|
- 강의 자료: [Lecture/76](../../Lecture/76/README.md)
|
||||||
|
- 하루 수강 안내: [LECTURE-76.md](LECTURE-76.md) (수업 준비부터 마칠 때까지 순서대로)
|
||||||
|
- 67~75일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 실패를 **나누고 · 모으고 · 알리고 · 다시 돌리는** 체계를 만듭니다.
|
||||||
|
|
||||||
|
## 오늘 고치는 것: 실패하면 잃고, 다시 보내면 겹치는 문의 일괄 접수
|
||||||
|
|
||||||
|
`reference/baseline-intake.json`(웹훅 `base-intake`)은 여러 문의(`requests[]`)를 받아 하나씩 LLM으로 분류하고 접수 건을 만듭니다. 수업 준비 때 빈 글이 섞인 3건을 보내자 **HTTP 500인데 접수 건 2개가 이미 생겼고**, 같은 3건을 다시 보내자 **같은 문의의 접수 건이 둘**이 되었습니다. 이것을 실패해도 잃지 않고, 다시 해도 겹치지 않고, 사람이 바로 알게 바꿉니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Q(["requests[]<br/>10건까지"]) --> V{"항목 확인<br/>빠진 칸 · 같은 id"}
|
||||||
|
V -->|잘못됨| RJ["rejected<br/>(이유)"]
|
||||||
|
V --> C{{"LLM 분류<br/>실패면 미분류"}} --> T["접수 건 만들기<br/>key = 요청 id · 재시도 3"]
|
||||||
|
T -->|성공| OK["created"]
|
||||||
|
T -->|실패| DLQ[("DLQ")] --> AL["알림 high<br/>(10분에 한 번)"]
|
||||||
|
DLQ -.->|DLQ도 멈춤| CR["알림 critical<br/>잃을 뻔한 id"]
|
||||||
|
RJ & OK & DLQ --> R(["200 부분 성공"])
|
||||||
|
X["처리 못 한 오류"] -.-> EH["Error Workflow<br/>알림 critical"]
|
||||||
|
RP["재처리 my-replay<br/>3번 실패면 parked"] -.->|고친 뒤| DLQ
|
||||||
|
ST["my-ops-status<br/>ok · warn · critical"] -.-> DLQ
|
||||||
|
```
|
||||||
|
|
||||||
|
## 일하는 방식 (67~75일차와 같음)
|
||||||
|
|
||||||
|
| 누가 | 무엇을 |
|
||||||
|
| --- | --- |
|
||||||
|
| 나 | 실패를 겪어 보고 나누기(거절 · 재시도 · DLQ · 알림), 요청문, 고장 연습, 대응 절차(런북) 쓰기 · 짝 연습 |
|
||||||
|
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
|
||||||
|
| `tools/flow.py` | check(재시도 설정 자리 · `errorWorkflow` id) · push · call · test(`{"sleep": 3}`) · **ops**(접수 건 · DLQ · 알림 · 고장 스위치, `--reset`) · **fault tickets\|dlq flaky\|slow\|error\|ok** · llm · doctor (모두 `-v`) |
|
||||||
|
|
||||||
|
> 실패를 **숨기지** 않습니다. 실패한 항목은 결과에 그대로, 그리고 다시 돌릴 수 있는 곳(DLQ)에. 알림을 줄여도 실패의 기록은 줄이지 않습니다.
|
||||||
|
|
||||||
|
## 실습 15개 (Lab마다 예제 3 · 과제 2)
|
||||||
|
|
||||||
|
| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| [Lab 01](LAB-01-Q-FAILURE-TYPES.md) | 기준을 망가뜨려 보기 (에이전트 없이) | 항목 확인 · 재시도 · 멱등 키 | 고장 종류마다 재시도가 하는 일 (에이전트 없이) | LLM이 멈춰도 미분류로 접수 | 요청 안의 규칙 (같은 id · 10건) |
|
||||||
|
| [Lab 02](LAB-02-Q-COLLECT-AND-ALERT.md) | 실패한 요청은 DLQ | Error Workflow | 어느 실패가 어느 그물에 (에이전트 없이) | 처리한 실패도 알림 · DLQ가 멈추면 | 알림 폭주 막기 |
|
||||||
|
| [Lab 03](LAB-03-Q-REPLAY-AND-RUNBOOK.md) | 재처리 | 런북 · 장애 연습 (에이전트 없이) | 반쯤 처리된 요청 (에이전트 없이) | 세 번 실패하면 멈춤 (parked) | 운영 상태 한눈에 |
|
||||||
|
|
||||||
|
## 실습 문서 읽는 법
|
||||||
|
|
||||||
|
- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TRETRY.md`)에는 **복사해 붙일 요청문이 없습니다.** 실습마다 `### 이해하기`(왜 · 지금 → 바뀐 뒤 · 테스트 케이스 · 기대 결과)를 읽고, 막히면 `▶` 를 하나씩 펼칩니다. 요청문은 `▶ 빈칸 채우기`의 빈칸 4개(`____①`~`____④`)를 채워서 씁니다. 실습마다 끝에 `### 실행 결과`(수업 준비 때 고치기 전 · 후 출력과 그렇게 나온 까닭)가 있습니다.
|
||||||
|
- 쓴 요청문은 `my-work/PROMPTS.md`에, 실패 지도와 대응 절차는 `my-work/RUNBOOK.md`에, 테스트 결과는 `my-work/TEST-REPORT.md`에 모아 둡니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
```text
|
||||||
|
Last3Mon/76/
|
||||||
|
├── README.md
|
||||||
|
├── LECTURE-76.md # 하루 수강 안내
|
||||||
|
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 항목 실패 · 재시도 · 멱등 키 · DLQ · Error Workflow 노드 5절)
|
||||||
|
├── LAB-01-Q-FAILURE-TYPES.md # 1단계 · 망가뜨려 보기 · 항목 확인 · 재시도 · 멱등 키 · 고장 종류 · LLM 멈춤 · 요청 규칙
|
||||||
|
├── LAB-02-Q-COLLECT-AND-ALERT.md # 2단계 · DLQ · Error Workflow · 그물 지도 · 처리한 실패 알림 · 알림 폭주
|
||||||
|
├── LAB-03-Q-REPLAY-AND-RUNBOOK.md # 3단계 · 재처리 · 런북 · 장애 연습 · 반쯤 처리된 요청 · parked · 운영 상태
|
||||||
|
├── VIBE-REQ-TRETRY.md # 7교시 바이브 코딩 요청서 (가장 먼저 읽기)
|
||||||
|
├── VIBE-Q-TRETRY.md # 7교시 바이브 코딩 · tretry (명령 · HTTP 재시도 · 대체 · 마감 · 회로 차단 TUI)
|
||||||
|
├── tools/flow.py
|
||||||
|
├── data-api/ # 76일차 판 데이터 API (75일차 + 접수 건 만들기 · DLQ · 알림 · 고장 스위치 dlq)
|
||||||
|
├── cases/ # lab01 · lab01-faults · lab01-challenge(2) · lab02 · lab02-nets · lab02-challenge(2) · lab03 · lab03-drill · lab03-half · lab03-challenge(2)
|
||||||
|
├── requests/ # batch-ok · batch-bad-item · batch-two · batch-dup-id · batch-eleven · not-a-list
|
||||||
|
├── reference/ # baseline-intake · nightly-report(버그) + 막혔을 때 이어 가는 워크플로우
|
||||||
|
├── templates/ # 런북 · 테스트 보고서
|
||||||
|
├── my-work/
|
||||||
|
└── runs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 준비
|
||||||
|
|
||||||
|
[실습 준비](../../Lecture/76/00-DAY76-PREP.md)를 끝내 주세요. 요약:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/76/data-api && docker compose up -d --force-recreate && sleep 3 # 76일차 판
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/76 && uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json
|
||||||
|
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||||
|
```
|
||||||
|
|
||||||
|
## `flow.py` 명령 (오늘 더해진 것)
|
||||||
|
|
||||||
|
| 명령 | 하는 일 | 수업 준비 때 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `uv run tools/flow.py call base-intake requests/batch-bad-item.json` | 기준이 잘못된 항목 하나에 어떻게 되나 | HTTP 500, 그런데 나머지 2건은 이미 접수됨 |
|
||||||
|
| `uv run tools/flow.py ops` · `ops --reset` | 만든 접수 건 · DLQ(parked) · 알림 · 고장 스위치 보기 · 모두 비우고 스위치 `ok` | |
|
||||||
|
| `uv run tools/flow.py fault tickets flaky` · `slow` · `error` · `ok` | 접수 건 만들기를 키마다 첫 시도만 실패 · 8초 늦게(그래도 만듦) · 늘 실패 · 되돌리기 | `slow`에서 내 접수 12.4초 · 늦게 생긴 3건 |
|
||||||
|
| `uv run tools/flow.py fault dlq error` · `ok` | DLQ 저장을 실패로 (Lab 02 예제 3 · 과제 1) | |
|
||||||
|
| `uv run tools/flow.py llm down` · `llm up` | LLM 멈춤 연습 (72일차와 같음) | |
|
||||||
|
| `uv run tools/flow.py check my-work/intake.json` | 재시도 설정을 `parameters` 안에 쓰면 · `errorWorkflow` id가 이상하면 오류 | |
|
||||||
|
|
||||||
|
> 오늘 LLM은 분류에만 씁니다 (`gpt-4.1-mini`, 문의 하나에 한 번). `slow` 케이스는 한 번에 10초 넘게 걸리니 같은 테스트를 여러 번 반복하지 않습니다.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 7교시 바이브 코딩 · `tretry` 정답본
|
||||||
|
|
||||||
|
> **강사용 정답본** · 학생 저장소에는 수업 뒤에 올립니다. 학생용: [VIBE-REQ-TRETRY.md](VIBE-REQ-TRETRY.md) → [VIBE-Q-TRETRY.md](VIBE-Q-TRETRY.md).
|
||||||
|
> 강사가 직접 만든 본: [tretry/](tretry/README.md) (테스트 17개, `uv tool install --editable .`). 아래 숫자는 2026-10-29 강사 Mac에서 76일차 데이터 API에 실제로 돌린 값입니다. 68일차부터 바이브 코딩은 CLI 에이전트로 다시 만들어 재지 않습니다 (요청문은 예시).
|
||||||
|
|
||||||
|
## 1. TODO 예 (요청의 말 → 할 일)
|
||||||
|
|
||||||
|
| # | 요청의 말 | 할 일 (내 말) | 꼭 · 나중 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ① | 종료 코드 ≠ 0이면 다시 | `--tries N`까지 다시, 결과에 시도 수 | 꼭 |
|
||||||
|
| ② | 점점 늘려 · 상한 · 한꺼번에 X | 지수 backoff · `--max-wait` · `--jitter`(줄이기만) | 꼭 |
|
||||||
|
| ③ | 멈추면 끊고 다시 | 시도마다 `--timeout`, 끊긴 것도 다시 | 꼭 |
|
||||||
|
| ④ | 대신 할 명령 · 알려 주기 | `--fallback`, "by the fallback" · 기록에 `fallback` | 꼭 |
|
||||||
|
| ⑤ | 고칠 수 없는 실패는 바로 멈춤 · 잠깐 뒤에 | 4xx(408 · 425 · 429 빼고) 1번 · 종료 코드 3 · 대체 안 함, `Retry-After` | 꼭 |
|
||||||
|
| ⑥ | 두 번 만들어지면 안 됨 | 실행에 한 번 만든 키를 모든 시도에 (헤더 · 본문) | 꼭 |
|
||||||
|
| ⑦ | 몇 초 안에 | `--deadline`, 끝낼 수 없는 시도는 시작 안 함 | 나중 |
|
||||||
|
| ⑧ | 시도마다 파일에 · 키 · 토큰 X | JSON Lines, `***` | 꼭 |
|
||||||
|
| ⑨ | 어느 대상이 자주 실패 | `stats`: 성공률 · 실행당 시도 · p95 | 나중 |
|
||||||
|
| ⑩ | 계속 실패한 대상은 바로 대체 | 회로 차단 (최근 60초 5번) | 나중 |
|
||||||
|
| ⑪ | 화면 | `tretry log` 두 표 · 실패만 | 꼭 |
|
||||||
|
| ⑫ | 종료 코드 · 다른 프로그램 | 0 · 1 · 2 · 3, `--json` | 꼭 |
|
||||||
|
| 끝 | 시험에서 기다리지 않기 | sleep · clock · 무작위를 인자로, 가짜 서버 | 꼭 |
|
||||||
|
|
||||||
|
## 2. 요청문 (처음 한 번 + 다음 단계 한 줄)
|
||||||
|
|
||||||
|
단계마다 긴 요청문을 쓰지 않습니다. 할 일은 이미 `Doc/SPEC.md` · `Doc/IMP-PLAN.md`에 있으니, 에이전트가 그 파일을 읽게 하면 됩니다.
|
||||||
|
|
||||||
|
### 처음 한 번
|
||||||
|
|
||||||
|
```text
|
||||||
|
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 체크 안 된 첫 단계만 만들어 줘. uv 프로젝트, 명령 이름 tretry, 코드와 화면 글자는 영어.
|
||||||
|
끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 그다음 단계마다 (같은 한 줄)
|
||||||
|
|
||||||
|
```text
|
||||||
|
확인했고 commit 했어. 다음 단계.
|
||||||
|
```
|
||||||
|
|
||||||
|
- 그냥 "계속"만 치면 단계 두세 개를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 이 한 줄은 처음 요청의 약속(한 단계만 · 체크 · 확인 명령 · 멈춤)을 이어 가게 합니다.
|
||||||
|
- 대화가 길어지면 새 세션에서 **처음 요청**을 다시 보냅니다. "체크 안 된 첫 단계"부터 이어집니다.
|
||||||
|
- 에이전트가 단계 내용을 잘못 알았다면, 요청문을 길게 쓰기보다 **SPEC · IMP-PLAN을 고치고** 다시 시킵니다.
|
||||||
|
|
||||||
|
### 틀렸을 때만 · 고쳐 달라는 요청문 예
|
||||||
|
|
||||||
|
무엇이 틀렸나(보인 것) · SPEC의 어느 요구인가 · 어디만 고치나 · 무엇으로 확인하나, 넷을 적습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
tretry http POST 가 400 을 받아도 3번 다시 하고 대체 명령까지 돌려. SPEC R5 대로 408 · 425 · 429 를 뺀 4xx 는 1번에 멈추고 종료 코드 3, 대체도 안 해야 해.
|
||||||
|
runners.http 의 retryable 판단만 고치고, 가짜 서버 400 → 시도 1 · 종료 코드 3 · 대체 출력 없음 테스트를 넣어 줘. 다른 파일은 건드리지 마.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 확인 기준과 강사 실측 (10/29, 76일차 데이터 API)
|
||||||
|
|
||||||
|
| 요구 | 기준 | 실측 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 · R2 | `--tries 3 --backoff 0.2 -- false` | 시도 3 · 기다림 0.20 → 0.40 · 0.65초 · 종료 코드 1 |
|
||||||
|
| R3 | `--tries 1 --timeout 0.3 -- sleep 5` | `timeout 0.3s` · 0.31초 · 1 |
|
||||||
|
| R4 | 대체 | (회로 차단 예에서) `queued-locally` · `ok by the fallback` · 0 |
|
||||||
|
| R5 | `flaky` · 400 | `flaky`: #1 HTTP 500 → #2 0.33초 성공 (`T-2001`). 본문 `{}`: HTTP 400 `key and title are required` · 1번 · `(not-retryable)` · **종료 코드 3**, `--fallback`을 줬어도 대체 안 함 |
|
||||||
|
| R6 | 같은 키로 다시 | `{"ticket_id": "T-2001", "duplicate": true}` (1번, 0.01초) |
|
||||||
|
| R7 | `slow` · `--timeout 3 --deadline 5` | #1 `timeout 3.0s` → 0.5초 기다림 → #2 `timeout 1.5s` (남은 시간으로 줄임) · **5.00초**에 `(deadline)` · 1. Lab 01 예제 3의 12.4초와 비교 |
|
||||||
|
| R8 | 기록 | 14줄, 키 없음 |
|
||||||
|
| R9 | `tretry stats` | `POST http://localhost:8800/tickets` 6 runs · 50% · 1.67 tries/run · failed 7 · fallback 1 · p95 5.00 |
|
||||||
|
| R10 | `error`에서 실패가 이어진 뒤 | 앞선 실패(400 · 마감 2 · error 3)로 최근 5개가 모두 실패 → 다음 실행부터 `0 tries · breaker open`, 대체가 있으면 바로 대체 |
|
||||||
|
| R12 | `tretry` (인자 없음) | `put the command after --` · 종료 코드 2 |
|
||||||
|
| R13 | `uv run pytest -q` | 17 passed in 3.3s |
|
||||||
|
| R14 | `/tmp`에서 `tretry --help` | `usage: tretry [options] -- <command ...>` |
|
||||||
|
|
||||||
|
- R10에서 볼 것: 차단은 **대상**(`METHOD URL`) 단위라 본문이 다른 요청(400을 받은 것 포함)도 같은 대상으로 셉니다. 실무에서는 대상을 더 잘게(경로 · 키) 나누거나 4xx를 세지 않기도 합니다. 8교시에 "무엇을 같은 대상으로 볼까"를 질문으로 씁니다.
|
||||||
|
|
||||||
|
## 4. 7교시에 볼 것
|
||||||
|
|
||||||
|
| 볼 것 | 막혔을 때 한마디 |
|
||||||
|
| --- | --- |
|
||||||
|
| 400도 다시 함 | "고칠 수 없는 실패는 1번, 종료 코드 3, 대체도 하지 않기 (오늘 Lab 01)" |
|
||||||
|
| 테스트가 몇 초씩 | "엔진이 sleep · clock을 인자로" |
|
||||||
|
| 간격이 늘 같음 | "지수 backoff와 상한" |
|
||||||
|
| 다시 보낸 POST로 접수 건이 둘 | "키는 실행에 한 번, 본문 key에도" |
|
||||||
|
| `--timeout`이 HTTP에 안 먹음 | "urlopen(timeout=...)" |
|
||||||
|
| `--` 뒤 옵션을 tretry가 먹음 | "`--` 앞만 tretry 옵션" |
|
||||||
|
| 기록에 토큰 | "헤더 값은 남기지 않기, URL · 오류도 지우기" |
|
||||||
|
| 대체가 늘 성공해 차단이 안 풀림 | "대체의 성공은 대상의 성공이 아님" |
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# 7교시 바이브 코딩 · `tretry` 만들기
|
||||||
|
|
||||||
|
> **먼저 [VIBE-REQ-TRETRY.md](VIBE-REQ-TRETRY.md)(요청서)를 읽고 `~/vibe/tretry/Doc/TODO.md`를 직접 쓴 뒤** 이 문서를 엽니다. 아래 1~8을 차례로 갑니다. 이 문서의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
T["1. Doc/TODO.md<br/>할 일 정리"] --> S["2. Doc/SPEC.md<br/>확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> I["4. 구현<br/>단계마다 요청 · 확인"]
|
||||||
|
I --> H["5. 막히면<br/>문제 해결 팁"] --> I
|
||||||
|
I --> C["6. 결과 확인<br/>요구 표 체크"] --> U["7. uv tool install"] --> O["8. 다른 곳에서 사용"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시 · 50분 안에 못 한 단계는 집에서 `Doc/IMP-PLAN.md`의 다음 단계로 이어 갑니다
|
||||||
|
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), 표준 라이브러리 `subprocess` · `urllib.request`
|
||||||
|
- 결과물: 아무 **명령이나 HTTP 호출을 감싸** 실패하면 **간격을 늘려 가며 다시** 하고(상한 · 흔들기), 한 시도는 **시간 제한**으로 끊고, 전체 **마감 시간** 안에 끝내고, 다 실패하면 **대신 할 명령**을 돌리는 도구. 고칠 수 없는 실패(HTTP 4xx)는 다시 하지 않고, 다시 보낼 때는 **같은 멱등 키**를 붙입니다. 시도마다 기록을 남기고(키 · 토큰은 지움), 기록으로 순위와 **바로 대체(회로 차단)**를 정하고, TUI로 봅니다. `uv tool install`로 설치해 **어느 폴더에서나** `tretry`. 오늘 n8n 노드 설정(`retryOnFail` · `onError` · 대체 경로)으로 한 일을 **n8n 밖 어떤 명령에든.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ tretry --tries 4 --backoff 0.5 --timeout 2 --fallback "cat ~/cache/faq.json" -- curl -sf localhost:8800/faq?q=vpn
|
||||||
|
#1 0.00s ✘ exit 22 · 0.02s
|
||||||
|
#2 0.52s ✘ timeout 2.0s · 2.00s (waited 0.50s)
|
||||||
|
#3 3.53s ✔ ok · 0.03s (waited 1.00s)
|
||||||
|
result: ok · 3 tries · 3.56s
|
||||||
|
|
||||||
|
$ tretry log
|
||||||
|
┌ tretry · ~/.tretry/log.jsonl ──────────────────────────────────────────────────┐
|
||||||
|
│ target runs ok% tries/run failed │ run try at +s result error │
|
||||||
|
│ POST http://localhost:8800/tickets 4 50% 2.75 7 │ 3 1 0.00 ✘ HTTP 500 │
|
||||||
|
│ curl -sf localhost:8800/faq?q=vpn 2 100% 2.00 2 │ 3 2 0.31 ✔ │
|
||||||
|
│ 6 run(s) · 15 tr(ies) · [enter] show tries · [f] failed only · [r] reload │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
|
||||||
|
>
|
||||||
|
> **테스트에서 진짜로 기다리지 않습니다.** 기다리는 일(`sleep`)과 시계(`clock`)를 바꿔 끼울 수 있게 만들고, HTTP는 테스트 안의 작은 가짜 서버로 시험합니다.
|
||||||
|
|
||||||
|
## 시간 (50분)
|
||||||
|
|
||||||
|
| 시간 | 단계 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 16:00~16:09 | 0 준비 · 1 `Doc/TODO.md` (요청서 읽고 직접) | 할 일 ①~⑫ · 꼭 / 나중 |
|
||||||
|
| 16:09~16:15 | 2 `Doc/SPEC.md` → 아래 요구 표와 비교 · 고치기 | 요구마다 확인 방법 |
|
||||||
|
| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 단계 · TUI 단계) | 단계마다 체크리스트 |
|
||||||
|
| 16:20~16:44 | 4 구현: **엔진 1단계 → 엔진 2단계 → TUI 1단계** (막히면 5) | 단계마다 `pytest` · 직접 실행 · commit |
|
||||||
|
| 16:44~16:50 | 6 확인 · 7 `uv tool install` · 8 다른 폴더에서 | `tretry http POST localhost:8800/tickets ...` (데이터 API `flaky`) |
|
||||||
|
|
||||||
|
50분 안 목표: **R1 · R2 · R3 · R4 · R12 · R13(엔진 1단계) + R5 · R6 · R8(엔진 2단계) + R11(TUI 1단계) + R14(설치)**. R7 · R9 · R10은 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다.
|
||||||
|
|
||||||
|
## 0. 준비 (1분)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/vibe/tretry/Doc && cd ~/vibe/tretry
|
||||||
|
git init
|
||||||
|
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이)
|
||||||
|
|
||||||
|
[VIBE-REQ-TRETRY.md](VIBE-REQ-TRETRY.md)의 "할 일" 틀대로 씁니다. 요청의 말 ①~⑫를 **내 말로** 옮기고, 꼭 · 나중을 표시하고, 걱정되는 것과 50분 안에 못 할 것을 적습니다. "어떻게"는 아직 쓰지 않습니다.
|
||||||
|
|
||||||
|
<details><summary>▶ TODO와 SPEC은 무엇이 다른가</summary>
|
||||||
|
|
||||||
|
TODO는 **내가 이해한 것**(무엇을 · 왜)입니다. SPEC은 그것을 **확인할 수 있게** 바꾼 것(됐다 · 안 됐다를 말하는 방법)입니다. TODO에서 빠진 것은 SPEC에서도 빠지므로, TODO는 빠짐없이, SPEC은 정확하게.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
`git add Doc/TODO.md && git commit -m "TODO"`
|
||||||
|
|
||||||
|
## 2. `Doc/SPEC.md` · 확인할 수 있는 요구로 (6분)
|
||||||
|
|
||||||
|
TODO의 할 일마다 **확인 방법**(시도 수 · 기다린 시간 · 종료 코드 · 기록에 남은 것)을 붙여 SPEC을 만듭니다. 에이전트에게 "TODO.md를 읽고 SPEC.md 초안을 써 줘. 요구마다 확인 방법을 붙여"라고 시켜도 됩니다. 그러면 **반드시 내가 읽고 고칩니다.**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# tretry SPEC
|
||||||
|
|
||||||
|
## 1. 요구 (확인할 수 있게)
|
||||||
|
| 번호 | 요구 | 요청의 어느 말 | 확인 방법 | 꼭 · 나중 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| R1 | | ① | | 꼭 |
|
||||||
|
|
||||||
|
## 2. 숨은 요구
|
||||||
|
## 3. 범위 밖 (하지 않을 것)
|
||||||
|
## 4. 정해 둘 것 (기본 횟수 · 간격 · 상한, 다시 할 HTTP 상태, 종료 코드 번호, 기록 파일 위치 · 모양, 회로 차단 기준)
|
||||||
|
```
|
||||||
|
|
||||||
|
<details><summary>▶ 확인 방법은 손으로 셀 수 있는 숫자로</summary>
|
||||||
|
|
||||||
|
"간격을 늘린다"는 확인 방법이 아닙니다. "두 번 실패 후 성공하는 가짜 시도, `--tries 4 --backoff 0.5` → 시도 3번, 기다린 시간 [0.5, 1.0]"이 확인 방법입니다. 오늘 Lab 01 예제 3처럼 고장 종류마다 **나와야 할 것과 나오면 안 되는 것**을 둘 다 적습니다 (400 → 시도 1번 · 대체 명령 **안 함**).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다.
|
||||||
|
|
||||||
|
### 요구 사항 (요청서에서 뽑은 것, 14개)
|
||||||
|
|
||||||
|
| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| R1 | `tretry [--tries N] -- <명령>`: 종료 코드 ≠ 0이면 N번까지 다시 | ① | `--tries 3 --backoff 0 -- false` → 시도 3번 · 종료 코드 1 | 꼭 |
|
||||||
|
| R2 | 기다림 = `backoff × 2^(i-1)`, `--max-wait` 상한, `--jitter`는 기다림을 **줄이기만** (무작위를 바꿔 끼울 수 있게) | ② | 가짜 sleep: 0.5 → 1.0, `backoff 1 · max 4` → 1 · 2 · 4 · 4, jitter 0.5 · 무작위 1.0 → 절반 | 꼭 |
|
||||||
|
| R3 | `--timeout S`: 한 시도를 S초에 끊고, 끊긴 것도 다시 할 실패로 | ③ | `--tries 1 --timeout 0.3 -- sleep 5` → `timeout 0.3s` · 종료 코드 1 · 0.3초쯤에 끝 | 꼭 |
|
||||||
|
| R4 | `--fallback "<명령>"`: 다 실패하면 한 번 실행해 그 출력을 쓰고, 대체로 됐다고 알림 | ④ | `-- false` + `--fallback "echo cached"` → 출력 `cached` · 종료 코드 0 · "by the fallback" | 꼭 |
|
||||||
|
| R5 | `tretry http <METHOD> <url> [--body] [--header]`: 2xx 성공 · 5xx · 408 · 425 · 429 · timeout · 연결 실패는 다시 · 그 밖의 4xx는 **1번에 멈추고 대체도 안 함**, `Retry-After`를 따름 (상한까지) | ⑤ | 가짜 서버 500 → 201: 시도 2번, 400: 시도 1번 · 종료 코드 3 · 대체 출력 없음, 429 + `Retry-After: 3` → 기다림 3 | 꼭 |
|
||||||
|
| R6 | `--idem-key auto\|<값>`: 모든 시도에 **같은** 키를 헤더 `Idempotency-Key`로, `--body-key key`면 본문 필드에도 | ⑥ | 가짜 서버가 본 키가 시도 3번 모두 하나 · 본문 `key`와 같음. 데이터 API `flaky`에서 201 → 같은 키로 다시 200 `duplicate: true` | 꼭 |
|
||||||
|
| R7 | `--deadline S`: 전체 S초. 끝낼 수 없는 기다림 · 시도는 시작하지 않고, 마지막 시도의 시간 제한은 남은 시간으로 줄임 | ⑦ | 가짜: 시도마다 3초 · `backoff 1 · deadline 7` → 시도 2번 · 멈춘 까닭 `deadline` | 나중 |
|
||||||
|
| R8 | 시도마다 JSON Lines 한 줄 `{at, kind, target, attempt, started, seconds, ok, error, status, waited, fallback, final, result}`, 키 · 토큰(`sk-…`, `Bearer …`, `api_key=…`)은 `***` | ⑧ | URL `?api_key=secret123`, 헤더 `Authorization: Bearer sk-…` → 기록에 `secret123` · `sk-` 없음 | 꼭 |
|
||||||
|
| R9 | `tretry stats [--json]`: 대상마다 실행 수 · 성공률 · 실행당 시도 · 실패한 시도 · 대체 · p95 시간, 실패 많은 순 | ⑨ | 실패 실행 3번(시도 3 · 2 · 대체) → runs 3 · failed 5 · fallbacks 1 · 33% | 나중 |
|
||||||
|
| R10 | 회로 차단: 같은 대상의 최근 60초 시도 N개(기본 5)가 모두 실패면 시도 없이 바로 대체 (`--breaker 0`은 끔), 대체가 없으면 멈춤 | ⑩ | 실패 시도 5번 뒤 → 시도 0번 · 대체 출력 · "breaker open" | 나중 |
|
||||||
|
| R11 | `tretry log`: TUI. 왼쪽 대상 순위(실패 많은 순), 고르면 오른쪽에 시도들, `[f]` 실패만, `[r]` 다시 읽기 | ⑪ | `run_test`: 대상 2개 · 첫 줄이 실패 많은 대상, `f` → 실패만 | 꼭 |
|
||||||
|
| R12 | 종료 코드 0 됨(대체 포함) · 1 다 실패 · 2 쓰는 법이 틀림 · 3 고칠 수 없는 실패, `--json` | ⑫ | `--` 없음 → 2, 본문이 JSON이 아님 → 2, `--json` → `exit_code` · `stopped` · `attempts` | 꼭 |
|
||||||
|
| R13 | 테스트에서 기다리지 않음: `sleep` · `clock` · 무작위를 바꿔 끼움, HTTP는 테스트 안의 가짜 서버 | 끝 | `uv run pytest -q`가 10초 안 | 꼭 |
|
||||||
|
| R14 | `uv tool install`로 설치해 **어느 폴더에서나** `tretry` | 첫 문단 | `cd /tmp && tretry --help`, `uv tool list`에 `tretry` | 꼭 |
|
||||||
|
|
||||||
|
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
|
||||||
|
|
||||||
|
| 숨은 요구 | 어디서 나왔나 |
|
||||||
|
| --- | --- |
|
||||||
|
| 다시 하기 **엔진**은 명령도 HTTP도 모른다. "한 번 해 보기"를 받아 결과(성공 · 다시 할 수 있나 · 오류)만 본다 | ① · ⑤ — 그래야 명령 · HTTP · 다음에 올 것(DB 등)에 같은 엔진 |
|
||||||
|
| 명령이 **없으면**(오타) 다시 하지 않는다 | ⑤의 "몇 번을 해도 똑같은 것" |
|
||||||
|
| 대체 명령이 다 실패를 **가리지 않게**: 결과 · 기록에 "대체로 됨"이 남는다 | ④ "대신 한 거라면 그렇다고" — 오늘 Lab 01 과제 1의 `unclassified` 수와 같은 생각 |
|
||||||
|
| 한 실행의 시도 줄들을 다시 묶을 수 있어야 한다 (`final`) | ⑨ · ⑪ — 실행당 시도 수 |
|
||||||
|
| 회로 차단은 **대체의 성공**을 대상의 성공으로 세지 않는다 | ⑩ — 대체가 늘 성공하면 차단이 영영 안 풀림 |
|
||||||
|
| 기록 파일 위치를 바꿀 수 있게 (`--log`, `$TRETRY_LOG`) | 테스트가 내 진짜 기록을 더럽히지 않게 |
|
||||||
|
|
||||||
|
범위 밖 (오늘 하지 않음): 여러 대상 동시에, 차단 상태를 여러 컴퓨터가 함께 쓰기, 메일 · 메신저 알림, 기록 파일 돌려 쓰기(로테이션).
|
||||||
|
|
||||||
|
`git add Doc/SPEC.md && git commit -m "SPEC"`
|
||||||
|
|
||||||
|
## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분)
|
||||||
|
|
||||||
|
SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(백엔드)을 먼저, 화면(TUI)은 나중에.** 화면은 기록 파일을 읽어 보여 주기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침).
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# tretry IMP-PLAN
|
||||||
|
|
||||||
|
## 엔진 1단계 · 다시 하기 · 대체 · 종료 코드 (R1 · R2 · R3 · R4 · R12 · R13)
|
||||||
|
- [ ] retry.py: Policy(tries, backoff, max_wait, jitter), wait(i) = min(backoff × 2^(i-1), max_wait) × (1 - jitter × 무작위)
|
||||||
|
- [ ] run_with_retry(attempt, policy, fallback, timeout, sleep, clock, rand) → Result(ok, attempts, stopped, used_fallback)
|
||||||
|
- [ ] runners.command(argv): 종료 코드 · timeout · 명령 없음
|
||||||
|
- [ ] cli: tretry [옵션] -- <명령>, 종료 코드 0 · 1 · 2 · 3, --json
|
||||||
|
- [ ] tests: 가짜 시계로 [0.5, 1.0], 상한, 대체, 4xx 같은 "다시 못 함"은 1번
|
||||||
|
- 확인: uv run pytest -q, uv run tretry --tries 3 --backoff 0.2 -- false
|
||||||
|
|
||||||
|
## 엔진 2단계 · HTTP · 멱등 키 · 기록 (R5 · R6 · R8)
|
||||||
|
## TUI 1단계 · 기록 보기 (R11)
|
||||||
|
## 설치 (R14)
|
||||||
|
## 엔진 3단계 · 마감 · 순위 · 회로 차단 (R7 · R9 · R10)
|
||||||
|
```
|
||||||
|
|
||||||
|
<details><summary>▶ 왜 엔진 먼저인가</summary>
|
||||||
|
|
||||||
|
- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tretry -- false`). 화면부터 만들면 다시 하는지 눈으로만 봅니다.
|
||||||
|
- 밤 작업 스크립트는 화면 없이 돌아갑니다. 종료 코드(R12)가 다음 줄을 정합니다 (74일차 게이트와 같음).
|
||||||
|
- TUI는 기록 파일을 읽어 표에 넣기만 합니다. 기록 모양(R8)이 정해져야 TUI를 만들 수 있습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 단계 크기</summary>
|
||||||
|
|
||||||
|
한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 단계가 크면 에이전트가 많이 바꾸고, 틀렸을 때 어디가 틀렸는지 모릅니다. 오늘 Lab 01에서 기준을 한 번에 다 고치지 않고 "확인 → 재시도 → 멱등 키"로 나눈 것과 같습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"`
|
||||||
|
|
||||||
|
## 4. 구현 시작 (24분: 엔진 1단계 → 엔진 2단계 → TUI 1단계)
|
||||||
|
|
||||||
|
이제 에이전트를 켭니다. 첫 요청은 늘 같습니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽어 줘. 아직 코드는 쓰지 말고,
|
||||||
|
IMP-PLAN 의 "엔진 1단계"를 어떻게 만들지와 각 체크 항목의 확인 방법을 5줄로 먼저 말해 줘.
|
||||||
|
```
|
||||||
|
|
||||||
|
그다음 **한 단계씩**. 단계마다 긴 요청문은 필요 없습니다. 할 일은 SPEC · IMP-PLAN에 있으니까요.
|
||||||
|
|
||||||
|
```text
|
||||||
|
(처음) 좋아. 체크 안 된 첫 단계만 만들어 줘. 끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마.
|
||||||
|
(그다음) 확인했고 commit 했어. 다음 단계.
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 내가 확인 → commit → 한 줄. 그냥 "계속"만 치면 여러 단계를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 틀렸을 때만 무엇이 틀렸는지 · SPEC의 어느 요구인지 · 어디만 고칠지 · 무엇으로 확인할지를 적어 고쳐 달라고 합니다.
|
||||||
|
|
||||||
|
> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. `tretry`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 어디가 틀렸는지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`.
|
||||||
|
|
||||||
|
<details><summary>▶ 엔진 1단계 · 테스트가 기다리지 않게</summary>
|
||||||
|
|
||||||
|
`time.sleep`을 엔진 안에서 바로 부르면 테스트 하나에 몇 초씩 걸립니다. 엔진이 `sleep` · `clock`을 **인자로 받게** 하고, 테스트는 "기다린 시간을 적고 시계만 앞으로 돌리는" 가짜를 넣습니다. 그러면 "3초짜리 시도 · 마감 7초"도 0초 안에 시험합니다 (R7).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 엔진 1단계 · 무엇을 "다시 할 수 있는 실패"로 보나</summary>
|
||||||
|
|
||||||
|
엔진은 시도 결과에 **다시 할 수 있나**(retryable)를 함께 받습니다. 명령: 종료 코드 ≠ 0 · 시간 초과는 예, 명령이 없음은 아니오. HTTP(엔진 2단계): 5xx · 408 · 425 · 429 · 시간 초과 · 연결 실패는 예, 그 밖의 4xx는 아니오. 이 판단을 엔진 밖(실행기)에 두면 엔진은 하나로 충분합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 엔진 2단계 · 멱등 키는 언제 만드나</summary>
|
||||||
|
|
||||||
|
키를 **시도마다** 만들면 데이터 API가 매번 새 요청으로 봅니다 (오늘 기준 워크플로우의 `$execution.id`와 같은 실수). 키는 **실행을 시작할 때 한 번** 만들고 모든 시도에 붙입니다. 오늘 데이터 API는 본문의 `key`를 봅니다 → `--body-key key`.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 엔진 2단계 · 기록에 무엇을 남기나 · 남기지 않나</summary>
|
||||||
|
|
||||||
|
남길 것: 언제 · 무엇을(대상) · 몇 번째 · 시작부터 몇 초 · 걸린 시간 · 결과 · 오류 · 상태 코드 · 기다린 시간 · 대체였나. 남기지 **않을** 것: 헤더 값, URL · 오류 문장 안의 키 · 토큰. 한 줄에 하나(JSON Lines)면 실행이 중간에 죽어도 앞 줄들은 살아 있습니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ TUI 1단계 · 화면 나누기</summary>
|
||||||
|
|
||||||
|
순위 계산(엔진)과 화면을 나눕니다 (`tcost` · `tver`와 같음). 왼쪽 표의 줄을 고르면(`RowHighlighted`) 오른쪽 표를 다시 채웁니다. 기록 읽기 · 순위는 엔진 함수를 부르기만.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 다음 단계들 (8교시 뒤 · 집에서)</summary>
|
||||||
|
|
||||||
|
- 마감(R7): 시도를 시작하기 전에 남은 시간을 보고, 기다림이 마감을 넘으면 멈춤. 마지막 시도의 시간 제한 = min(`--timeout`, 남은 시간).
|
||||||
|
- 순위(R9): 기록을 실행 단위로 묶어(`final`) 대상마다 셈. p95는 실행 시간으로.
|
||||||
|
- 회로 차단(R10): 기록에서 그 대상의 최근 60초 시도(대체 제외) N개가 모두 실패면 바로 대체. 60초가 지나면 저절로 다시 시도 (half-open).
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 5. 바이브 코딩으로 문제 해결 팁
|
||||||
|
|
||||||
|
| 이럴 때 | 이렇게 |
|
||||||
|
| --- | --- |
|
||||||
|
| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tretry --tries 3 -- false; echo $?`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 |
|
||||||
|
| 실패 · 오류 | 오류 출력 **전부**와 실행한 명령을 그대로 붙여 줍니다 |
|
||||||
|
| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 |
|
||||||
|
| 테스트가 몇 초씩 걸림 | "엔진이 sleep · clock을 인자로 받게, 테스트에서는 가짜로" |
|
||||||
|
| 400도 다시 함 | "고칠 수 없는 실패(4xx, 408 · 425 · 429 빼고)는 1번에 멈추고 종료 코드 3, 대체도 하지 않기" |
|
||||||
|
| 간격이 늘 같음 · 끝없이 늚 | "지수 backoff와 상한(--max-wait)" |
|
||||||
|
| 다시 보낸 POST로 접수 건이 둘 | "멱등 키는 실행에 한 번 만들고 모든 시도에 같은 값, 본문 key에도" |
|
||||||
|
| `--timeout`이 안 먹음 | "subprocess.run(timeout=...)과 urlopen(timeout=...) 둘 다, 시간 초과도 다시 할 실패로" |
|
||||||
|
| `-- ` 뒤 옵션을 tretry가 먹어 버림 | "`--` 앞만 tretry 옵션으로 읽고, 뒤는 그대로 명령으로" |
|
||||||
|
| 기록에 토큰이 보임 | "헤더 값은 기록하지 않고, URL · 오류의 sk-… · Bearer … · api_key=… 는 *** 로" |
|
||||||
|
| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console` |
|
||||||
|
| 대화가 길어져 에이전트가 앞 내용을 잊음 | 새 세션을 열고 "Doc/SPEC.md · Doc/IMP-PLAN.md를 읽고, 체크 안 된 첫 단계부터"로 다시 시작 |
|
||||||
|
| 설치한 `tretry`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) |
|
||||||
|
|
||||||
|
## 6. 결과 확인
|
||||||
|
|
||||||
|
`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/vibe/tretry
|
||||||
|
uv run pytest -q # R13: 10초 안
|
||||||
|
uv run tretry --tries 3 --backoff 0.2 -- false; echo "exit $?" # R1 · R2: 3번, 0.2 → 0.4
|
||||||
|
uv run tretry --tries 1 --timeout 0.3 -- sleep 5; echo "exit $?" # R3
|
||||||
|
uv run tretry --tries 2 --backoff 0 --fallback "echo cached" -- false # R4
|
||||||
|
uv run tretry; echo "exit $?" # R12: 2
|
||||||
|
L=~/aio-01d4-stu/Last3Mon/76
|
||||||
|
(cd $L && uv run tools/flow.py fault tickets flaky)
|
||||||
|
uv run tretry http POST http://localhost:8800/tickets --body '{"title": "vibe test", "requester": "나"}' \
|
||||||
|
--idem-key vibe-001 --body-key key --backoff 0.3 # R5 · R6: 500 → 201
|
||||||
|
uv run tretry http POST http://localhost:8800/tickets --body '{"title": "vibe test"}' --idem-key vibe-001 --body-key key
|
||||||
|
# R6: 200 duplicate: true
|
||||||
|
uv run tretry http POST http://localhost:8800/tickets --body '{}' --fallback "echo cached"; echo "exit $?" # R5: 400 → 3
|
||||||
|
(cd $L && uv run tools/flow.py fault tickets ok)
|
||||||
|
tail -3 ~/.tretry/log.jsonl # R8
|
||||||
|
uv run tretry log # R11
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] R1 횟수.
|
||||||
|
- [ ] R2 backoff · 상한 · jitter.
|
||||||
|
- [ ] R3 시도마다 시간 제한.
|
||||||
|
- [ ] R4 대체 명령.
|
||||||
|
- [ ] R5 HTTP 5xx · timeout만 다시, 4xx는 1번 · 종료 코드 3.
|
||||||
|
- [ ] R6 같은 멱등 키 · `duplicate: true`.
|
||||||
|
- [ ] R8 기록 · 키 지움.
|
||||||
|
- [ ] R11 TUI.
|
||||||
|
- [ ] R12 종료 코드 · `--json`.
|
||||||
|
- [ ] R13 기다리지 않는 테스트.
|
||||||
|
- [ ] (이어서) R7 · R9 · R10.
|
||||||
|
|
||||||
|
## 7. `uv tool install`로 설치
|
||||||
|
|
||||||
|
`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.scripts]
|
||||||
|
tretry = "tretry.cli:main"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/vibe/tretry
|
||||||
|
uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함)
|
||||||
|
uv tool list # tretry 가 보이는가
|
||||||
|
which tretry # ~/.local/bin/tretry
|
||||||
|
# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶
|
||||||
|
```
|
||||||
|
|
||||||
|
- 지우기: `uv tool uninstall tretry`.
|
||||||
|
|
||||||
|
## 8. 다른 곳에서 사용
|
||||||
|
|
||||||
|
설치했으면 오늘 Lab과 내 스크립트에 씁니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/aio-01d4-stu/Last3Mon/76
|
||||||
|
uv run tools/flow.py fault tickets slow
|
||||||
|
tretry http POST http://localhost:8800/tickets --body '{"title": "deadline test"}' --idem-key dl-1 --body-key key \
|
||||||
|
--timeout 3 --deadline 5 # (R7) Lab 01 예제 3의 12초 대신 5초 안에 포기
|
||||||
|
uv run tools/flow.py fault tickets ok
|
||||||
|
tretry --tries 3 --fallback "echo '{\"status\": \"cached\"}'" -- curl -sf http://localhost:8800/health # 아무 명령이나
|
||||||
|
tretry stats # (R9) 오늘 가장 자주 실패한 대상
|
||||||
|
tretry log
|
||||||
|
```
|
||||||
|
|
||||||
|
- `~/vibe/tretry`는 지우지 마세요. 79일차 `tops`에서 다시 씁니다.
|
||||||
|
|
||||||
|
## 발표 (8교시, 3분)
|
||||||
|
|
||||||
|
| 내용 | |
|
||||||
|
| --- | |
|
||||||
|
| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
|
||||||
|
| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 |
|
||||||
|
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
|
||||||
|
| 막힌 곳 | 5절의 어느 팁으로 풀었나 (4xx 재시도 · 테스트 시간 · 멱등 키 · `--` 뒤 옵션) |
|
||||||
|
| 시연 | 데이터 API `flaky` · `error` · `slow`에서 다시 하기 · 대체 · 마감 |
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`를 에이전트 전에 (또는 에이전트와 함께 쓰고 내가 고쳐서) 만들고 commit했다.
|
||||||
|
- [ ] IMP-PLAN의 엔진 1 · 2단계와 TUI 1단계를 체크했다 (단계마다 commit).
|
||||||
|
- [ ] R1 · R2 · R3 · R4 · R5 · R6 · R8 · R11 · R12 · R13 확인, `uv run pytest -q`.
|
||||||
|
- [ ] R14 `uv tool install`, 다른 폴더에서 `tretry`.
|
||||||
|
- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 7교시 바이브 코딩 · 요청서 `tretry` (가장 먼저 읽기)
|
||||||
|
|
||||||
|
- 순서: **이 요청서** → 내 정리 `~/vibe/tretry/Doc/TODO.md` (8분) → [VIBE-Q-TRETRY.md](VIBE-Q-TRETRY.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TRETRY.md`(정답본)로 확인
|
||||||
|
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 72일차 `tbench` · 73일차 `tscan` · 74일차 `tver` · 75일차 `tcost`와 같은 순서입니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
REQ["요청서<br/>(사람의 말)"] --> TODO["Doc/TODO.md<br/>만들고 싶은 것 ·<br/>요청의 말 정리"] --> SPEC["Doc/SPEC.md<br/>확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·<br/>직접 확인"] --> USE["uv tool install<br/>다른 곳에서 쓰기"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 왜 요청서부터인가
|
||||||
|
|
||||||
|
요구 사항 표를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 오늘 Lab 01 예제 1에서 기준 워크플로우를 먼저 망가뜨려 본 것과 같습니다: 무엇이 실패인지 알아야 고쳤는지 말할 수 있습니다.
|
||||||
|
|
||||||
|
| 단계 | 누가 | 결과 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 |
|
||||||
|
| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
|
||||||
|
| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 |
|
||||||
|
| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `Doc/*.md`를 함께 읽게 함 |
|
||||||
|
| 확인 | **나** | 직접 `uv run tretry …`, `pytest`, 요구 표에 체크 |
|
||||||
|
|
||||||
|
## 요청 (운영 담당에게서 온 메시지, 그대로)
|
||||||
|
|
||||||
|
> 오늘 n8n에서 재시도 · 대체 · 알림을 노드 설정으로 했잖아요. 그런데 우리 밤 작업은 n8n 밖에도 많아요. 셸 스크립트에서 `curl`로 API를 부르고, 백업 명령을 돌리고요. 그런 것들이 **한 번 실패하면 그냥 끝나** 버려서 아침에 보면 빠져 있어요. 아무 명령이나 감싸서 **n8n처럼 다시 해 보게** 해 주는 작은 도구가 있으면 좋겠어요. **어느 폴더에서나** `tretry`라고 치면 되게요.
|
||||||
|
>
|
||||||
|
> ① 명령을 주면 실행하고, 실패하면(**종료 코드가 0이 아니면**) 정해진 **횟수**만큼 다시 해 주세요.
|
||||||
|
>
|
||||||
|
> ② 바로바로 다시 하면 이미 힘든 서버가 더 힘들어진대요. 기다리는 시간을 **점점 늘려** 주시고, 너무 길어지지 않게 **상한**도요. 그리고 밤 작업 여러 개가 **같은 순간에 한꺼번에** 다시 보내지 않게 해 주세요.
|
||||||
|
>
|
||||||
|
> ③ 한 번 시도가 **멈춰 버리면**(응답이 안 오면) 정해진 시간에 끊고 다시 해 주세요.
|
||||||
|
>
|
||||||
|
> ④ 다 해도 안 되면 **대신 할 명령**을 돌려 주세요. 예를 들어 어제 받아 둔 파일을 쓰는 거요. 대신 한 거라면 그렇다고 알려 주세요.
|
||||||
|
>
|
||||||
|
> ⑤ API를 부를 때는 **고칠 수 없는 실패**(우리 요청이 잘못된 것)까지 다시 하면 안 되잖아요. 몇 번을 해도 똑같으니까요. 그런 건 바로 멈추고, 그때는 대신 할 명령도 돌리지 말아 주세요. 실수가 가려지니까요. 다만 서버가 "**잠깐 뒤에 다시 와**"라고 하면 그 말대로 기다려 주세요.
|
||||||
|
>
|
||||||
|
> ⑥ 같은 요청을 다시 보낼 때 **두 번 만들어지면** 안 돼요. 오늘 접수 건이 그랬잖아요. 다시 보낼 때마다 같은 표시(키)를 붙여 주세요.
|
||||||
|
>
|
||||||
|
> ⑦ 다시 하는 건 좋은데, 전체가 **몇 초 안에는** 끝나야 할 때가 있어요. 오늘 느린 API에 3초씩 세 번 기다린 것처럼요. 정해진 시간 안에 끝낼 수 없는 시도는 **시작하지 말아** 주세요.
|
||||||
|
>
|
||||||
|
> ⑧ 시도마다 **언제 · 무엇을 · 몇 번째 · 몇 초 · 결과 · 오류**를 파일에 남겨 주세요. 나중에 모아서 보려고요. 그런데 거기에 **API 키나 토큰이 남으면 큰일** 나요.
|
||||||
|
>
|
||||||
|
> ⑨ 그 기록으로 **어느 대상이 가장 자주 실패하는지**, 성공률 · 평균 몇 번 만에 됐는지 · 오래 걸린 정도를 보여 주세요.
|
||||||
|
>
|
||||||
|
> ⑩ 하루 종일 죽어 있는 대상에 매번 세 번씩 다시 보내는 건 낭비예요. **방금 계속 실패한 대상**이면 시도하지 말고 바로 대신 할 명령으로 가 주세요. 조금 지나면 다시 시도해 보고요.
|
||||||
|
>
|
||||||
|
> ⑪ **화면에서** 보고 싶어요. 실패가 많은 대상 순서로, 하나를 고르면 그 시도들이 보이게요. 실패한 것만 볼 수도 있게요.
|
||||||
|
>
|
||||||
|
> ⑫ 스크립트 안에서 쓸 거라 **종료 코드**가 중요해요. 됐다 · 다 실패했다 · 쓰는 법이 틀렸다 · 고칠 수 없는 실패다를 서로 다른 번호로 주세요. 그리고 **다른 프로그램이 읽을 수 있는** 결과도요.
|
||||||
|
>
|
||||||
|
> 아, 시험할 때 **진짜로 몇 초씩 기다리면** 안 돼요. 그리고 오늘 수업의 데이터 API(고장 스위치)로 직접 해 보고 싶어요.
|
||||||
|
|
||||||
|
## 함께 받은 것
|
||||||
|
|
||||||
|
| 파일 · 자료 | 메모 |
|
||||||
|
| --- | --- |
|
||||||
|
| 오늘 데이터 API `http://localhost:8800` | `POST /tickets` (멱등 키 `key`), 고장 스위치: `uv run tools/flow.py fault tickets flaky` · `error` · `slow` · `ok` |
|
||||||
|
| `requests/batch-ok.json` | 요청 본문 예. `tretry http POST ... --body @파일` |
|
||||||
|
| 오늘 Lab 01 예제 3 | 고장 종류마다 재시도가 하는 일 (flaky · error · slow) |
|
||||||
|
| Python 표준 라이브러리 | `subprocess`(시간 제한), `urllib.request`. TUI는 Textual |
|
||||||
|
|
||||||
|
## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이)
|
||||||
|
|
||||||
|
`~/vibe/tretry/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# tretry TODO
|
||||||
|
|
||||||
|
## 만들고 싶은 것 (한 문단)
|
||||||
|
어느 폴더에서나 `tretry`로 아무 명령이나 API 호출을 감싸, 실패하면 간격을 늘려 가며 다시 하고 ...
|
||||||
|
|
||||||
|
## 요청의 말 → 할 일
|
||||||
|
| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ① | 종료 코드 ≠ 0이면 다시 | | 꼭 |
|
||||||
|
| ② | 점점 늘려 · 상한 · 한꺼번에 X | | |
|
||||||
|
| ... | | | |
|
||||||
|
| 끝 | 시험에서 기다리지 않기 | | |
|
||||||
|
|
||||||
|
## 요청에 없지만 걱정되는 것
|
||||||
|
## 오늘 50분 안에 못 할 것 같은 것
|
||||||
|
```
|
||||||
|
|
||||||
|
예 하나만 보여 드립니다.
|
||||||
|
|
||||||
|
| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ⑤ "고칠 수 없는 실패까지 다시 하면 안 돼요" | "API가 400처럼 내 잘못을 말하면 한 번만 하고 멈춤, 대신 할 명령도 안 함" | R5: 가짜 서버가 400 → 시도 1번, 종료 코드 3, 대체 명령의 출력 없음 |
|
||||||
|
|
||||||
|
<details><summary>▶ 1단계 · 꼭 할 것과 나중에 할 것</summary>
|
||||||
|
|
||||||
|
요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 담당자가 가장 아파하는 것은 "한 번 실패하면 그냥 끝나 버려서"입니다. 그리고 기록 · 순위 · 화면 · 바로 대체(⑩)를 만들려면 먼저 무엇이 있어야 하나요? (다시 하기 엔진 → 명령 · HTTP → 기록 → 기록 읽기 → 화면)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details><summary>▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗)</summary>
|
||||||
|
|
||||||
|
- ② "점점 늘려": 0.5 → 1 → 2 → 4초면, 열 번째는 몇 초일까요? 상한이 없으면? "한꺼번에 X"는 기다리는 시간을 조금씩 다르게 하면 됩니다. 그런데 무작위면 시험 결과가 날마다 달라지지 않을까요?
|
||||||
|
- ③ "멈춰 버리면": 명령이 멈춘 것과 API가 멈춘 것은 끊는 방법이 다릅니다. 둘 다 끊어야 합니다.
|
||||||
|
- ⑤ "고칠 수 없는 실패": HTTP 4xx 중에도 기다리면 되는 것이 있습니다 (429 Too Many Requests · 408). 명령은? 명령이 없을 때(오타)도 다시 할까요?
|
||||||
|
- ⑥ "같은 표시": 키를 시도마다 새로 만들면 아무 소용이 없습니다. 오늘 데이터 API는 키를 **본문**의 `key`로 받습니다. 헤더로만 보내면?
|
||||||
|
- ⑦ "몇 초 안에": 남은 시간이 1.5초인데 한 시도의 시간 제한이 3초라면?
|
||||||
|
- ⑧ "키나 토큰": 헤더뿐 아니라 URL(`?api_key=...`)이나 오류 문장에도 들어갈 수 있습니다.
|
||||||
|
- ⑩ "계속 실패한 대상": 몇 번 실패하면 · 얼마 동안 바로 대체로? 대신 할 명령이 없으면?
|
||||||
|
- 끝 "진짜로 기다리면 안 돼요": 기다리는 일을 프로그램 밖에서 바꿔 끼울 수 있어야 합니다.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 그다음
|
||||||
|
|
||||||
|
[VIBE-Q-TRETRY.md](VIBE-Q-TRETRY.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
$ uv run tools/flow.py ops --reset
|
||||||
|
✔ created tickets, DLQ and alerts emptied, fault switch all ok
|
||||||
|
|
||||||
|
$ uv run tools/flow.py budget --reset
|
||||||
|
✔ budget back to data/budget.json
|
||||||
|
budget $1.0 / used $0.000000 · remaining $1.000000 · over False · per user 20/min · question 500 chars
|
||||||
|
|
||||||
|
$ uv run tools/flow.py usage --clear
|
||||||
|
✔ usage cleared: {"cleared": 0}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py off my-intake my-replay my-ops-status reference/lab02-example2-error-handler.json
|
||||||
|
✔ unpublished my-intake (id w75a95815facf2d6)
|
||||||
|
✔ unpublished my-replay (id w7d8de63c811909b)
|
||||||
|
✔ unpublished my-ops-status (id w07b5812eaa88ef9)
|
||||||
|
✔ n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>
|
||||||
|
|
||||||
|
$ uv run tools/flow.py push reference/baseline-intake.json reference/nightly-report.json
|
||||||
|
✔ pushed '76 Intake baseline (provided)' (id w4ef8dc8a25b82e3)
|
||||||
|
POST http://localhost:5678/webhook/base-intake
|
||||||
|
✔ pushed '76 Nightly report (provided, has a bug)' (id wb9ce32c18d7df56)
|
||||||
|
POST http://localhost:5678/webhook/nightly-report
|
||||||
|
|
||||||
|
$ uv run tools/flow.py doctor
|
||||||
|
! n8n install folder /Users/mcchae/day65-n8n
|
||||||
|
→ day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>
|
||||||
|
✔ n8n container day65-n8n-n8n-1
|
||||||
|
✔ n8n http://localhost:5678
|
||||||
|
✔ data API http://localhost:8800
|
||||||
|
✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API)
|
||||||
|
✔ n8n → http://data-api:8800
|
||||||
|
✔ credential 'OpenAI (.env)' (openAiCourse0001)
|
||||||
|
✔ node catalog (for check)
|
||||||
|
✔ CLI coding agent: codex, claude, agy
|
||||||
|
ready
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
$ uv run tools/flow.py ops --reset
|
||||||
|
✔ created tickets, DLQ and alerts emptied, fault switch all ok
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call base-intake requests/batch-ok.json
|
||||||
|
HTTP 200 · 5.2s
|
||||||
|
{
|
||||||
|
"processed": 3,
|
||||||
|
"tickets": [
|
||||||
|
"T-2001",
|
||||||
|
"T-2002",
|
||||||
|
"T-2003"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call base-intake requests/batch-bad-item.json
|
||||||
|
HTTP 500 · 3.2s
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 5
|
||||||
|
T-2001 key 10057-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있
|
||||||
|
T-2002 key 10057-1 장비/낮음 새 모니터를 하나 더 받을 수 있을까요?
|
||||||
|
T-2003 key 10057-2 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요
|
||||||
|
T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요
|
||||||
|
T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 0
|
||||||
|
|
||||||
|
$ uv run tools/flow.py fault tickets flaky
|
||||||
|
✔ fault switch: {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call base-intake requests/batch-two.json
|
||||||
|
HTTP 500 · 2.5s
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py fault tickets ok
|
||||||
|
✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call base-intake requests/batch-ok.json
|
||||||
|
HTTP 200 · 3.0s
|
||||||
|
{
|
||||||
|
"processed": 3,
|
||||||
|
"tickets": [
|
||||||
|
"T-2006",
|
||||||
|
"T-2008",
|
||||||
|
"T-2007"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 8
|
||||||
|
T-2004 key 10058-0 장비/보통 프린터 용지가 걸려요
|
||||||
|
T-2005 key 10058-2 소프트웨어/보통 메일 첨부가 안 열려요
|
||||||
|
T-2006 key 10060-0 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있
|
||||||
|
T-2007 key 10060-2 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요
|
||||||
|
T-2008 key 10060-1 장비/낮음 새 모니터를 하나 더 받을 수 있을까요?
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 0
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01.json",
|
||||||
|
"webhook": "base-intake",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "정상 3건",
|
||||||
|
"webhook": "base-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"created = null (expected 3)",
|
||||||
|
"failed = null (expected 0)",
|
||||||
|
"rejected = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 2.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"processed": 3,
|
||||||
|
"tickets": [
|
||||||
|
"T-2001",
|
||||||
|
"T-2002",
|
||||||
|
"T-2003"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "같은 요청을 다시 보내도 중복 없음",
|
||||||
|
"webhook": "base-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"created = null (expected 3)",
|
||||||
|
"duplicates = null (expected 3)"
|
||||||
|
],
|
||||||
|
"seconds": 3.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"processed": 3,
|
||||||
|
"tickets": [
|
||||||
|
"T-2004",
|
||||||
|
"T-2005",
|
||||||
|
"T-2006"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "만들어진 접수 건은 3개",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"count = 6 (expected 3)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 6,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "10061-0",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "10061-1",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "10061-2",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "10062-0",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "10062-1",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"key": "10062-2",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "잘못된 항목은 그 항목만 거절",
|
||||||
|
"webhook": "base-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 500 (expected 200)",
|
||||||
|
"created = null (expected 2)",
|
||||||
|
"rejected = null (expected 1)",
|
||||||
|
"failed = null (expected 0)",
|
||||||
|
"results has none of ['text is required']"
|
||||||
|
],
|
||||||
|
"seconds": 3.4,
|
||||||
|
"status": 500,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "목록이 아니면 400",
|
||||||
|
"webhook": "base-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 500 (expected 400)"
|
||||||
|
],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 500,
|
||||||
|
"request": {
|
||||||
|
"requests": "VPN이 끊겨요"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "가끔 실패하는 API · 다시 해 보기로 성공",
|
||||||
|
"webhook": "base-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 500 (expected 200)",
|
||||||
|
"created = null (expected 2)",
|
||||||
|
"failed = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 2.0,
|
||||||
|
"status": 500,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01.json --webhook base-intake -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 8}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 8
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/base-intake · 6 case(s) × 1
|
||||||
|
✘ 정상 3건 · 2.7s · created = null (expected 3); failed = null (expected 0); rejected = null (expected 0)
|
||||||
|
→ request POST /webhook/base-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"processed": 3,
|
||||||
|
"tickets": [
|
||||||
|
"T-2001",
|
||||||
|
"T-2002",
|
||||||
|
"T-2003"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 같은 요청을 다시 보내도 중복 없음 · 3.1s · created = null (expected 3); duplicates = null (expected 3)
|
||||||
|
→ request POST /webhook/base-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"processed": 3,
|
||||||
|
"tickets": [
|
||||||
|
"T-2004",
|
||||||
|
"T-2005",
|
||||||
|
"T-2006"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 만들어진 접수 건은 3개 · 0.0s · count = 6 (expected 3)
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 6,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "10061-0",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "10061-1",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "10061-2",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "10062-0",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "10062-1",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"key": "10062-2",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 잘못된 항목은 그 항목만 거절 · 3.4s · status 500 (expected 200); created = null (expected 2); rejected = null (expected 1); failed = null (expected 0); results has none of ['text is required']
|
||||||
|
→ request POST /webhook/base-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
✘ 목록이 아니면 400 · 0.1s · status 500 (expected 400)
|
||||||
|
→ request POST /webhook/base-intake
|
||||||
|
{
|
||||||
|
"requests": "VPN이 끊겨요"
|
||||||
|
}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "flaky"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "flaky",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.0s · status 500 (expected 200); created = null (expected 2); failed = null (expected 0)
|
||||||
|
→ request POST /webhook/base-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
Passed 0/6
|
||||||
|
report: runs/lab01-20261029-141145.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 6,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "정상 3건",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "같은 요청을 다시 보내도 중복 없음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 3,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "만들어진 접수 건은 3개",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 3,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "잘못된 항목은 그 항목만 거절",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "목록이 아니면 400",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 400,
|
||||||
|
"request": {
|
||||||
|
"requests": "VPN이 끊겨요"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"error": "requests must be a list"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "가끔 실패하는 API · 다시 해 보기로 성공",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2007",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab01-example2-intake.json
|
||||||
|
✔ pushed '76 Intake · retry and idempotency key (reference, Lab 01 example 2)' (id w8399f905b79a073)
|
||||||
|
POST http://localhost:5678/webhook/my-intake
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 8}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 8
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ 정상 3건 · 3.9s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 3,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 만들어진 접수 건은 3개 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 3,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 잘못된 항목은 그 항목만 거절 · 1.7s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 목록이 아니면 400 · 0.0s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": "VPN이 끊겨요"
|
||||||
|
}
|
||||||
|
← response HTTP 400
|
||||||
|
{
|
||||||
|
"error": "requests must be a list"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "flaky"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "flaky",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2007",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab01-20261029-141207.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 7
|
||||||
|
T-2003 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요
|
||||||
|
T-2004 key R-201 장비/보통 프린터 용지가 걸려요
|
||||||
|
T-2005 key R-203 소프트웨어/보통 메일 첨부가 안 열려요
|
||||||
|
T-2006 key R-301 계정/높음 사내 위키 로그인이 안 돼요
|
||||||
|
T-2007 key R-302 장비/보통 노트북 배터리가 금방 닳아요
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 0
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-faults.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "flaky · 다시 해 보면 됨",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "error · 세 번 해도 실패",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "slow · 3초씩 세 번 기다리다 실패",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 12.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "실패라던 3건이 늦게 만들어짐 (2 + 3)",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 5,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "같은 요청을 다시 · 멱등 키로 중복 없음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 3,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01-faults.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 7
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "flaky"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "flaky",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ flaky · 다시 해 보면 됨 · 2.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ error · 세 번 해도 실패 · 3.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "slow", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "slow"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "slow",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ slow · 3초씩 세 번 기다리다 실패 · 12.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
… before: wait 8s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 실패라던 3건이 늦게 만들어짐 (2 + 3) · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 5,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 같은 요청을 다시 · 멱등 키로 중복 없음 · 2.9s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 3,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab01-faults-20261029-141237.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 5
|
||||||
|
T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요
|
||||||
|
T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요
|
||||||
|
T-2003 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요
|
||||||
|
T-2004 key R-102 장비/낮음 새 모니터를 하나 더 받을 수 있을까요?
|
||||||
|
T-2005 key R-101 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 0
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 1,
|
||||||
|
"total": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "평소에는 분류됨",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "LLM이 멈추면 미분류로 접수",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 500 (expected 200)",
|
||||||
|
"created = null (expected 3)",
|
||||||
|
"unclassified = null (expected 3)",
|
||||||
|
"failed = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.8,
|
||||||
|
"status": 500,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "미분류도 접수 건으로",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"tickets has none of ['미분류']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 5
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1
|
||||||
|
✔ 평소에는 분류됨 · 2.1s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
$ bash 65/n8n/register-credentials.sh → exit 0
|
||||||
|
Successfully imported 2 credentials.
|
||||||
|
Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env
|
||||||
|
✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up
|
||||||
|
✘ LLM이 멈추면 미분류로 접수 · 0.8s · status 500 (expected 200); created = null (expected 3); unclassified = null (expected 3); failed = null (expected 0)
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
$ bash 65/n8n/register-credentials.sh → exit 0
|
||||||
|
Successfully imported 2 credentials.
|
||||||
|
Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env
|
||||||
|
✔ OpenAI credential: real key from .env restored
|
||||||
|
✘ 미분류도 접수 건으로 · 0.0s · tickets has none of ['미분류']
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Passed 1/3
|
||||||
|
report: runs/lab01-challenge-20261029-141245.json
|
||||||
|
$ bash 65/n8n/register-credentials.sh → exit 0
|
||||||
|
Successfully imported 2 credentials.
|
||||||
|
Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env
|
||||||
|
✔ OpenAI credential: real key from .env restored
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 3,
|
||||||
|
"total": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "평소에는 분류됨",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.5,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "LLM이 멈추면 미분류로 접수",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "미분류도 접수 건으로",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 5,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
$ uv run tools/flow.py push answers/76-lab01-challenge-fallback.json
|
||||||
|
! unpublished '76 Intake · retry and idempotency key (reference, Lab 01 example 2)' (w8399f905b79a073): it used the same webhook path
|
||||||
|
✔ pushed '76 Intake · LLM fallback (answer, Lab 01 task 1)' (id w0f5e1ef11aa4c14)
|
||||||
|
POST http://localhost:5678/webhook/my-intake
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1
|
||||||
|
✔ 평소에는 분류됨 · 3.5s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
$ bash 65/n8n/register-credentials.sh → exit 0
|
||||||
|
Successfully imported 2 credentials.
|
||||||
|
Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env
|
||||||
|
✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up
|
||||||
|
✔ LLM이 멈추면 미분류로 접수 · 0.9s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 3,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
$ bash 65/n8n/register-credentials.sh → exit 0
|
||||||
|
Successfully imported 2 credentials.
|
||||||
|
Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env
|
||||||
|
✔ OpenAI credential: real key from .env restored
|
||||||
|
✔ 미분류도 접수 건으로 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 5,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "미분류",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Passed 3/3
|
||||||
|
report: runs/lab01-challenge-20261029-141310.json
|
||||||
|
$ bash 65/n8n/register-credentials.sh → exit 0
|
||||||
|
Successfully imported 2 credentials.
|
||||||
|
Registered 'OpenAI (.env)' and 'Ollama local' from /Users/mcchae/work/AIO-01D4/Last3Mon/.env
|
||||||
|
✔ OpenAI credential: real key from .env restored
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ 정상 3건 · 3.1s
|
||||||
|
✔ 같은 요청을 다시 보내도 중복 없음 · 3.1s
|
||||||
|
✔ 만들어진 접수 건은 3개 · 0.0s
|
||||||
|
✔ 잘못된 항목은 그 항목만 거절 · 1.8s
|
||||||
|
✔ 목록이 아니면 400 · 0.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab01-20261029-141323.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge2.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 1,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "같은 id가 두 번이면 두 번째는 거절",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"created = 3 (expected 2)",
|
||||||
|
"rejected = 0 (expected 1)",
|
||||||
|
"duplicates = 1 (expected 0)",
|
||||||
|
"results has none of ['duplicate id']"
|
||||||
|
],
|
||||||
|
"seconds": 2.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 1,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "11건은 400 (한 번에 10건까지)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 200 (expected 400)",
|
||||||
|
"error has none of ['too many']"
|
||||||
|
],
|
||||||
|
"seconds": 11.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-501",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 1번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-502",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 2번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-503",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 3번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-504",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 4번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-505",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 5번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-506",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "모니터 6번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-507",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 7번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-508",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 8번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-509",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 9번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-510",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 10번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-511",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 11번이 켜지지 않아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 11,
|
||||||
|
"created": 11,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-501",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-502",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-503",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-504",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-505",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2007",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-506",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2008",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-507",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2013",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-508",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2012",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-509",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2010",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-510",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2011",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-511",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2009",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "400이면 하나도 만들지 않음",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"count = 13 (expected 2)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 13,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-401",
|
||||||
|
"title": "회의실 화면 공유가 안 돼요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-402",
|
||||||
|
"title": "비밀번호를 바꾸라는 창이 계속 떠요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-502",
|
||||||
|
"title": "모니터 2번이 켜지지 않아요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-501",
|
||||||
|
"title": "모니터 1번이 켜지지 않아요",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-503",
|
||||||
|
"title": "모니터 3번이 켜지지 않아요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"key": "R-504",
|
||||||
|
"title": "모니터 4번이 켜지지 않아요",
|
||||||
|
"requester": "한지훈",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2007",
|
||||||
|
"key": "R-505",
|
||||||
|
"title": "모니터 5번이 켜지지 않아요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2008",
|
||||||
|
"key": "R-506",
|
||||||
|
"title": "모니터 6번이 켜지지 않아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2009",
|
||||||
|
"key": "R-511",
|
||||||
|
"title": "모니터 11번이 켜지지 않아요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2010",
|
||||||
|
"key": "R-509",
|
||||||
|
"title": "모니터 9번이 켜지지 않아요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2011",
|
||||||
|
"key": "R-510",
|
||||||
|
"title": "모니터 10번이 켜지지 않아요",
|
||||||
|
"requester": "한지훈",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2012",
|
||||||
|
"key": "R-508",
|
||||||
|
"title": "모니터 8번이 켜지지 않아요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2013",
|
||||||
|
"key": "R-507",
|
||||||
|
"title": "모니터 7번이 켜지지 않아요",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "평소 요청은 그대로",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2014",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2015",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2016",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 7
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1
|
||||||
|
✘ 같은 id가 두 번이면 두 번째는 거절 · 2.8s · created = 3 (expected 2); rejected = 0 (expected 1); duplicates = 1 (expected 0); results has none of ['duplicate id']
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 1,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": true,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 11건은 400 (한 번에 10건까지) · 11.0s · status 200 (expected 400); error has none of ['too many']
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-501",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 1번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-502",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 2번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-503",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 3번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-504",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 4번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-505",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 5번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-506",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "모니터 6번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-507",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 7번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-508",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 8번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-509",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 9번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-510",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 10번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-511",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 11번이 켜지지 않아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 11,
|
||||||
|
"created": 11,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-501",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-502",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-503",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-504",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-505",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2007",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-506",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2008",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-507",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2013",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-508",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2012",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-509",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2010",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-510",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2011",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-511",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2009",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 400이면 하나도 만들지 않음 · 0.0s · count = 13 (expected 2)
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 13,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-401",
|
||||||
|
"title": "회의실 화면 공유가 안 돼요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-402",
|
||||||
|
"title": "비밀번호를 바꾸라는 창이 계속 떠요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-502",
|
||||||
|
"title": "모니터 2번이 켜지지 않아요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-501",
|
||||||
|
"title": "모니터 1번이 켜지지 않아요",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-503",
|
||||||
|
"title": "모니터 3번이 켜지지 않아요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2006",
|
||||||
|
"key": "R-504",
|
||||||
|
"title": "모니터 4번이 켜지지 않아요",
|
||||||
|
"requester": "한지훈",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2007",
|
||||||
|
"key": "R-505",
|
||||||
|
"title": "모니터 5번이 켜지지 않아요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2008",
|
||||||
|
"key": "R-506",
|
||||||
|
"title": "모니터 6번이 켜지지 않아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2009",
|
||||||
|
"key": "R-511",
|
||||||
|
"title": "모니터 11번이 켜지지 않아요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2010",
|
||||||
|
"key": "R-509",
|
||||||
|
"title": "모니터 9번이 켜지지 않아요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2011",
|
||||||
|
"key": "R-510",
|
||||||
|
"title": "모니터 10번이 켜지지 않아요",
|
||||||
|
"requester": "한지훈",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2012",
|
||||||
|
"key": "R-508",
|
||||||
|
"title": "모니터 8번이 켜지지 않아요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2013",
|
||||||
|
"key": "R-507",
|
||||||
|
"title": "모니터 7번이 켜지지 않아요",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 평소 요청은 그대로 · 3.2s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2014",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2015",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2016",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Passed 1/4
|
||||||
|
report: runs/lab01-challenge2-20261029-141341.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab01-challenge2.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 4,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "같은 id가 두 번이면 두 번째는 거절",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "duplicate id in this request"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "11건은 400 (한 번에 10건까지)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.2,
|
||||||
|
"status": 400,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-501",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 1번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-502",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 2번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-503",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 3번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-504",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 4번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-505",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 5번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-506",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "모니터 6번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-507",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 7번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-508",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 8번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-509",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 9번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-510",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 10번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-511",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 11번이 켜지지 않아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"error": "too many requests (max 10)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "400이면 하나도 만들지 않음",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-401",
|
||||||
|
"title": "회의실 화면 공유가 안 돼요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-402",
|
||||||
|
"title": "비밀번호를 바꾸라는 창이 계속 떠요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "평소 요청은 그대로",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
$ uv run tools/flow.py push answers/76-lab01-challenge2-rules.json
|
||||||
|
! unpublished '76 Intake · LLM fallback (answer, Lab 01 task 1)' (w0f5e1ef11aa4c14): it used the same webhook path
|
||||||
|
✔ pushed '76 Intake · request rules (answer, Lab 01 task 2)' (id w4bb85fc171aea9d)
|
||||||
|
POST http://localhost:5678/webhook/my-intake
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 16}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 16
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1
|
||||||
|
✔ 같은 id가 두 번이면 두 번째는 거절 · 3.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "duplicate id in this request"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 11건은 400 (한 번에 10건까지) · 0.2s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-501",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 1번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-502",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 2번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-503",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 3번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-504",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 4번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-505",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 5번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-506",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "모니터 6번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-507",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "모니터 7번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-508",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "모니터 8번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-509",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "모니터 9번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-510",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "모니터 10번이 켜지지 않아요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-511",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "모니터 11번이 켜지지 않아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 400
|
||||||
|
{
|
||||||
|
"error": "too many requests (max 10)"
|
||||||
|
}
|
||||||
|
✔ 400이면 하나도 만들지 않음 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-401",
|
||||||
|
"title": "회의실 화면 공유가 안 돼요",
|
||||||
|
"requester": "김민지",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-402",
|
||||||
|
"title": "비밀번호를 바꾸라는 창이 계속 떠요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 평소 요청은 그대로 · 3.7s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Passed 4/4
|
||||||
|
report: runs/lab01-challenge2-20261029-141401.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ 정상 3건 · 2.8s
|
||||||
|
✔ 같은 요청을 다시 보내도 중복 없음 · 2.8s
|
||||||
|
✔ 만들어진 접수 건은 3개 · 0.0s
|
||||||
|
✔ 잘못된 항목은 그 항목만 거절 · 1.8s
|
||||||
|
✔ 목록이 아니면 400 · 0.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.4s
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab01-20261029-141411.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-challenge.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 3 case(s) × 1
|
||||||
|
✔ 평소에는 분류됨 · 2.0s
|
||||||
|
✔ OpenAI credential: broken key (drill). Restore with: uv run tools/flow.py llm up
|
||||||
|
✔ LLM이 멈추면 미분류로 접수 · 1.4s
|
||||||
|
✔ OpenAI credential: real key from .env restored
|
||||||
|
✔ 미분류도 접수 건으로 · 0.0s
|
||||||
|
Passed 3/3
|
||||||
|
report: runs/lab01-challenge-20261029-141421.json
|
||||||
|
✔ OpenAI credential: real key from .env restored
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 2,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "예제 1 · API가 멈추면 실패로 표시 · 200",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 1 · 실패한 2건은 DLQ에",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"size = 0 (expected 2)",
|
||||||
|
"items has none of ['R-301', 'R-302']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 2 · 예상 못 한 오류 (nightly-report)",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 2 · Error Workflow가 알림을 남김",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"severity = null (expected critical)",
|
||||||
|
"reason = null (expected unexpected)",
|
||||||
|
"workflow = null (expected 76 Nightly report (provided, has a bug))",
|
||||||
|
"node has none of ['Count by category']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
$ uv run tools/flow.py ops --reset
|
||||||
|
✔ created tickets, DLQ and alerts emptied, fault switch all ok
|
||||||
|
|
||||||
|
$ uv run tools/flow.py fault tickets error
|
||||||
|
✔ fault switch: {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call my-intake requests/batch-two.json
|
||||||
|
HTTP 200 · 2.8s
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py fault tickets ok
|
||||||
|
✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 0
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 0
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call nightly-report
|
||||||
|
HTTP 500 · 0.1s
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.8s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 예제 1 · 실패한 2건은 DLQ에 · 0.0s · size = 0 (expected 2); items has none of ['R-301', 'R-302']
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s
|
||||||
|
→ request POST /webhook/nightly-report
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
… before: wait 3s
|
||||||
|
✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); reason = null (expected unexpected); workflow = null (expected 76 Nightly report (provided, has a bug)); node has none of ['Count by category']
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{}
|
||||||
|
Passed 2/4
|
||||||
|
report: runs/lab02-20261029-141433.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 3,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "예제 1 · API가 멈추면 실패로 표시 · 200",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 4.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 1 · 실패한 2건은 DLQ에",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250889.918716
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250889.918718
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 2 · 예상 못 한 오류 (nightly-report)",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 2 · Error Workflow가 알림을 남김",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"severity = null (expected critical)",
|
||||||
|
"reason = null (expected unexpected)",
|
||||||
|
"workflow = null (expected 76 Nightly report (provided, has a bug))",
|
||||||
|
"node has none of ['Count by category']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab02-example1-dlq.json
|
||||||
|
! unpublished '76 Intake · request rules (answer, Lab 01 task 2)' (w4bb85fc171aea9d): it used the same webhook path
|
||||||
|
✔ pushed '76 Intake · DLQ (reference, Lab 02 example 1, includes Lab 01 tasks)' (id w92de92194fc1657)
|
||||||
|
POST http://localhost:5678/webhook/my-intake
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.1s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250889.918716
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250889.918718
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s
|
||||||
|
→ request POST /webhook/nightly-report
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
… before: wait 3s
|
||||||
|
✘ 예제 2 · Error Workflow가 알림을 남김 · 0.0s · severity = null (expected critical); reason = null (expected unexpected); workflow = null (expected 76 Nightly report (provided, has a bug)); node has none of ['Count by category']
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{}
|
||||||
|
Passed 3/4
|
||||||
|
report: runs/lab02-20261029-141452.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 0
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
alerts: 0
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 4,
|
||||||
|
"total": 4,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "예제 1 · API가 멈추면 실패로 표시 · 200",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 4.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 1 · 실패한 2건은 DLQ에",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250914.161773
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250914.161775
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 2 · 예상 못 한 오류 (nightly-report)",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "예제 2 · Error Workflow가 알림을 남김",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10104",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793250914.256605
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab02-example2-error-handler.json reference/lab02-example2-intake.json
|
||||||
|
! unpublished '76 Intake · DLQ (reference, Lab 02 example 1, includes Lab 01 tasks)' (w92de92194fc1657): it used the same webhook path
|
||||||
|
✔ pushed '76 Error handler (reference)' (id errHandler76001)
|
||||||
|
✔ pushed '76 Intake · DLQ + error workflow (reference, Lab 02 example 2)' (id w01376519d41c1ce)
|
||||||
|
POST http://localhost:5678/webhook/my-intake
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 4.8s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250914.161773
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250914.161775
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.1s
|
||||||
|
→ request POST /webhook/nightly-report
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10104",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793250914.256605
|
||||||
|
}
|
||||||
|
Passed 4/4
|
||||||
|
report: runs/lab02-20261029-141517.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 0
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
alerts: 1
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ 정상 3건 · 2.9s
|
||||||
|
✔ 같은 요청을 다시 보내도 중복 없음 · 2.6s
|
||||||
|
✔ 만들어진 접수 건은 3개 · 0.0s
|
||||||
|
✔ 잘못된 항목은 그 항목만 거절 · 1.9s
|
||||||
|
✔ 목록이 아니면 400 · 0.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "flaky", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 가끔 실패하는 API · 다시 해 보기로 성공 · 2.5s
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab01-20261029-141527.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-nets.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 7,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "1 · 잘못된 항목 → 결과에만 (rejected)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "2 · API 오류 → failed",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "3 · failed는 DLQ에",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250932.1935513
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250932.1935537
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "4 · 버그 → 500",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "5 · Error Workflow → critical 알림",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10114",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793250932.293378
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "6 · DLQ마저 멈추면 결과가 알려야",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"dlq_saved = null (expected false)"
|
||||||
|
],
|
||||||
|
"seconds": 4.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down)",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"reason = unexpected (expected dlq_down)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10114",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793250932.293378
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab02-nets.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 7}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 7
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1
|
||||||
|
✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 1.7s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 2 · API 오류 → failed · 2.8s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 3 · failed는 DLQ에 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250932.1935513
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250932.1935537
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 4 · 버그 → 500 · 0.0s
|
||||||
|
→ request POST /webhook/nightly-report
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 5 · Error Workflow → critical 알림 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10114",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793250932.293378
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "error",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 6 · DLQ마저 멈추면 결과가 알려야 · 4.1s · dlq_saved = null (expected false)
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s · reason = unexpected (expected dlq_down)
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10114",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793250932.293378
|
||||||
|
}
|
||||||
|
Passed 5/7
|
||||||
|
report: runs/lab02-nets-20261029-141539.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 2
|
||||||
|
T-2001 key R-201 장비/보통 프린터 용지가 걸려요
|
||||||
|
T-2002 key R-203 소프트웨어/보통 메일 첨부가 안 열려요
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
alerts: 1
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 0,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "API가 멈춤 → DLQ",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"dlq_saved = null (expected true)",
|
||||||
|
"alert = null (expected sent)"
|
||||||
|
],
|
||||||
|
"seconds": 2.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "처리한 실패도 알림 (high · 개수)",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"severity = null (expected high)",
|
||||||
|
"reason = null (expected dlq)",
|
||||||
|
"count = null (expected 2)",
|
||||||
|
"workflow = null (expected intake)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "실패가 없으면 알림 없음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"alert = null (expected none)"
|
||||||
|
],
|
||||||
|
"seconds": 2.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "알림은 그대로 1개",
|
||||||
|
"webhook": "http://localhost:8800/alerts/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"count = 0 (expected 1)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 0,
|
||||||
|
"alerts": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ도 멈추면 critical",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"dlq_saved = null (expected false)"
|
||||||
|
],
|
||||||
|
"seconds": 2.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "잃어버린 id를 알림에",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"severity = null (expected critical)",
|
||||||
|
"reason = null (expected dlq_down)",
|
||||||
|
"count = null (expected 2)",
|
||||||
|
"message has none of ['R-201']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 1
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ API가 멈춤 → DLQ · 2.7s · dlq_saved = null (expected true); alert = null (expected sent)
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 처리한 실패도 알림 (high · 개수) · 0.0s · severity = null (expected high); reason = null (expected dlq); count = null (expected 2); workflow = null (expected intake)
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{}
|
||||||
|
✘ 실패가 없으면 알림 없음 · 2.8s · alert = null (expected none)
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 알림은 그대로 1개 · 0.0s · count = 0 (expected 1)
|
||||||
|
→ request POST http://localhost:8800/alerts/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 0,
|
||||||
|
"alerts": []
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "error",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ DLQ도 멈추면 critical · 2.4s · dlq_saved = null (expected false)
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 잃어버린 id를 알림에 · 0.0s · severity = null (expected critical); reason = null (expected dlq_down); count = null (expected 2); message has none of ['R-201']
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{}
|
||||||
|
Passed 0/6
|
||||||
|
report: runs/lab02-challenge-20261029-141547.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 6,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "API가 멈춤 → DLQ",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 4.3,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "처리한 실패도 알림 (high · 개수)",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10120",
|
||||||
|
"at": 1793250965.2341518
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "실패가 없으면 알림 없음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "알림은 그대로 1개",
|
||||||
|
"webhook": "http://localhost:8800/alerts/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 1,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10120",
|
||||||
|
"at": 1793250965.2341518
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ도 멈추면 critical",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": false,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "잃어버린 id를 알림에",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "dlq_down",
|
||||||
|
"count": 2,
|
||||||
|
"message": "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203",
|
||||||
|
"execution_id": "10122",
|
||||||
|
"ids": [
|
||||||
|
"R-201",
|
||||||
|
"R-203"
|
||||||
|
],
|
||||||
|
"at": 1793250970.5394928
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
$ uv run tools/flow.py push answers/76-lab02-challenge-alert.json
|
||||||
|
! unpublished '76 Intake · DLQ + error workflow (reference, Lab 02 example 2)' (w01376519d41c1ce): it used the same webhook path
|
||||||
|
✔ pushed '76 Intake · alert on handled failures (answer, Lab 02 task 1)' (id w82959c505ef75f7)
|
||||||
|
POST http://localhost:5678/webhook/my-intake
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 3
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ API가 멈춤 → DLQ · 4.3s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 처리한 실패도 알림 (high · 개수) · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10120",
|
||||||
|
"at": 1793250965.2341518
|
||||||
|
}
|
||||||
|
✔ 실패가 없으면 알림 없음 · 2.9s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "낮음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
✔ 알림은 그대로 1개 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10120",
|
||||||
|
"at": 1793250965.2341518
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "error",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ DLQ도 멈추면 critical · 2.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": false,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 잃어버린 id를 알림에 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "dlq_down",
|
||||||
|
"count": 2,
|
||||||
|
"message": "DLQ save failed: dlq service is failing (fault switch) · lost ids R-201, R-203",
|
||||||
|
"execution_id": "10122",
|
||||||
|
"ids": [
|
||||||
|
"R-201",
|
||||||
|
"R-203"
|
||||||
|
],
|
||||||
|
"at": 1793250970.5394928
|
||||||
|
}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-challenge-20261029-141610.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-nets.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1
|
||||||
|
✔ 1 · 잘못된 항목 → 결과에만 (rejected) · 2.1s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 2 · API 오류 → failed · 2.9s
|
||||||
|
✔ 3 · failed는 DLQ에 · 0.0s
|
||||||
|
✔ 4 · 버그 → 500 · 0.1s
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 5 · Error Workflow → critical 알림 · 0.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
✔ 6 · DLQ마저 멈추면 결과가 알려야 · 3.7s
|
||||||
|
✔ 7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down) · 0.0s
|
||||||
|
Passed 7/7
|
||||||
|
report: runs/lab02-nets-20261029-141622.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 예제 1 · API가 멈추면 실패로 표시 · 200 · 2.7s
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 예제 1 · 실패한 2건은 DLQ에 · 0.0s
|
||||||
|
✔ 예제 2 · 예상 못 한 오류 (nightly-report) · 0.0s
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 예제 2 · Error Workflow가 알림을 남김 · 0.0s
|
||||||
|
Passed 4/4
|
||||||
|
report: runs/lab02-20261029-141628.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 0
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
alerts: 2
|
||||||
|
[high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge2.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 4,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "첫 실패 → 알림 보냄",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "10분 안에 또 실패 → 보내지 않음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"alert = sent (expected suppressed)"
|
||||||
|
],
|
||||||
|
"seconds": 3.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "dlq 알림은 1개",
|
||||||
|
"webhook": "http://localhost:8800/alerts/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"count = 2 (expected 1)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"reason": "dlq"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"count": 2,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10131",
|
||||||
|
"at": 1793250991.527381
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 3,
|
||||||
|
"message": "3 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10132",
|
||||||
|
"at": 1793250995.206434
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "그래도 DLQ에는 5건 모두",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 5,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250991.5196192
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250991.5196211
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-3",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250995.1972318
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-4",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250995.1972342
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-5",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250995.1972353
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ가 멈춘 것은 늘 보냄 (critical)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.5,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": false,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "고친 뒤 · 실패 없음 · 알림 없음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "duplicate id in this request"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 첫 실패 → 알림 보냄 · 2.9s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✘ 10분 안에 또 실패 → 보내지 않음 · 3.7s · alert = sent (expected suppressed)
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✘ dlq 알림은 1개 · 0.0s · count = 2 (expected 1)
|
||||||
|
→ request POST http://localhost:8800/alerts/list
|
||||||
|
{
|
||||||
|
"reason": "dlq"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10131",
|
||||||
|
"at": 1793250991.527381
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 3,
|
||||||
|
"message": "3 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10132",
|
||||||
|
"at": 1793250995.206434
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 그래도 DLQ에는 5건 모두 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 5,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250991.5196192
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250991.5196211
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-3",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250995.1972318
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-4",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250995.1972342
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-5",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793250995.1972353
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "error",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.5s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": false,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.0s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "duplicate id in this request"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
Passed 4/6
|
||||||
|
report: runs/lab02-challenge2-20261029-141639.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab02-challenge2.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 6,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "첫 실패 → 알림 보냄",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 4.5,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "10분 안에 또 실패 → 보내지 않음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "suppressed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "dlq 알림은 1개",
|
||||||
|
"webhook": "http://localhost:8800/alerts/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"reason": "dlq"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"count": 1,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10135",
|
||||||
|
"at": 1793251017.2530532
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "그래도 DLQ에는 5건 모두",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 5,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251017.2315063
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251017.2315085
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-3",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251020.9757996
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-4",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251020.9758017
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-5",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251020.975803
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ가 멈춘 것은 늘 보냄 (critical)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": false,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "고친 뒤 · 실패 없음 · 알림 없음",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.8,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "duplicate id in this request"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
$ uv run tools/flow.py push answers/76-lab02-challenge2-throttle.json
|
||||||
|
! unpublished '76 Intake · alert on handled failures (answer, Lab 02 task 1)' (w82959c505ef75f7): it used the same webhook path
|
||||||
|
✔ pushed '76 Intake · alert once per 10 minutes (answer, Lab 02 task 2)' (id w75a95815facf2d6)
|
||||||
|
POST http://localhost:5678/webhook/my-intake
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 5}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 5
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 3
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 첫 실패 → 알림 보냄 · 4.5s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ 10분 안에 또 실패 → 보내지 않음 · 3.7s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "suppressed"
|
||||||
|
}
|
||||||
|
✔ dlq 알림은 1개 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/list
|
||||||
|
{
|
||||||
|
"reason": "dlq"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10135",
|
||||||
|
"at": 1793251017.2530532
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 그래도 DLQ에는 5건 모두 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 5,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251017.2315063
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251017.2315085
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-3",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251020.9757996
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-4",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251020.9758017
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-5",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251020.975803
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "error",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": "프린터 용지가 걸려요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"user": "한지훈",
|
||||||
|
"text": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "메일 첨부가 안 열려요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-201",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-203",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-202",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "text is required"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": false,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 고친 뒤 · 실패 없음 · 알림 없음 · 2.8s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"user": "김민지",
|
||||||
|
"text": "회의실 화면 공유가 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "비밀번호를 바꾸라는 창이 계속 떠요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-402",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-401",
|
||||||
|
"status": "rejected",
|
||||||
|
"reason": "duplicate id in this request"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-challenge2-20261029-141706.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-challenge.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 5}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ API가 멈춤 → DLQ · 2.7s
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 처리한 실패도 알림 (high · 개수) · 0.0s
|
||||||
|
✔ 실패가 없으면 알림 없음 · 2.9s
|
||||||
|
✔ 알림은 그대로 1개 · 0.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
✔ DLQ도 멈추면 critical · 2.3s
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 잃어버린 id를 알림에 · 0.0s
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-challenge-20261029-141714.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab01-challenge2.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 4 case(s) × 1
|
||||||
|
✔ 같은 id가 두 번이면 두 번째는 거절 · 2.1s
|
||||||
|
✔ 11건은 400 (한 번에 10건까지) · 0.0s
|
||||||
|
✔ 400이면 하나도 만들지 않음 · 0.0s
|
||||||
|
✔ 평소 요청은 그대로 · 2.7s
|
||||||
|
Passed 4/4
|
||||||
|
report: runs/lab01-challenge2-20261029-141719.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 5
|
||||||
|
T-2001 key R-401 네트워크/높음 회의실 화면 공유가 안 돼요
|
||||||
|
T-2002 key R-402 계정/보통 비밀번호를 바꾸라는 창이 계속 떠요
|
||||||
|
T-2003 key R-101 네트워크/높음 VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있
|
||||||
|
T-2004 key R-102 장비/보통 새 모니터를 하나 더 받을 수 있을까요?
|
||||||
|
T-2005 key R-103 소프트웨어/높음 엑셀 라이선스가 만료됐다고 나와요
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 0
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 1,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "API가 멈춘 동안 2건 → DLQ",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "고친 뒤 다시 돌리기",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"replayed = null (expected 2)",
|
||||||
|
"ok = null (expected 2)",
|
||||||
|
"still_failed = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-replay\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ가 비었음",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"size = 2 (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251042.6309726
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251042.630976
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "접수 건은 2개 (중복 없음)",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"count = 0 (expected 2)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 0,
|
||||||
|
"tickets": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "한 번 더 돌려도 할 일 없음",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"replayed = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-replay\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 5
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ API가 멈춘 동안 2건 → DLQ · 2.9s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 고친 뒤 다시 돌리기 · 0.0s · status 404 (expected 200); replayed = null (expected 2); ok = null (expected 2); still_failed = null (expected 0)
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-replay\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✘ DLQ가 비었음 · 0.0s · size = 2 (expected 0)
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251042.6309726
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251042.630976
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 접수 건은 2개 (중복 없음) · 0.0s · count = 0 (expected 2)
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 0,
|
||||||
|
"tickets": []
|
||||||
|
}
|
||||||
|
✘ 한 번 더 돌려도 할 일 없음 · 0.0s · status 404 (expected 200); replayed = null (expected 0)
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-replay\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
Passed 1/5
|
||||||
|
report: runs/lab03-20261029-141722.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 5,
|
||||||
|
"total": 5,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "API가 멈춘 동안 2건 → DLQ",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 4.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "고친 뒤 다시 돌리기",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "DLQ가 비었음",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "접수 건은 2개 (중복 없음)",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "한 번 더 돌려도 할 일 없음",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 0,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
$ uv run tools/flow.py push reference/lab03-example1-replay.json
|
||||||
|
✔ pushed '76 Replay (reference, Lab 03 example 1)' (id we4c5fdffca93da1)
|
||||||
|
POST http://localhost:5678/webhook/my-replay
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 1
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ API가 멈춘 동안 2건 → DLQ · 4.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 고친 뒤 다시 돌리기 · 1.1s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ DLQ가 비었음 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
✔ 접수 건은 2개 (중복 없음) · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 한 번 더 돌려도 할 일 없음 · 0.0s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 0,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0
|
||||||
|
}
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab03-20261029-141738.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 2
|
||||||
|
T-2001 key R-302 장비/보통 노트북 배터리가 금방 닳아요
|
||||||
|
T-2002 key R-301 계정/높음 사내 위키 로그인이 안 돼요
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 1
|
||||||
|
[high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-drill.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 7,
|
||||||
|
"total": 7,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "1 · 평소",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.6,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "2 · 장애 시작 · API 오류",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "3 · DLQ 확인",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251069.7296107
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251069.7296135
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "4 · 보고서 작업도 실패",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "5 · 알림 확인",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10159",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793251069.8482687
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "6 · 고친 뒤 재처리",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "7 · 결과 확인 · 중복 없이 5건",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 5,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
$ uv run tools/flow.py ops --reset
|
||||||
|
✔ created tickets, DLQ and alerts emptied, fault switch all ok
|
||||||
|
|
||||||
|
$ uv run tools/flow.py fault tickets error
|
||||||
|
✔ fault switch: {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call my-intake requests/batch-two.json
|
||||||
|
HTTP 200 · 2.8s
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call nightly-report
|
||||||
|
HTTP 500 · 0.1s
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: {"tickets": "error"}
|
||||||
|
created tickets: 0
|
||||||
|
DLQ: 2 (parked 0)
|
||||||
|
D-1 R-301 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
D-2 R-302 attempts 1 parked False 500 - "{\"error\": \"tickets service is failing (f
|
||||||
|
alerts: 2
|
||||||
|
[high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
|
|
||||||
|
$ uv run tools/flow.py fault tickets ok
|
||||||
|
✔ fault switch: {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call my-replay
|
||||||
|
HTTP 200 · 1.4s
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 2
|
||||||
|
T-2001 key R-302 장비/보통 노트북 배터리가 금방 닳아요
|
||||||
|
T-2002 key R-301 계정/높음 사내 위키 로그인이 안 돼요
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 2
|
||||||
|
[high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ
|
||||||
|
[critical] 76 Nightly report (provided, has a bug) · unexpected · Count by category · rows is not iterable [line 4]
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-drill.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1
|
||||||
|
✔ 1 · 평소 · 2.6s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"user": "박서연",
|
||||||
|
"text": "새 모니터를 하나 더 받을 수 있을까요?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"user": "최유진",
|
||||||
|
"text": "엑셀 라이선스가 만료됐다고 나와요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 3,
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-101",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-102",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-103",
|
||||||
|
"status": "created",
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"duplicate": false,
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 2 · 장애 시작 · API 오류 · 3.0s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ 3 · DLQ 확인 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251069.7296107
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251069.7296135
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 4 · 보고서 작업도 실패 · 0.0s
|
||||||
|
→ request POST /webhook/nightly-report
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 5 · 알림 확인 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)",
|
||||||
|
"workflow_id": "wb9ce32c18d7df56",
|
||||||
|
"node": "Count by category",
|
||||||
|
"message": "rows is not iterable [line 4]",
|
||||||
|
"execution_id": "10159",
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"at": 1793251069.8482687
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 6 · 고친 뒤 재처리 · 1.4s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ 7 · 결과 확인 · 중복 없이 5건 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 5,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-101",
|
||||||
|
"title": "VPN이 아침부터 계속 끊겨서 고객 미팅을 못 하고 있어요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "네트워크",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-102",
|
||||||
|
"title": "새 모니터를 하나 더 받을 수 있을까요?",
|
||||||
|
"requester": "박서연",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2003",
|
||||||
|
"key": "R-103",
|
||||||
|
"title": "엑셀 라이선스가 만료됐다고 나와요",
|
||||||
|
"requester": "최유진",
|
||||||
|
"category": "소프트웨어",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2004",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2005",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Passed 7/7
|
||||||
|
report: runs/lab03-drill-20261029-141754.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-half.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 6,
|
||||||
|
"total": 6,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "1 · API가 느림 → 기다리다 실패",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 11.6,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "2 · DLQ에 2건",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "timeout of 3000ms exceeded",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251086.0805922
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "timeout of 3000ms exceeded",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251086.080595
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "3 · 그런데 접수 건은 이미 2개",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "4 · 재처리 · 이미 있는 건이라 duplicate",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "5 · 그래도 2개 (중복 없음)",
|
||||||
|
"webhook": "http://localhost:8800/tickets-created/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "6 · DLQ가 비었음",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03-half.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 5}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 5
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "slow", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "slow"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "slow",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 1 · API가 느림 → 기다리다 실패 · 11.6s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "timeout of 3000ms exceeded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ 2 · DLQ에 2건 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "timeout of 3000ms exceeded",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251086.0805922
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 1,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "timeout of 3000ms exceeded",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251086.080595
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
… before: wait 8s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 3 · 그런데 접수 건은 이미 2개 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 4 · 재처리 · 이미 있는 건이라 duplicate · 1.1s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 2
|
||||||
|
}
|
||||||
|
✔ 5 · 그래도 2개 (중복 없음) · 0.0s
|
||||||
|
→ request POST http://localhost:8800/tickets-created/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2001",
|
||||||
|
"key": "R-301",
|
||||||
|
"title": "사내 위키 로그인이 안 돼요",
|
||||||
|
"requester": "정우성",
|
||||||
|
"category": "계정",
|
||||||
|
"priority": "높음"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ticket_id": "T-2002",
|
||||||
|
"key": "R-302",
|
||||||
|
"title": "노트북 배터리가 금방 닳아요",
|
||||||
|
"requester": "이준호",
|
||||||
|
"category": "장비",
|
||||||
|
"priority": "보통"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 6 · DLQ가 비었음 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab03-half-20261029-141815.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 2
|
||||||
|
T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요
|
||||||
|
T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 1
|
||||||
|
[high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 3,
|
||||||
|
"total": 7,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "API가 오래 멈춤 · 처음 실패",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "재처리 1 · 또 실패",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "재처리 2 · 또 실패",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.4,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "재처리 3 · 세 번 실패한 것은 멈춤 (parked)",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"replayed = 2 (expected 0)",
|
||||||
|
"parked = 0 (expected 2)"
|
||||||
|
],
|
||||||
|
"seconds": 2.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "parked로 남음",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"parked = 0 (expected 2)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 4,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251105.2922482
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 4,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251105.5231082
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "멈춘 것은 알림",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"reason = dlq (expected parked)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10168",
|
||||||
|
"at": 1793251099.0033967
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "고친 뒤에도 parked는 자동으로 돌리지 않음",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"replayed = 2 (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 1.6,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 1
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ API가 오래 멈춤 · 처음 실패 · 3.4s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ 재처리 1 · 또 실패 · 2.2s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ 재처리 2 · 또 실패 · 2.4s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✘ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 2.0s · replayed = 2 (expected 0); parked = 0 (expected 2)
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✘ parked로 남음 · 0.0s · parked = 0 (expected 2)
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 4,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251105.2922482
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 4,
|
||||||
|
"parked": false,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251105.5231082
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✘ 멈춘 것은 알림 · 0.0s · reason = dlq (expected parked)
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "intake",
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"message": "2 request(s) could not be created and wait in the DLQ",
|
||||||
|
"execution_id": "10168",
|
||||||
|
"at": 1793251099.0033967
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 1.6s · replayed = 2 (expected 0)
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
Passed 3/7
|
||||||
|
report: runs/lab03-challenge-20261029-141827.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge.json",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"passed": 7,
|
||||||
|
"total": 7,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "API가 오래 멈춤 · 처음 실패",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 4.3,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "재처리 1 · 또 실패",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "재처리 2 · 또 실패",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "재처리 3 · 세 번 실패한 것은 멈춤 (parked)",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 0,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 2,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "parked로 남음",
|
||||||
|
"webhook": "http://localhost:8800/dlq/list",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 3,
|
||||||
|
"parked": true,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251128.6803358
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 3,
|
||||||
|
"parked": true,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251128.9017823
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "멈춘 것은 알림",
|
||||||
|
"webhook": "http://localhost:8800/alerts/latest",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"workflow": "replay",
|
||||||
|
"node": "Park it",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "parked",
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"message": "D-2 (R-302) parked after 3 attempts",
|
||||||
|
"at": 1793251129.0694785
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "고친 뒤에도 parked는 자동으로 돌리지 않음",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 0,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
$ uv run tools/flow.py push answers/76-lab03-challenge-park.json
|
||||||
|
! unpublished '76 Replay (reference, Lab 03 example 1)' (we4c5fdffca93da1): it used the same webhook path
|
||||||
|
✔ pushed '76 Replay · park after 3 attempts (answer, Lab 03 task 1)' (id w7d8de63c811909b)
|
||||||
|
POST http://localhost:5678/webhook/my-replay
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 1
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ API가 오래 멈춤 · 처음 실패 · 4.3s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ 재처리 1 · 또 실패 · 1.9s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ 재처리 2 · 또 실패 · 2.1s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 0,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 2,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ parked로 남음 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/dlq/list
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"size": 2,
|
||||||
|
"parked": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dlq_id": "D-1",
|
||||||
|
"attempts": 3,
|
||||||
|
"parked": true,
|
||||||
|
"item": {
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
"reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251128.6803358
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"attempts": 3,
|
||||||
|
"parked": true,
|
||||||
|
"item": {
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
},
|
||||||
|
"reason": "gave up after 3 attempts: 500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\"",
|
||||||
|
"workflow": "intake",
|
||||||
|
"at": 1793251128.9017823
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 멈춘 것은 알림 · 0.0s
|
||||||
|
→ request POST http://localhost:8800/alerts/latest
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"workflow": "replay",
|
||||||
|
"node": "Park it",
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "parked",
|
||||||
|
"dlq_id": "D-2",
|
||||||
|
"message": "D-2 (R-302) parked after 3 attempts",
|
||||||
|
"at": 1793251129.0694785
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 0,
|
||||||
|
"ok": 0,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0
|
||||||
|
}
|
||||||
|
Passed 7/7
|
||||||
|
report: runs/lab03-challenge-20261029-141849.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 5 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ API가 멈춘 동안 2건 → DLQ · 3.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 고친 뒤 다시 돌리기 · 1.4s
|
||||||
|
✔ DLQ가 비었음 · 0.0s
|
||||||
|
✔ 접수 건은 2개 (중복 없음) · 0.0s
|
||||||
|
✔ 한 번 더 돌려도 할 일 없음 · 0.0s
|
||||||
|
Passed 5/5
|
||||||
|
report: runs/lab03-20261029-141853.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops
|
||||||
|
fault switch: all ok
|
||||||
|
created tickets: 2
|
||||||
|
T-2001 key R-301 계정/높음 사내 위키 로그인이 안 돼요
|
||||||
|
T-2002 key R-302 장비/보통 노트북 배터리가 금방 닳아요
|
||||||
|
DLQ: 0 (parked 0)
|
||||||
|
alerts: 1
|
||||||
|
[high] intake · dlq · Send to DLQ · count 2 · 2 request(s) could not be created and wait in the DLQ
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge2.json",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"passed": 3,
|
||||||
|
"total": 7,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "1 · 아무 일 없음 → ok",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"status = null (expected ok)",
|
||||||
|
"dlq = null (expected 0)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "2 · (접수 실패 2건)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 2.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "3 · DLQ가 있으면 warn",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"status = null (expected warn)",
|
||||||
|
"dlq = null (expected 2)",
|
||||||
|
"reasons has none of ['재처리']"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "4 · (보고서 버그)",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "5 · critical 알림이 있으면 critical",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"status = null (expected critical)",
|
||||||
|
"alerts.critical = null (expected 1)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "6 · (재처리 · 알림 확인 뒤 비움)",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "7 · 고친 뒤 → ok",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": false,
|
||||||
|
"problems": [
|
||||||
|
"status 404 (expected 200)",
|
||||||
|
"status = null (expected ok)",
|
||||||
|
"dlq = null (expected 0)",
|
||||||
|
"created = null (expected 2)"
|
||||||
|
],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 404,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 1
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-status · 7 case(s) × 1
|
||||||
|
✘ 1 · 아무 일 없음 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0)
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 2 · (접수 실패 2건) · 2.9s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✘ 3 · DLQ가 있으면 warn · 0.0s · status 404 (expected 200); status = null (expected warn); dlq = null (expected 2); reasons has none of ['재처리']
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✔ 4 · (보고서 버그) · 0.0s
|
||||||
|
→ request POST /webhook/nightly-report
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
… before: wait 3s
|
||||||
|
✘ 5 · critical 알림이 있으면 critical · 0.0s · status 404 (expected 200); status = null (expected critical); alerts.critical = null (expected 1)
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.1s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✘ 7 · 고친 뒤 → ok · 0.0s · status 404 (expected 200); status = null (expected ok); dlq = null (expected 0); created = null (expected 2)
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 404
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "The requested webhook \"POST my-ops-status\" is not registered.",
|
||||||
|
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
|
||||||
|
}
|
||||||
|
Passed 3/7
|
||||||
|
report: runs/lab03-challenge2-20261029-141901.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"cases": "lab03-challenge2.json",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"passed": 7,
|
||||||
|
"total": 7,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"case": "1 · 아무 일 없음 → ok",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.9,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"status": "ok",
|
||||||
|
"dlq": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 0,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 0,
|
||||||
|
"high": 0
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "2 · (접수 실패 2건)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 3.7,
|
||||||
|
"status": 200,
|
||||||
|
"request": {
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "3 · DLQ가 있으면 warn",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"status": "warn",
|
||||||
|
"dlq": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 0,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 0,
|
||||||
|
"high": 1
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"DLQ 2건: 원인을 고친 뒤 재처리",
|
||||||
|
"high 알림 1건 (10분)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "4 · (보고서 버그)",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 500,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "5 · critical 알림이 있으면 critical",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.1,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"status": "critical",
|
||||||
|
"dlq": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 0,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 1,
|
||||||
|
"high": 1
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"critical 알림 1건 (10분)",
|
||||||
|
"DLQ 2건: 원인을 고친 뒤 재처리",
|
||||||
|
"high 알림 1건 (10분)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "6 · (재처리 · 알림 확인 뒤 비움)",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 1.2,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"case": "7 · 고친 뒤 → ok",
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"run": 1,
|
||||||
|
"passed": true,
|
||||||
|
"problems": [],
|
||||||
|
"seconds": 0.0,
|
||||||
|
"status": 200,
|
||||||
|
"request": {},
|
||||||
|
"response": {
|
||||||
|
"status": "ok",
|
||||||
|
"dlq": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 2,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 0,
|
||||||
|
"high": 0
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
$ uv run tools/flow.py push answers/76-lab03-challenge2-ops-status.json
|
||||||
|
✔ pushed '76 Ops status (answer)' (id w07b5812eaa88ef9)
|
||||||
|
POST http://localhost:5678/webhook/my-ops-status
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/tickets-reset
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/dlq-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 0
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
POST http://localhost:5678/webhook/my-ops-status · 7 case(s) × 1
|
||||||
|
✔ 1 · 아무 일 없음 → ok · 0.9s
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"dlq": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 0,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 0,
|
||||||
|
"high": 0
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "error",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 2 · (접수 실패 2건) · 3.7s
|
||||||
|
→ request POST /webhook/my-intake
|
||||||
|
{
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"user": "정우성",
|
||||||
|
"text": "사내 위키 로그인이 안 돼요"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"user": "이준호",
|
||||||
|
"text": "노트북 배터리가 금방 닳아요"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"total": 2,
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2,
|
||||||
|
"rejected": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"unclassified": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "R-301",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "R-302",
|
||||||
|
"status": "failed",
|
||||||
|
"reason": "500 - \"{\\\"error\\\": \\\"tickets service is failing (fault switch)\\\"}\""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
✔ 3 · DLQ가 있으면 warn · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"status": "warn",
|
||||||
|
"dlq": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 0,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 0,
|
||||||
|
"high": 1
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"DLQ 2건: 원인을 고친 뒤 재처리",
|
||||||
|
"high 알림 1건 (10분)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 4 · (보고서 버그) · 0.1s
|
||||||
|
→ request POST /webhook/nightly-report
|
||||||
|
{}
|
||||||
|
← response HTTP 500
|
||||||
|
{
|
||||||
|
"message": "Error in workflow"
|
||||||
|
}
|
||||||
|
… before: wait 3s
|
||||||
|
✔ 5 · critical 알림이 있으면 critical · 0.1s
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"status": "critical",
|
||||||
|
"dlq": 2,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 0,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 1,
|
||||||
|
"high": 1
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"critical 알림 1건 (10분)",
|
||||||
|
"DLQ 2건: 원인을 고친 뒤 재처리",
|
||||||
|
"high 알림 1건 (10분)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
✔ 6 · (재처리 · 알림 확인 뒤 비움) · 1.2s
|
||||||
|
→ request POST /webhook/my-replay
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"duplicates": 0
|
||||||
|
}
|
||||||
|
✔ after: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
→ request POST http://localhost:8800/alerts-clear
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"cleared": 2
|
||||||
|
}
|
||||||
|
✔ 7 · 고친 뒤 → ok · 0.0s
|
||||||
|
→ request POST /webhook/my-ops-status
|
||||||
|
{}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"dlq": 0,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 2,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 0,
|
||||||
|
"high": 0
|
||||||
|
},
|
||||||
|
"reasons": []
|
||||||
|
}
|
||||||
|
Passed 7/7
|
||||||
|
report: runs/lab03-challenge2-20261029-141921.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
→ request POST http://localhost:8800/admin/fault
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
← response HTTP 200
|
||||||
|
{
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok",
|
||||||
|
"slow_seconds": 8
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab03-challenge.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 7 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ API가 오래 멈춤 · 처음 실패 · 2.8s
|
||||||
|
✔ 재처리 1 · 또 실패 · 1.9s
|
||||||
|
✔ 재처리 2 · 또 실패 · 2.5s
|
||||||
|
✔ 재처리 3 · 세 번 실패한 것은 멈춤 (parked) · 0.1s
|
||||||
|
✔ parked로 남음 · 0.0s
|
||||||
|
✔ 멈춘 것은 알림 · 0.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 고친 뒤에도 parked는 자동으로 돌리지 않음 · 0.0s
|
||||||
|
Passed 7/7
|
||||||
|
report: runs/lab03-challenge-20261029-141929.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py test cases/lab02-challenge2.json
|
||||||
|
✔ before: POST http://localhost:8800/tickets-reset · HTTP 200 · 0.0s · {"cleared": 0}
|
||||||
|
✔ before: POST http://localhost:8800/dlq-clear · HTTP 200 · 0.0s · {"cleared": 2}
|
||||||
|
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
POST http://localhost:5678/webhook/my-intake · 6 case(s) × 1
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 첫 실패 → 알림 보냄 · 2.9s
|
||||||
|
✔ 10분 안에 또 실패 → 보내지 않음 · 3.4s
|
||||||
|
✔ dlq 알림은 1개 · 0.0s
|
||||||
|
✔ 그래도 DLQ에는 5건 모두 · 0.0s
|
||||||
|
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "error", "prompts": "ok", "usage": "ok", "dlq": "error", "slow_seconds": 8}
|
||||||
|
✔ DLQ가 멈춘 것은 늘 보냄 (critical) · 2.8s
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
✔ 고친 뒤 · 실패 없음 · 알림 없음 · 3.4s
|
||||||
|
Passed 6/6
|
||||||
|
report: runs/lab02-challenge2-20261029-141941.json
|
||||||
|
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "usage": "ok", "dlq": "ok", "slow_seconds": 8}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py call my-ops-status
|
||||||
|
HTTP 200 · 0.1s
|
||||||
|
{
|
||||||
|
"status": "critical",
|
||||||
|
"dlq": 5,
|
||||||
|
"parked": 0,
|
||||||
|
"created": 2,
|
||||||
|
"alerts": {
|
||||||
|
"critical": 1,
|
||||||
|
"high": 1
|
||||||
|
},
|
||||||
|
"reasons": [
|
||||||
|
"critical 알림 1건 (10분)",
|
||||||
|
"DLQ 5건: 원인을 고친 뒤 재처리",
|
||||||
|
"high 알림 1건 (10분)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
$ uv run tools/flow.py ops --reset
|
||||||
|
✔ created tickets, DLQ and alerts emptied, fault switch all ok
|
||||||
|
|
||||||
|
$ uv run tools/flow.py doctor
|
||||||
|
! n8n install folder /Users/mcchae/day65-n8n
|
||||||
|
→ day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>
|
||||||
|
✔ n8n container day65-n8n-n8n-1
|
||||||
|
✔ n8n http://localhost:5678
|
||||||
|
✔ data API http://localhost:8800
|
||||||
|
✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API)
|
||||||
|
✔ n8n → http://data-api:8800
|
||||||
|
✔ credential 'OpenAI (.env)' (openAiCourse0001)
|
||||||
|
✔ node catalog (for check)
|
||||||
|
✔ CLI coding agent: codex, claude, agy
|
||||||
|
ready
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
{
|
||||||
|
"name": "76 Intake · LLM fallback (answer, Lab 01 task 1)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Intake webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-intake",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Is a list?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ Array.isArray($json.body.requests) }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reject body",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "{ \"error\": \"requests must be a list\" }",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check items",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Valid?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.valid }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $json.text }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
420
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Parse",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create ticket",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/tickets",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ key: $json.id, title: $json.text, requester: $json.user, category: $json.category, priority: $json.priority }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 300,
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark results",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark rejected",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
640
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Collect",
|
||||||
|
"type": "n8n-nodes-base.merge",
|
||||||
|
"typeVersion": 3,
|
||||||
|
"parameters": {
|
||||||
|
"numberInputs": 2
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Summarize",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Public result",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const { failed_requests, ...rest } = $('Summarize').first().json;\nreturn [{ json: rest }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3300,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send result",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3520,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Intake webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Is a list?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Is a list?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check items",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Reject body",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check items": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Valid?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Valid?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark rejected",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Create ticket",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Create ticket": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark results",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark results": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark rejected": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Collect": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Public result": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Public result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
{
|
||||||
|
"name": "76 Intake · request rules (answer, Lab 01 task 2)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Intake webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-intake",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Is a list?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reject body",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check items",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Valid?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.valid }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $json.text }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
420
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Parse",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create ticket",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/tickets",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ key: $json.id, title: $json.text, requester: $json.user, category: $json.category, priority: $json.priority }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 300,
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark results",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark rejected",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
640
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Collect",
|
||||||
|
"type": "n8n-nodes-base.merge",
|
||||||
|
"typeVersion": 3,
|
||||||
|
"parameters": {
|
||||||
|
"numberInputs": 2
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Summarize",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Public result",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const { failed_requests, ...rest } = $('Summarize').first().json;\nreturn [{ json: rest }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3300,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send result",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3520,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Intake webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Is a list?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Is a list?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check items",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Reject body",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check items": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Valid?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Valid?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark rejected",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Create ticket",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Create ticket": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark results",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark results": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark rejected": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Collect": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Public result": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Public result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
{
|
||||||
|
"name": "76 Intake · alert on handled failures (answer, Lab 02 task 1)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Intake webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-intake",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Is a list?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reject body",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check items",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Valid?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.valid }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $json.text }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
420
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Parse",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create ticket",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/tickets",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ key: $json.id, title: $json.text, requester: $json.user, category: $json.category, priority: $json.priority }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 300,
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark results",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark rejected",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
640
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Collect",
|
||||||
|
"type": "n8n-nodes-base.merge",
|
||||||
|
"typeVersion": 3,
|
||||||
|
"parameters": {
|
||||||
|
"numberInputs": 2
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Summarize",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Public result",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst { failed_requests, ...rest } = $('Summarize').first().json;\nconst out = { ...rest };\nif (true) {\n if (ran('Send to DLQ')) out.dlq_saved = Number($('Send to DLQ').first().json.stored ?? 0) > 0;\n out.alert = ran('Send alert') ? 'sent' : (ran('Decide alert') && $('Decide alert').first().json.suppressed ? 'suppressed' : 'none');\n}\nreturn [{ json: out }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3300,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send result",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3520,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Any failed?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.failed > 0 }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send to DLQ",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/dlq",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ items: $json.failed_requests.map((r, i) => ({ item: r, reason: $json.results.filter((x) => x.status === 'failed')[i]?.reason ?? 'failed', workflow: 'intake' })) }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2420,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Make alert",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const s = $('Summarize').first().json;\nconst saved = Number($json.stored ?? 0) > 0;\nreturn [{ json: saved\n ? { workflow: 'intake', node: 'Send to DLQ', severity: 'high', reason: 'dlq', count: s.failed,\n message: s.failed + ' request(s) could not be created and wait in the DLQ', execution_id: $execution.id }\n : { workflow: 'intake', node: 'Send to DLQ', severity: 'critical', reason: 'dlq_down', count: s.failed,\n message: 'DLQ save failed: ' + String($json.error ?? JSON.stringify($json)).slice(0, 150) + ' · lost ids ' + s.failed_requests.map((r) => r.id).join(', '),\n execution_id: $execution.id, ids: s.failed_requests.map((r) => r.id) } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2640,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send alert",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/alerts",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify((({ suppressed, recent, ...a }) => a)($json)) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3080,
|
||||||
|
60
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Intake webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Is a list?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Is a list?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check items",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Reject body",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check items": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Valid?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Valid?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark rejected",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Create ticket",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Create ticket": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark results",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark results": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark rejected": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Collect": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Public result": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Any failed?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Any failed?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Public result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Send to DLQ": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Make alert",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Make alert": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send alert",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Send alert": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Public result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"errorWorkflow": "errHandler76001"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
{
|
||||||
|
"name": "76 Intake · alert once per 10 minutes (answer, Lab 02 task 2)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Intake webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-intake",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Is a list?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ Array.isArray($json.body.requests) && $json.body.requests.length <= 10 }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Reject body",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ error: Array.isArray($json.body.requests) ? 'too many requests (max 10)' : 'requests must be a list' }) }}",
|
||||||
|
"options": {
|
||||||
|
"responseCode": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
600
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Check items",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const requests = $('Intake webhook').first().json.body.requests;\nconst seen = new Set();\nreturn requests.map((r, index) => {\n const problems = [];\n if (!r || typeof r !== 'object') return { json: { index, valid: false, reason: 'not an object', id: null } };\n if (!String(r.id ?? '').trim()) problems.push('id is required');\n if (typeof r.text !== 'string' || !r.text.trim()) problems.push('text is required');\n if (!String(r.user ?? '').trim()) problems.push('user is required');\n const id = String(r.id ?? '').trim();\n if (id && seen.has(id)) problems.push('duplicate id in this request');\n if (id) seen.add(id);\n return { json: { index, valid: problems.length === 0, reason: problems.join(', '), id: r.id ?? null, user: r.user ?? '', text: typeof r.text === 'string' ? r.text.trim() : '' } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Valid?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.valid }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 3.1,
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "={{ $json.text }}",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "You sort one employee IT request. Return only JSON, no code fence: {\"category\": \"계정\" | \"네트워크\" | \"장비\" | \"소프트웨어\" | \"기타\", \"priority\": \"높음\" | \"보통\" | \"낮음\"}. 높음 only when work is blocked now.",
|
||||||
|
"maxIterations": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Classify model",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"mode": "id",
|
||||||
|
"value": "gpt-4.1-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
420
|
||||||
|
],
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "openAiCourse0001",
|
||||||
|
"name": "OpenAI (.env)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Parse",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Valid?').all().filter((item) => item.json.valid);\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n let c = {}, fallback = false;\n if (item.json.error || item.json.output === undefined) { fallback = true; }\n else { try { c = JSON.parse(String(item.json.output).replace(/^```(json)?\\s*|```$/g, '')); } catch (e) { fallback = true; } }\n return { json: { ...req, category: fallback ? '미분류' : (c.category ?? '기타'), priority: fallback ? '보통' : (c.priority ?? '보통'), classified: !fallback } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create ticket",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/tickets",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ key: $json.id, title: $json.text, requester: $json.user, category: $json.category, priority: $json.priority }) }}",
|
||||||
|
"options": {
|
||||||
|
"timeout": 3000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
200
|
||||||
|
],
|
||||||
|
"retryOnFail": true,
|
||||||
|
"maxTries": 3,
|
||||||
|
"waitBetweenTries": 300,
|
||||||
|
"onError": "continueRegularOutput"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark results",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const inputs = $('Parse').all();\nreturn $input.all().map((item, i) => {\n const req = inputs[i].json;\n if (item.json.ticket_id) return { json: { id: req.id, status: 'created', ticket_id: item.json.ticket_id, duplicate: item.json.duplicate === true, category: req.category, priority: req.priority } };\n const message = item.json.error?.message ?? item.json.error ?? 'create failed';\n return { json: { id: req.id, status: 'failed', reason: String(message).slice(0, 200), request: { id: req.id, user: req.user, text: req.text } } };\n});"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mark rejected",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "return $input.all().map((item) => ({ json: { id: item.json.id, status: 'rejected', reason: item.json.reason } }));"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
640
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Collect",
|
||||||
|
"type": "n8n-nodes-base.merge",
|
||||||
|
"typeVersion": 3,
|
||||||
|
"parameters": {
|
||||||
|
"numberInputs": 2
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1760,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Summarize",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const rows = $input.all().map((item) => item.json);\nconst created = rows.filter((r) => r.status === 'created');\nconst failed = rows.filter((r) => r.status === 'failed');\nconst rejected = rows.filter((r) => r.status === 'rejected');\nreturn [{ json: { total: rows.length, created: created.length, failed: failed.length, rejected: rejected.length,\n duplicates: created.filter((r) => r.duplicate).length, unclassified: created.filter((r) => r.category === '미분류').length,\n results: rows.map(({ request, ...rest }) => rest), failed_requests: failed.map((r) => r.request) } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Public result",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst { failed_requests, ...rest } = $('Summarize').first().json;\nconst out = { ...rest };\nif (true) {\n if (ran('Send to DLQ')) out.dlq_saved = Number($('Send to DLQ').first().json.stored ?? 0) > 0;\n out.alert = ran('Send alert') ? 'sent' : (ran('Decide alert') && $('Decide alert').first().json.suppressed ? 'suppressed' : 'none');\n}\nreturn [{ json: out }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3740,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send result",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3960,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Any failed?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.failed > 0 }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send to DLQ",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/dlq",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ items: $json.failed_requests.map((r, i) => ({ item: r, reason: $json.results.filter((x) => x.status === 'failed')[i]?.reason ?? 'failed', workflow: 'intake' })) }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2420,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Make alert",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const s = $('Summarize').first().json;\nconst saved = Number($json.stored ?? 0) > 0;\nreturn [{ json: saved\n ? { workflow: 'intake', node: 'Send to DLQ', severity: 'high', reason: 'dlq', count: s.failed,\n message: s.failed + ' request(s) could not be created and wait in the DLQ', execution_id: $execution.id }\n : { workflow: 'intake', node: 'Send to DLQ', severity: 'critical', reason: 'dlq_down', count: s.failed,\n message: 'DLQ save failed: ' + String($json.error ?? JSON.stringify($json)).slice(0, 150) + ' · lost ids ' + s.failed_requests.map((r) => r.id).join(', '),\n execution_id: $execution.id, ids: s.failed_requests.map((r) => r.id) } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2640,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send alert",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/alerts",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify((({ suppressed, recent, ...a }) => a)($json)) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3520,
|
||||||
|
60
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Recent alerts",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/alerts/list",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ workflow: 'intake', reason: $json.reason, within: 600 }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2860,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Decide alert",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const alert = $('Make alert').first().json;\nconst recent = Number($json.count ?? 0);\n// critical is never held back; a high alert with the same reason in the last 10 minutes is enough\nconst suppressed = alert.severity === 'high' && recent > 0;\nreturn [{ json: { ...alert, suppressed, recent } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3080,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send it?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ !$json.suppressed }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "boolean",
|
||||||
|
"operation": "true",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
3300,
|
||||||
|
140
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Intake webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Is a list?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Is a list?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Check items",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Reject body",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Check items": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Valid?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Valid?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark rejected",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify model": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Classify",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Classify": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Parse",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Parse": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Create ticket",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Create ticket": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Mark results",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark results": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Mark rejected": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Collect": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Public result": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Any failed?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Any failed?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send to DLQ",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Public result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Send to DLQ": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Make alert",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Make alert": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Recent alerts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Recent alerts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Decide alert",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Decide alert": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send it?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Send it?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send alert",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Public result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Send alert": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Public result",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"errorWorkflow": "errHandler76001"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
{
|
||||||
|
"name": "76 Replay · park after 3 attempts (answer, Lab 03 task 1)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Replay webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-replay",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get DLQ",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/dlq",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pick items",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const max = Number($('Replay webhook').first().json.body?.max_attempts ?? 3);\nconst items = ($json.items ?? []).filter((e) => !e.parked);\nconst ready = items.filter((e) => e.attempts < max);\nconst tooMany = items.filter((e) => e.attempts >= max);\nreturn [...ready.map((e) => ({ json: { ...e, action: 'replay' } })), ...tooMany.map((e) => ({ json: { ...e, action: 'park' } }))];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
],
|
||||||
|
"alwaysOutputData": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Has items?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.dlq_id }}",
|
||||||
|
"rightValue": "",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "exists",
|
||||||
|
"singleValue": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nothing to do",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ JSON.stringify({ replayed: 0, ok: 0, still_failed: 0, parked: 0 }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
520
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Run intake again",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://localhost:5678/webhook/my-intake",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ requests: [$json.item] }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout": 60000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Outcome",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const picked = $('Replay it?').all().filter((item) => item.json.action === 'replay');\nreturn $input.all().map((item, i) => ({ json: { dlq_id: picked[i].json.dlq_id, id: picked[i].json.item?.id, ok: item.json.created === 1, duplicate: item.json.duplicates === 1 } }));"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Remove done",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/dlq/update",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ dlq_id: $json.dlq_id, remove: $json.ok }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1540,
|
||||||
|
200
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Summarize",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const ran = (n) => { try { return $(n).isExecuted; } catch (e) { return false; } };\nconst outcomes = ran('Outcome') ? $('Outcome').all().map((i) => i.json) : [];\nconst parked = ran('Park it') ? $('Park it').all().length : 0;\nconst ok = outcomes.filter((o) => o.ok).length;\nreturn [{ json: { replayed: outcomes.length, ok, still_failed: outcomes.length - ok, parked, duplicates: outcomes.filter((o) => o.duplicate).length } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2200,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send summary",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
2420,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Replay it?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "loose",
|
||||||
|
"version": 2
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"leftValue": "={{ $json.action }}",
|
||||||
|
"rightValue": "replay",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Park it",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/dlq/update",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ dlq_id: $json.dlq_id, parked: true, reason: 'gave up after ' + $json.attempts + ' attempts: ' + $json.reason }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
420
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alert parked",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/alerts",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ workflow: 'replay', node: 'Park it', severity: 'high', reason: 'parked', dlq_id: $json.entry?.dlq_id, message: $json.entry?.dlq_id + ' (' + $json.entry?.item?.id + ') parked after ' + $json.entry?.attempts + ' attempts' }) }}",
|
||||||
|
"options": {
|
||||||
|
"response": {
|
||||||
|
"response": {
|
||||||
|
"neverError": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1320,
|
||||||
|
420
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Collect",
|
||||||
|
"type": "n8n-nodes-base.merge",
|
||||||
|
"typeVersion": 3,
|
||||||
|
"parameters": {
|
||||||
|
"numberInputs": 2
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1980,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Replay webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get DLQ",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get DLQ": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Pick items",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Pick items": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Has items?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Has items?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Replay it?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Nothing to do",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Run intake again": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Outcome",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Outcome": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Remove done",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Summarize": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send summary",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Replay it?": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Run intake again",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Park it",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Park it": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Alert parked",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Remove done": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Alert parked": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Collect",
|
||||||
|
"type": "main",
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Collect": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Summarize",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"name": "76 Ops status (answer)",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"name": "Status webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "my-ops-status",
|
||||||
|
"responseMode": "responseNode",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
0,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get DLQ",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/dlq",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
220,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Recent alerts",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "http://data-api:8800/alerts/list",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={{ JSON.stringify({ within: 600 }) }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
440,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get created",
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.2,
|
||||||
|
"parameters": {
|
||||||
|
"url": "http://data-api:8800/tickets-created",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
660,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Decide status",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const dlq = $('Get DLQ').first().json;\nconst alerts = $('Recent alerts').first().json.alerts ?? [];\nconst created = $('Get created').first().json.count ?? 0;\nconst parked = (dlq.items ?? []).filter((e) => e.parked).length;\nconst waiting = (dlq.items ?? []).length - parked;\nconst critical = alerts.filter((a) => a.severity === 'critical').length;\nconst high = alerts.filter((a) => a.severity === 'high').length;\nconst reasons = [];\nif (parked) reasons.push(`parked ${parked}건: 사람이 보고 결정`);\nif (critical) reasons.push(`critical 알림 ${critical}건 (10분)`);\nif (waiting) reasons.push(`DLQ ${waiting}건: 원인을 고친 뒤 재처리`);\nif (high) reasons.push(`high 알림 ${high}건 (10분)`);\nconst status = parked || critical ? 'critical' : (waiting || high ? 'warn' : 'ok');\nreturn [{ json: { status, dlq: waiting, parked, created, alerts: { critical, high }, reasons } }];"
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
880,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Send status",
|
||||||
|
"type": "n8n-nodes-base.respondToWebhook",
|
||||||
|
"typeVersion": 1.1,
|
||||||
|
"parameters": {
|
||||||
|
"respondWith": "json",
|
||||||
|
"responseBody": "={{ $json }}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"position": [
|
||||||
|
1100,
|
||||||
|
300
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"Status webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get DLQ",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get DLQ": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Recent alerts",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Recent alerts": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get created",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get created": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Decide status",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Decide status": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Send status",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"llm": "up"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "평소에는 분류됨",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 2,
|
||||||
|
"unclassified": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LLM이 멈추면 미분류로 접수",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 3,
|
||||||
|
"unclassified": 3,
|
||||||
|
"failed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"llm": "down"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"llm": "up"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "미분류도 접수 건으로",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"contains": {
|
||||||
|
"tickets": [
|
||||||
|
"미분류"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "같은 id가 두 번이면 두 번째는 거절",
|
||||||
|
"request": "requests/batch-dup-id.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"duplicates": 0
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"results": [
|
||||||
|
"duplicate id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "11건은 400 (한 번에 10건까지)",
|
||||||
|
"request": "requests/batch-eleven.json",
|
||||||
|
"expect": {
|
||||||
|
"status": 400,
|
||||||
|
"contains": {
|
||||||
|
"error": [
|
||||||
|
"too many"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "400이면 하나도 만들지 않음",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "평소 요청은 그대로",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 3,
|
||||||
|
"rejected": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "flaky · 다시 해 보면 됨",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "flaky"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "error · 세 번 해도 실패",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 0,
|
||||||
|
"failed": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "slow · 3초씩 세 번 기다리다 실패",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "slow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "실패라던 3건이 늦게 만들어짐 (2 + 3)",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"sleep": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "같은 요청을 다시 · 멱등 키로 중복 없음",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 3,
|
||||||
|
"duplicates": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "정상 3건",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 3,
|
||||||
|
"failed": 0,
|
||||||
|
"rejected": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "같은 요청을 다시 보내도 중복 없음",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 3,
|
||||||
|
"duplicates": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "만들어진 접수 건은 3개",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "잘못된 항목은 그 항목만 거절",
|
||||||
|
"request": "requests/batch-bad-item.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 2,
|
||||||
|
"rejected": 1,
|
||||||
|
"failed": 0
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"results": [
|
||||||
|
"text is required"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "목록이 아니면 400",
|
||||||
|
"request": "requests/not-a-list.json",
|
||||||
|
"expect": {
|
||||||
|
"status": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "가끔 실패하는 API · 다시 해 보기로 성공",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 2,
|
||||||
|
"failed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "flaky"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "API가 멈춤 → DLQ",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2,
|
||||||
|
"dlq_saved": true,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "처리한 실패도 알림 (high · 개수)",
|
||||||
|
"url": "http://localhost:8800/alerts/latest",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"severity": "high",
|
||||||
|
"reason": "dlq",
|
||||||
|
"count": 2,
|
||||||
|
"workflow": "intake"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "실패가 없으면 알림 없음",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 0,
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "알림은 그대로 1개",
|
||||||
|
"url": "http://localhost:8800/alerts/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLQ도 멈추면 critical",
|
||||||
|
"request": "requests/batch-bad-item.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2,
|
||||||
|
"dlq_saved": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "잃어버린 id를 알림에",
|
||||||
|
"url": "http://localhost:8800/alerts/latest",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "dlq_down",
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"message": [
|
||||||
|
"R-201"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "첫 실패 → 알림 보냄",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "10분 안에 또 실패 → 보내지 않음",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 3,
|
||||||
|
"alert": "suppressed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dlq 알림은 1개",
|
||||||
|
"url": "http://localhost:8800/alerts/list",
|
||||||
|
"body": {
|
||||||
|
"reason": "dlq"
|
||||||
|
},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "그래도 DLQ에는 5건 모두",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLQ가 멈춘 것은 늘 보냄 (critical)",
|
||||||
|
"request": "requests/batch-bad-item.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"dlq_saved": false,
|
||||||
|
"alert": "sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "고친 뒤 · 실패 없음 · 알림 없음",
|
||||||
|
"request": "requests/batch-dup-id.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 0,
|
||||||
|
"alert": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "1 · 잘못된 항목 → 결과에만 (rejected)",
|
||||||
|
"request": "requests/batch-bad-item.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"rejected": 1,
|
||||||
|
"created": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2 · API 오류 → failed",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3 · failed는 DLQ에",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4 · 버그 → 500",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"status": 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "5 · Error Workflow → critical 알림",
|
||||||
|
"url": "http://localhost:8800/alerts/latest",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"sleep": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "6 · DLQ마저 멈추면 결과가 알려야",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 3,
|
||||||
|
"dlq_saved": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error",
|
||||||
|
"dlq": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "7 · DLQ가 멈춘 것을 누가 아나 (critical · dlq_down)",
|
||||||
|
"url": "http://localhost:8800/alerts/latest",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "dlq_down"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "예제 1 · API가 멈추면 실패로 표시 · 200",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"status": 200,
|
||||||
|
"equals": {
|
||||||
|
"created": 0,
|
||||||
|
"failed": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "예제 1 · 실패한 2건은 DLQ에",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 2
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"items": [
|
||||||
|
"R-301",
|
||||||
|
"R-302"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "예제 2 · 예상 못 한 오류 (nightly-report)",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"status": 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "예제 2 · Error Workflow가 알림을 남김",
|
||||||
|
"url": "http://localhost:8800/alerts/latest",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"severity": "critical",
|
||||||
|
"reason": "unexpected",
|
||||||
|
"workflow": "76 Nightly report (provided, has a bug)"
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"node": [
|
||||||
|
"Count by category"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"sleep": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "API가 오래 멈춤 · 처음 실패",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "재처리 1 · 또 실패",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"replayed": 2,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "재처리 2 · 또 실패",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"replayed": 2,
|
||||||
|
"still_failed": 2,
|
||||||
|
"parked": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "재처리 3 · 세 번 실패한 것은 멈춤 (parked)",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"replayed": 0,
|
||||||
|
"parked": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "parked로 남음",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 2,
|
||||||
|
"parked": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "멈춘 것은 알림",
|
||||||
|
"url": "http://localhost:8800/alerts/latest",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"reason": "parked",
|
||||||
|
"severity": "high"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "고친 뒤에도 parked는 자동으로 돌리지 않음",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"replayed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-ops-status",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "1 · 아무 일 없음 → ok",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"status": "ok",
|
||||||
|
"dlq": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2 · (접수 실패 2건)",
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3 · DLQ가 있으면 warn",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"status": "warn",
|
||||||
|
"dlq": 2
|
||||||
|
},
|
||||||
|
"contains": {
|
||||||
|
"reasons": [
|
||||||
|
"재처리"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4 · (보고서 버그)",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"status": 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "5 · critical 알림이 있으면 critical",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"status": "critical",
|
||||||
|
"alerts.critical": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"sleep": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "6 · (재처리 · 알림 확인 뒤 비움)",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"ok": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "7 · 고친 뒤 → ok",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"status": "ok",
|
||||||
|
"dlq": 0,
|
||||||
|
"created": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "1 · 평소",
|
||||||
|
"request": "requests/batch-ok.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"created": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2 · 장애 시작 · API 오류",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2,
|
||||||
|
"created": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3 · DLQ 확인",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4 · 보고서 작업도 실패",
|
||||||
|
"webhook": "nightly-report",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"status": 500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "5 · 알림 확인",
|
||||||
|
"url": "http://localhost:8800/alerts/latest",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"severity": "critical"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"sleep": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "6 · 고친 뒤 재처리",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"ok": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "7 · 결과 확인 · 중복 없이 5건",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "1 · API가 느림 → 기다리다 실패",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "slow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2 · DLQ에 2건",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3 · 그런데 접수 건은 이미 2개",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"sleep": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4 · 재처리 · 이미 있는 건이라 duplicate",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"duplicates": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "5 · 그래도 2개 (중복 없음)",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "6 · DLQ가 비었음",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"webhook": "my-intake",
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/tickets-reset",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/dlq-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/alerts-clear",
|
||||||
|
"body": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"after": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "API가 멈춘 동안 2건 → DLQ",
|
||||||
|
"request": "requests/batch-two.json",
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"failed": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"tickets": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "고친 뒤 다시 돌리기",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"replayed": 2,
|
||||||
|
"ok": 2,
|
||||||
|
"still_failed": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"before": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8800/admin/fault",
|
||||||
|
"body": {
|
||||||
|
"faq": "ok",
|
||||||
|
"tickets": "ok",
|
||||||
|
"prompts": "ok",
|
||||||
|
"usage": "ok",
|
||||||
|
"dlq": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DLQ가 비었음",
|
||||||
|
"url": "http://localhost:8800/dlq/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"size": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "접수 건은 2개 (중복 없음)",
|
||||||
|
"url": "http://localhost:8800/tickets-created/list",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "한 번 더 돌려도 할 일 없음",
|
||||||
|
"webhook": "my-replay",
|
||||||
|
"body": {},
|
||||||
|
"expect": {
|
||||||
|
"equals": {
|
||||||
|
"replayed": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Sample company data API for day 76 (day 75 API + ticket creation + DLQ + alerts), attached to the day 65 n8n network
|
||||||
|
# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default).
|
||||||
|
# docker compose up -d (run from this folder, after day 65 n8n is running)
|
||||||
|
# from n8n : http://data-api:8800
|
||||||
|
# from WSL : http://localhost:8800
|
||||||
|
name: day67-data
|
||||||
|
|
||||||
|
services:
|
||||||
|
data-api:
|
||||||
|
image: python:3.12-slim
|
||||||
|
command: python /app/server.py
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/data
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
ports:
|
||||||
|
- "8800:8800"
|
||||||
|
volumes:
|
||||||
|
- ./server.py:/app/server.py:ro
|
||||||
|
- ./data:/data:ro
|
||||||
|
networks:
|
||||||
|
- n8n
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
n8n:
|
||||||
|
external: true
|
||||||
|
name: day65-n8n_default
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"daily_usd": 1.0, "user_limit_per_min": 20, "max_question_chars": 500}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 업무용 장비 지급·반납 규정
|
||||||
|
|
||||||
|
## 1. 지급과 교체
|
||||||
|
- 업무용 노트북은 입사일에 지급한다.
|
||||||
|
- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.
|
||||||
|
|
||||||
|
## 2. 분실과 도난
|
||||||
|
- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.
|
||||||
|
- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.
|
||||||
|
|
||||||
|
## 3. 반납
|
||||||
|
- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.
|
||||||
|
- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.
|
||||||
|
|
||||||
|
## 4. 개인 장비 사용
|
||||||
|
- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.
|
||||||
|
- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 메일·문서 보안 규정
|
||||||
|
|
||||||
|
## 1. 메일함
|
||||||
|
- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.
|
||||||
|
- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.
|
||||||
|
|
||||||
|
## 2. 첨부 파일
|
||||||
|
- 메일 첨부 파일은 25MB까지 보낼 수 있다.
|
||||||
|
- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.
|
||||||
|
- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.
|
||||||
|
|
||||||
|
## 3. 문서 등급과 보관
|
||||||
|
- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.
|
||||||
|
- 업무 문서는 3년 동안 보관한 뒤 정리한다.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 회의실 이용 규정
|
||||||
|
|
||||||
|
## 1. 예약
|
||||||
|
- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.
|
||||||
|
- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.
|
||||||
|
|
||||||
|
## 2. 장비
|
||||||
|
- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.
|
||||||
|
- 프로젝터는 B회의실과 대회의실에 있다.
|
||||||
|
|
||||||
|
## 3. 사용 후
|
||||||
|
- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 비밀번호·계정 보안 규정
|
||||||
|
|
||||||
|
## 1. 비밀번호 만들기
|
||||||
|
- 비밀번호는 12자 이상으로 만든다.
|
||||||
|
- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.
|
||||||
|
- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.
|
||||||
|
|
||||||
|
## 2. 변경 주기
|
||||||
|
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
|
||||||
|
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
|
||||||
|
|
||||||
|
## 3. 로그인 실패와 잠금
|
||||||
|
- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.
|
||||||
|
- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.
|
||||||
|
|
||||||
|
## 4. 다단계 인증
|
||||||
|
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.
|
||||||
|
- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 개인정보 처리 지침
|
||||||
|
|
||||||
|
## 1. 수집
|
||||||
|
- 업무에 꼭 필요한 개인정보만 수집한다.
|
||||||
|
- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.
|
||||||
|
|
||||||
|
## 2. 이용과 보관
|
||||||
|
- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.
|
||||||
|
- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.
|
||||||
|
|
||||||
|
## 3. 유출 대응
|
||||||
|
- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.
|
||||||
|
- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 원격근무·VPN 이용 규정
|
||||||
|
|
||||||
|
## 1. 접속 원칙
|
||||||
|
- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.
|
||||||
|
- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.
|
||||||
|
|
||||||
|
## 2. VPN 인증서
|
||||||
|
- VPN 인증서의 유효 기간은 1년이다.
|
||||||
|
- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
|
||||||
|
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
|
||||||
|
|
||||||
|
## 3. 재택근무 중 보안
|
||||||
|
- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.
|
||||||
|
- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.
|
||||||
|
- VPN 접속 기록은 1년 동안 보관된다.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 보안 사고 대응 절차
|
||||||
|
|
||||||
|
## 1. 피싱 메일
|
||||||
|
- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.
|
||||||
|
- 메일 화면의 '피싱 신고' 버튼으로 신고한다.
|
||||||
|
- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.
|
||||||
|
|
||||||
|
## 2. 악성코드 감염 의심
|
||||||
|
- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.
|
||||||
|
- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.
|
||||||
|
- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 소프트웨어 사용 규정
|
||||||
|
|
||||||
|
## 1. 설치와 승인
|
||||||
|
- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.
|
||||||
|
- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.
|
||||||
|
- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.
|
||||||
|
|
||||||
|
## 2. 금지 사항
|
||||||
|
- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.
|
||||||
|
|
||||||
|
## 3. 생성형 AI 서비스
|
||||||
|
- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.
|
||||||
|
- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.
|
||||||
|
- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "이준호",
|
||||||
|
"dept": "영업팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "박서연",
|
||||||
|
"dept": "재무팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "최유진",
|
||||||
|
"dept": "인사팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "한지훈",
|
||||||
|
"dept": "개발팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "김민지",
|
||||||
|
"dept": "IT팀"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "정우성",
|
||||||
|
"dept": "IT팀"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
|
||||||
|
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
|
||||||
|
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
|
||||||
|
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
|
||||||
|
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
|
||||||
|
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
|
||||||
|
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
|
||||||
|
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
|
||||||
|
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
|
||||||
|
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
|
||||||
|
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
|
||||||
|
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"currency": "USD",
|
||||||
|
"unit": "per 1M tokens",
|
||||||
|
"note": "OpenAI 모델 문서의 공개 단가 (2026-10-28 확인). 수업 전에 가격표를 확인해 바꾸세요.",
|
||||||
|
"models": {
|
||||||
|
"gpt-4.1-mini": {"input": 0.40, "output": 1.60},
|
||||||
|
"gpt-5.4-nano": {"input": 0.20, "output": 1.25}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
|
||||||
|
When the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003].
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
You are the company IT help desk. Keep every answer to one short Korean sentence. Do not repeat the steps from the data; tell the employee to check the IT portal instead.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"helpdesk": {
|
||||||
|
"active": "1.0.0",
|
||||||
|
"releases": {
|
||||||
|
"1.0.0": {"prompt_version": "p1", "model": "gpt-4.1-mini", "note": "첫 릴리스"},
|
||||||
|
"1.1.0": {"prompt_version": "p2", "model": "gpt-4.1-mini", "note": "FAQ 답 끝에 출처 번호"},
|
||||||
|
"1.2.0": {"prompt_version": "p3", "model": "gpt-4.1-mini", "note": "답을 한 문장으로 짧게"},
|
||||||
|
"2.0.0": {"prompt_version": "p2", "model": "gpt-5.4-nano", "note": "더 작은 모델 (비용)"},
|
||||||
|
"2.1.0": {"prompt_version": "p9", "model": "gpt-4.1-mini", "note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다.", "contact": {"phone": "010-3141-5926", "email": "junho.lee@example.com"}},
|
||||||
|
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.", "contact": {"phone": "010-2718-2818", "email": "seoyeon.park@example.com"}},
|
||||||
|
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다.", "contact": {"phone": "010-1414-2135", "email": "yujin.choi@example.com"}},
|
||||||
|
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요.", "contact": {"phone": "010-1732-0508", "email": "jihoon.han@example.com"}}
|
||||||
|
}
|
||||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user