Skip to content

[Feat] fetch 기반 run SSE reader와 run API client·요청 검증 [1.89] - #310

Merged
chowon442 merged 3 commits into
devfrom
worktree-task-1.89
Oct 20, 2026
Merged

chowon442 merged 3 commits into
devfrom
worktree-task-1.89

Conversation

@chowon442

@chowon442 chowon442 commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

📌 관련 이슈

🏷️ PR 타입

  • ✨ 기능 추가 (Feature)
  • 🐛 버그 수정 (Bug Fix)
  • ♻️ 리팩토링 (Refactoring)
  • 🗑️ 코드 제거 (Chore)
  • 📝 문서 수정 (Documentation)
  • 🎨 스타일 변경 (Style)
  • ✅ 테스트 추가 (Test)

📝 작업 내용

browser-run-v1(pin browser-run-fixtures-v1) 경로를 한 번 연결·한 번 요청하는 통신 계층을 src/features/run/api/에 추가했다. 재접속·backoff·409 복원(1.93), 상태 판단(1.90), 화면은 하지 않았다. 기존 parse_sse_stream.ts·editor_api.ts는 그대로 뒀다(1.95).

  • sse_reader.ts — fetch body를 읽는 SSE reader. CR·LF·CRLF와 청크 경계(줄·CRLF·UTF-8 다바이트가 갈린 경우)를 처리한다. : heartbeat comment와 data 없는 블록은 frame·cursor가 아니다. 빈 줄 없이 잘린 마지막 블록은 버린다. event=data.type, id={runId}:{seq}, 같은 run인지 확인하고, 어긋나면 protocol 오류로 연결을 끊는다(조용히 버리지 않음).
  • 종료 사유: terminal 뒤 정상 · terminal 없이 끝남(eof/network/protocol) · 사용자 abort · stream 전 거부. 읽는 중 네트워크 오류는 정상 종료로 처리하지 않는다.
  • run_api_error.ts — stream 전 JSON ApiResponse를 HTTP status·code로 해석한다. RUN4091은 activeRunId, RUN4291은 Retry-After(delta-seconds·HTTP-date → 초)를 담는다. 계약 밖 응답(COMMON500, proxy HTML 등)은 code: null이다.
  • run_api_client.ts — 아래 5개 경로. Authorization: Bearer를 붙이고, 401이면 token을 한 번 갱신한 뒤 같은 요청(같은 body)을 다시 보낸다.
  • run_request.ts — requestId는 소문자 UUID다(crypto.randomUUID, 비보안 context에서는 getRandomValues로 대체). Python str.strip() 집합을 직접 구현했고 code point로 1~10,000을 센다(JS .length·trim() 사용 안 함). closed key·ID 패턴·selection·video=RUN4002도 검사한다. noteId가 없으면 키를 뺀다.
  • src/shared/api/client.ts — refreshAccessToken() export를 추가만 했다(기존 interceptor 동작은 그대로). Server AuthService.refreshToken은 refresh token을 쓰면 바로 폐기하고 새로 발급한다(1회용 회전). fetch 쪽이 따로 갱신하면 axios interceptor와 동시에 갱신할 때 한쪽이 실패해 로그아웃된다. 그래서 interceptor의 isRefreshing·대기큐를 같이 쓴다.

공개 인터페이스 (@/features/run/api)

// 연결 하나의 결과. SSE 함수는 이 값을 돌려주고 던지지 않는다(계약에 맞지 않는 request·cursor만 TypeError).
type RunStreamEnd =
  | { reason: "terminal"; terminalFrame: TerminalFrame; lastEventId: EventId }          // run.completed|failed|canceled 뒤 정상 종료
  | { reason: "interrupted"; cause: "eof" | "network" | "protocol"; error?: unknown;
      lastEventId: EventId | null }                                                     // terminal 없이 끝남 → 재접속 대상(취소 아님)
  | { reason: "aborted"; lastEventId: EventId | null }                                  // 호출한 쪽 AbortSignal
  | { reason: "rejected"; error: RunApiError; lastEventId: EventId | null };            // stream 전 JSON 오류(frame 없음)
// lastEventId = 다음 재접속 cursor: 이 연결에서 마지막으로 처리한 frame id, frame이 없으면 연결할 때 보낸 cursor.

interface RunStreamOptions {
  onFrame: (frame: BrowserFrame) => void;  // frame마다 순서대로 한 번. 던지면 연결을 닫고 그 오류를 다시 던진다
  onOpen?: () => void;                     // 200 SSE를 받은 직후(첫 frame 전)
  signal?: AbortSignal;
}

const runApi: RunApiClient;  // 기본 client(tokenUtils + refreshAccessToken, VITE_API_BASE_URL)
const createRunApiClient: (options?: { baseUrl?; fetch?; auth?: { getAccessToken; refreshAccessToken? } }) => RunApiClient;

interface RunApiClient {
  // POST /api/conversations (SSE). 같은 전송을 다시 보낼 때는 같은 request 객체를 넘긴다(같은 requestId → Server replay).
  streamConversation(request: ConversationRunRequest, options: RunStreamOptions): Promise<RunStreamEnd>;
  // GET /api/ai-runs/{runId}/events (SSE). cursor는 기본 Last-Event-ID header, placement "query"면 ?lastEventId=
  streamRunEvents(runId: Uuid, cursor: { lastEventId?: EventId | null; placement?: "header" | "query" },
                  options: RunStreamOptions): Promise<RunStreamEnd>;
  getRunStatus(runId: Uuid, signal?): Promise<RunStatus>;                       // GET /api/ai-runs/{runId}
  cancelRun(runId: Uuid, signal?): Promise<RunCancelResult>;                    // POST /api/ai-runs/{runId}/cancel (body 없음)
  getConversationMessages(noteId: NoteId, query?: { before?: Uuid | null; limit?: number },
                          signal?): Promise<ConversationPage>;                  // GET /api/conversations/{noteId}/messages?before&limit
}
// JSON 함수는 오류 응답이면 RunApiError를 던진다. fetch 자체 실패(TypeError·AbortError)는 그대로 던진다.

class RunApiError extends Error { detail: RunApiErrorDetail; status: number; code: RunErrorCode | null }
type RunApiErrorDetail =
  | { code: "RUN4091"; status; message; activeRunId: Uuid }
  | { code: "RUN4291"; status; message; retryAfterSeconds: number | null }
  | { code: /* 나머지 RunErrorCode */; status; message }
  | { code: null; status; rawCode: string | null; message: string | null };     // 계약 밖

// 요청
createConversationRequest(input: { noteId?; text; latex?; requestedTasks?; requestedOutputs?;
                                   turnAttachmentIds?; viewerFocus? }): ConversationRequestCheck;  // 새 requestId·strip·noteId 없으면 생략
validateConversationRequest(value: unknown): ConversationRequestCheck;
type ConversationRequestCheck =
  | { ok: true; request: ConversationRunRequest }
  | { ok: false; code: "RUN4001" | "RUN4002"; issues: { path; code; reason }[] };
createRequestId(), stripPythonWhitespace(s), countCodePoints(s), isUuid, isNoteId, isEventId

// 저수준(필요할 때만)
readRunStream(body, { onFrame, signal?, runId?, lastEventId? }): Promise<RunStreamEnd>;
createSseParser(onMessage) / toBrowserFrame(message, runId?) / SseProtocolError

검증

현재 head 8605de4(origin/dev 509028a 위로 rebase) 기준이다.

  • CI: Frontend CI run 39353846270 통과 → 13 files, 722 passed / 2 skipped. Lint changed files·Type check tests·Build 통과
  • vitest run src/features/run/api src/shared/api(이 PR 파일) → 5 files, 217 tests passed
    • fixture streams/valid 10개: SSE 바이트 → frames와 같은 결과. 1바이트·임의 분할, 두 조각 분할(모든 위치, LF·CRLF), CRLF·CR 줄 끝
    • heartbeat: frame 사이·앞에 끼워도 같은 frame. heartbeat만 오면 cursor 그대로. heartbeat-invalid 3종(id 붙은 heartbeat는 cursor를 바꾸지 않고, data·event 붙은 것은 protocol 오류)
    • 종료 사유: terminal / eof / 잘린 블록 / 네트워크 오류 / terminal 뒤 오류 / abort(frame 처리 중·대기 중·처음부터) / onFrame 예외
    • requests/valid 10개 통과, requests/invalid 43개 기대 code(RUN4001·RUN4002)와 일치. strip 집합 29자, U+FEFF(JS trim과 다름), astral 10,000/10,001, text·latex 앞뒤 공백을 붙인 상한
    • responses/valid 오류 13종 status·code·message. RUN4091 activeRunId, RUN4291 Retry-After. 계약 밖 응답
    • cursors/cases.json의 schema 거부 판정과 isEventId가 일치
    • client: header·body·URL·method, 첫 질문에 noteId 없음, 재시도에 같은 body, 401 갱신 1회, cancel에 body·Content-Type 없음
    • refreshAccessToken 동시 호출 → refresh 요청 1회
    • 401 갱신 중 abort, body 읽기 중 abort·네트워크 실패, frame seq·runId 검사, streamRunEvents runId·cursor 검사
    • axios interceptor: 401→갱신→재요청, 동시 401 갱신 1회, 갱신 실패 시 /error/401 redirect, refresh token 없음 시 원래 401 reject·갱신 상태 고착 없음, skipErrorRedirect, fetch 쪽 갱신 실패 시 대기 axios 요청 redirect, 재요청 401 재갱신 없음
  • eslint(변경 파일), tsc -p tsconfig.app.json, tsc -p tsconfig.test.json 통과
  • pnpm run build 통과

✅ 체크리스트

공통

  • 셀프 리뷰를 완료했습니다
  • 코드 리뷰를 받을 준비가 완료되었습니다
  • pnpm tsc --noEmit 타입 체크를 통과했습니다
  • 브랜치명 컨벤션을 준수했습니다 (type/이슈번호-작업명) — 오케스트레이터가 지정한 worktree-task-1.89를 사용

인증 관련 변경 시

  • 카카오 / 네이버 로그인 흐름에 영향이 없음을 확인했습니다 — client.ts는 export만 추가했고 interceptor·login 코드는 바꾸지 않았다(실제 로그인으로는 확인하지 않음)
  • 토큰 갱신 및 로그아웃 흐름을 확인했습니다 — 단위 테스트로만 확인(동시 갱신 1회, 실패하면 token 삭제)

📎 기타 참고사항

  • 1.93 인계: 재접속 cursor는 RunStreamEnd.lastEventId이고 heartbeat는 반영되지 않는다. interrupted는 실행 취소가 아니다(backoff 뒤 streamRunEvents). rejected + RUN4041이면 멈춘다. rejected + RUN4091이면 error.detail.activeRunId로 streamRunEvents. RUN4291은 retryAfterSeconds. protocol 원인은 같은 seq에서 반복될 수 있으니 재접속 정책에서 구분한다.
  • reader는 frame 하나마다 형식(event/id/type/runId)만 본다. payload 검사와 seq 순서(S1~S7)는 1.90이 판단한다.
  • viewerFocus.page: schema에는 상한이 없지만 Server가 Java int로 받으므로 검증에서 1..2^31-1로 제한했다(1.75 메모: 판정이 갈리는 값은 보내지 않음).
  • 기존 결함(리뷰 [Chore] 프로젝트 초기 설정 #1·[Feat] 홈페이지 및 사이드바 구현 #7, 사용자 승인으로 수정): client.ts interceptor가 refresh token이 없을 때 isRefreshing = true를 걸어 둔 채 return하던 결함은, interceptor가 refreshAccessToken()을 쓰도록 합치면서 고쳤다.
  • 테스트 helper test_streams.ts는 src/features/run/api/에 뒀다(1.88 소유 src/test/contracts/는 건드리지 않음).

🤖 Generated with Claude Code

@chowon442

Copy link
Copy Markdown
Member Author

리뷰 1회차(r189) 반영 — bab2bea

반영(src/features/run/api/ 안):

미반영:

1.93 인계 추가(#6): rejected + status 401은 갱신이 실패해 token이 이미 지워진 상태다. 재접속하지 말고 로그인 또는 /error/401로 보낸다(현재 이 경로에서 redirect는 아무도 하지 않는다).

검증:

  • 로컬: vitest run src/features/run/api src/shared/api 210 passed, eslint와 tsc(app·test) 통과
  • CI run 39316425407(bab2bea): 10 files, 518 passed / 2 skipped. typecheck:test·build 통과
  • 이전 CI run 39312205062(986b555): 507 passed / 2 skipped

@chowon442

Copy link
Copy Markdown
Member Author

리뷰 권고 #1·#7 반영 (사용자 승인 (b)): commit 4cf6541

  • axios interceptor의 401 분기가 refreshAccessToken()을 기다린 뒤 재요청하도록 합쳤습니다. 갱신 본문 복제가 없어졌습니다.
  • refresh token이 없을 때 isRefreshing이 true로 남던 기존 결함을 고쳤습니다. 이제 이 경우 갱신 상태를 아예 켜지 않습니다.
  • 기존 동작은 유지합니다: 401→갱신→재요청, 동시 401은 갱신 1회, 갱신 실패 시 token 삭제와 /error/401 redirect, refresh token이 없으면 원래 401로 reject, skipErrorRedirect면 redirect 생략. client.test.ts로 확인했습니다.
  • [Feat] Tldraw 및 MathLive 기본 연동 (PoC) #6: 이제 fetch 경로가 시작한 갱신이 실패해도 기다리던 axios 요청이 스스로 /error/401로 보냅니다. run client는 여전히 rejected + 401만 돌려주고, redirect는 화면(1.93)이 정합니다(인계 메모 유지).
  • 의도된 차이 하나: 갱신을 기다렸다가 재요청한 요청이 다시 401을 받으면 더는 갱신하지 않고 redirect합니다. 예전에는 대기큐에서 재요청한 요청에 _retry가 없어 한 번 더 갱신했습니다.

CI: Frontend CI run 39353456941 통과 (test·typecheck:test·build).

chowon442 and others added 3 commits October 20, 2026 10:28
- sse_reader: CR/LF/CRLF·청크 경계 처리, heartbeat comment 무시, 종료 사유(terminal·interrupted·aborted·rejected) 구분
- run_api_client: POST/GET SSE, 상태·취소·대화 조회, stream 전 401 1회 갱신
- run_api_error: ApiResponse 오류를 status·code·activeRunId·Retry-After로 해석
- run_request: requestId 생성, Python str.strip() 집합·code point 길이 검증
- client.ts: interceptor와 대기큐를 공유하는 refreshAccessToken export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts 입력 검사, body 읽기 실패 구분

- 401 갱신을 AbortSignal과 경합시켜 abort를 바로 반영하고 재요청하지 않음
- frame seq는 1..2^53-1 정수, runId는 소문자 UUID가 아니면 protocol 오류
- streamRunEvents: runId 소문자 UUID·cursor가 같은 run인지 확인, SSE 함수는 TypeError를 reject로 통일
- 오류·JSON body 읽기 중 abort·네트워크 실패를 계약 밖 오류로 삼키지 않음
- noteId·turnAttachmentIds 값이 undefined면 키가 없는 것으로 판정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- interceptor 401 분기의 갱신 본문 복제를 없애고 refreshAccessToken()을 기다린 뒤 재요청한다.
- refresh token이 없을 때 isRefreshing이 true로 남아 이후 갱신이 끝나지 않던 기존 결함을 고친다.
- 갱신 실패 시 /error/401 redirect, refresh token 없음 시 원래 401 reject 등 기존 동작을 유지하고 테스트로 확인한다.
- 다른 경로(fetch)가 시작한 갱신이 실패해도 기다리던 axios 요청이 redirect한다(#6 간접 차이 해소).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chowon442
chowon442 merged commit 39c1460 into dev Oct 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] fetch 기반 SSE reader와 run API client·요청 검증 [1.89]

1 participant