> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tiro.ooo/llms.txt
> Use this file to discover all available pages before exploring further.

# WebSocket 프로토콜

> 실시간 전사 WebSocket의 연결 방법, 오디오 프레임 규칙, 메시지 필드, 종료와 재접속 절차, 에러 reason과 close code를 정리한 레퍼런스예요.

엔터프라이즈 실시간 전사 API는 엔터프라이즈 플랜 고객에게 별도 문의로 제공하는 기능이에요. 이용 조건은 [개요](https://docs.tiro.ooo/ko/developers/realtime-transcription/overview.md)를 참고하세요.

이 문서는 세션을 만든 뒤 `wsUrl`로 여는 WebSocket 연결의 규칙을 다뤄요. 세션 생성, 조회, 목록, 종료 REST API는 [API 레퍼런스](https://docs.tiro.ooo/api-reference/realtime-transcription/create-realtime-transcription.md)에 있어요.

## 세션과 연결은 어떻게 구분되나요?

세션은 REST로 만든 전사 단위이고, 연결은 그 세션에 붙는 WebSocket 하나예요. 세션은 `OPEN`과 `ENDED` 두 상태만 가져요. 연결은 세션이 `OPEN`인 동안 끊겼다가 다시 붙을 수 있어요.

```mermaid theme={"system"}
stateDiagram-v2
    [*] --> OPEN: POST /v1/external/realtime-transcriptions
    state OPEN {
        state "연결 없음" as Idle
        state "연결됨" as Connected
        [*] --> Idle
        Idle --> Connected: wsUrl 연결, session.started
        Connected --> Idle: 연결 끊김 (1001, 1006, 1011, 1012, 4029, 4500, 4503 등)
        Connected --> Connected: 새 연결이 기존 연결을 대체 (기존 연결은 4409)
    }
    OPEN --> ENDED: session.end, REST 종료, 타임아웃
    ENDED --> [*]
```

* 세션이 `ENDED`가 되면 되돌릴 수 없어요. 계속 전사하려면 새 세션을 만드세요.
* 연결이 끊겨도 세션은 `OPEN`으로 남아요. 같은 `wsUrl`과 `streamToken`으로 다시 연결하세요.
* 세션이 끝나는 사유는 `endReason`으로 받아요. 값은 [`session.ended`](#session-ended)에 정리했어요.

## 연결

`POST /v1/external/realtime-transcriptions` 응답의 `wsUrl`에 `Authorization: Bearer <streamToken>` 헤더를 넣어 연결해요.

* `wsUrl`은 받은 그대로 쓰세요. 지금은 `wss://<host>/v1/external/realtime-transcriptions/{id}/stream` 모양이지만 호스트와 경로는 바뀔 수 있어요.
* 토큰은 `Authorization` 헤더로만 받아요. 쿼리 문자열, 쿠키, API Key로는 연결할 수 없어요. 그래서 헤더를 넣을 수 없는 브라우저 WebSocket API로는 연결할 수 없어요.
* Origin 헤더는 검사하지 않아요.
* `streamToken`은 그 세션에만 쓸 수 있고, 세션이 `OPEN`인 동안 다시 연결할 때도 같은 토큰을 써요. 세션 생성 응답에서만 받을 수 있고 다시 발급하는 API는 없어요. 토큰을 잃어버렸다면 세션을 닫고 새 세션을 만드세요.

Tiro는 연결을 먼저 수락하고, 문제가 있으면 `error` 메시지를 보낸 뒤 연결을 닫아요. HTTP 상태로 거절하는 경우는 아래 세 가지뿐이에요.

| HTTP | 조건 |
| - | - |
| `400` | WebSocket 업그레이드 요청이 아니에요 |
| `401` | `Authorization` 헤더에 Bearer 토큰이 없어요 |
| `404` | 경로가 실시간 전사 스트림 경로가 아니에요 |

연결을 수락하면 Tiro는 토큰, 세션 상태, 토큰을 발급한 API Key, 워크스페이스 정책을 확인해요. 세션 도중 정책이 꺼지거나 API Key가 삭제되면 Tiro는 열려 있는 연결을 끊지 않고 다음 연결부터 거절해요.

## 오디오 프레임

오디오는 바이너리 프레임으로, 제어 메시지는 텍스트 프레임으로 보내요. 텍스트 프레임 하나에는 UTF-8 JSON 객체 하나를 담아요.

| 항목 | 값 |
| - | - |
| 형식 | 16,000 Hz, mono, signed 16-bit little-endian PCM. 헤더 없는 raw 데이터 |
| 데이터 양 | 1초에 32,000바이트 |
| 프레임 크기 | 최대 6,400바이트(200 ms). 3,200바이트(100 ms) 권장. 최소 크기는 없어요 |
| 오디오 선행량 | 실제 경과 시간보다 최대 60초 앞서 보낼 수 있어요 |

`session.started`를 받기 전에 오디오를 보내도 돼요. Tiro는 전사 준비가 끝나기 전에 받은 오디오를 보관했다가 이어서 처리해요.

| 위반 | 결과 |
| - | - |
| 6,400바이트를 넘는 프레임 | `error`(`audio_frame_too_large`) 후 close `1009` |
| 홀수 바이트 프레임 | `error`(`invalid_audio_frame`) 후 close `1007` |
| 오디오 선행량 60초 초과 | `error`(`audio_rate_exceeded`) 후 close `4029` |
| JSON이 아닌 텍스트 프레임 | `error`(`invalid_message`, `fatal: false`). 연결은 유지돼요 |
| 모르는 `type` | `error`(`unknown_message_type`, `fatal: false`). 연결은 유지돼요 |
| `session.end` 뒤에 보낸 오디오 | 에러 없이 무시해요 |

### 오디오 선행량은 어떻게 계산하나요?

오디오 선행량은 보낸 오디오 길이가 실제 경과 시간보다 얼마나 앞서 있는지를 뜻해요. Tiro는 이 값을 연결마다 재고, 60초를 넘으면 연결을 닫아요.

* 쉬는 동안에는 여유가 쌓이지 않아요. 1분 동안 아무것도 보내지 않았다가 2분 분량을 한 번에 보내도 60초를 넘어요.
* 재접속하면 직전 연결의 선행량을 이어받아요. 재접속으로 한도를 다시 받을 수는 없어요.
* 60초를 넘으면 `error`(`audio_rate_exceeded`)의 `retryAfterMs`에 넘친 양이 담겨요. 그 시간 이상 기다린 뒤 다시 연결하고 실시간 속도로 보내세요.

## 메시지

모든 텍스트 메시지에는 `type` 필드가 있어요. 길이와 오프셋은 이름이 `Ms`로 끝나는 정수 밀리초이고, 시각은 이름이 `At`으로 끝나는 ISO-8601 UTC 문자열이에요. 로케일은 `ko_KR` 같은 [지원 로케일](https://docs.tiro.ooo/ko/developers/fundamentals/supported-locales.md) 표기를 써요.

| 방향 | `type` | 언제 |
| - | - | - |
| 클라이언트 → Tiro | 바이너리 프레임 | 오디오 |
| 클라이언트 → Tiro | [`session.end`](#session-end) | 세션을 끝낼 때 |
| Tiro → 클라이언트 | [`session.started`](#session-started) | 연결마다 첫 메시지로 한 번 |
| Tiro → 클라이언트 | [`transcript`](#transcript) | 전사 결과 |
| Tiro → 클라이언트 | [`translation`](#translation) | 번역 결과 |
| Tiro → 클라이언트 | [`session.ended`](#session-ended) | 세션이 끝났을 때. 세션의 마지막 메시지예요 |
| Tiro → 클라이언트 | [`error`](#error) | 오류. `fatal: true`면 Tiro가 바로 연결을 닫아요 |

**순서.** 같은 `segmentId`의 `transcript`와 같은 `translationId`의 `translation`은 보낸 순서대로 도착해요. `isFinal: false`가 0번 이상 온 뒤 `isFinal: true`가 한 번 와요. 서로 다른 segment 사이, 그리고 `transcript`와 `translation` 사이의 순서는 보장하지 않아요.

**호환성.** Tiro는 메시지 종류와 필드를 추가할 수 있어요. 필드를 지우거나 이름, 타입, 의미를 바꾸지는 않아요. 클라이언트는 모르는 `type`, 필드, `reason`, `endReason`, `status` 값을 무시하거나 기본값으로 처리하세요.

### `session.end`

클라이언트가 세션을 끝낼 때 보내요. 여러 번 보내도 두 번째부터는 무시해요. 이후 절차는 [종료](#ending)를 보세요.

```json theme={"system"}
{ "type": "session.end" }
```

### `session.started`

연결마다 첫 메시지로 와요. 보통 1초 안팎에 오고, 드물게 수십 초 걸려요.

```json theme={"system"}
{ "type": "session.started", "id": "Rt7Yq2PbX9cDk", "resumed": false, "audioOffsetMs": 0 }
```

| 필드 | 타입 | 설명 |
| - | - | - |
| `id` | string | 세션 ID. REST 응답의 `id`와 같아요 |
| `resumed` | boolean | 세션의 첫 연결이면 `false`, 재접속이면 `true` |
| `audioOffsetMs` | integer | Tiro가 처리를 마친 오디오 길이예요. 이 연결로 보낼 첫 오디오가 세션 타임라인에서 놓이는 위치이기도 해요. 첫 연결은 `0`이에요. 쓰는 법은 [끊긴 동안의 오디오](#resume-audio)를 보세요 |

### `transcript`

전사 결과예요.

```json theme={"system"}
{
  "type": "transcript",
  "segmentId": "Ab3kZ9qLm2XyW-1-17",
  "isFinal": true,
  "text": "오늘 회의는 일정 점검부터 하겠습니다.",
  "locale": "ko_KR"
}
```

| 필드 | 타입 | 설명 |
| - | - | - |
| `segmentId` | string | 결과 단위(segment) ID. 형식에 의미가 없는 문자열이니 파싱하거나 정렬 기준으로 쓰지 마세요 |
| `isFinal` | boolean | `false`면 아직 바뀔 수 있는 결과, `true`면 확정된 결과 |
| `text` | string | 이 segment의 현재 전체 텍스트. 이전 메시지에 덧붙일 내용이 아니에요. 빈 문자열일 수 있어요 |
| `locale` | string \| null | 세션에 `transcriptionLocale`을 지정했으면 항상 그 값이에요. 자동 감지 세션이면 이 segment에서 감지한 언어예요. 감지하지 못했거나 지원 목록 밖이면 `null`이에요 |

* 같은 `segmentId`의 `isFinal: false` 메시지가 오면 그 segment의 텍스트를 통째로 바꾸세요.
* `isFinal: true`는 segment마다 정확히 한 번 와요. 그 뒤로 그 segment의 메시지는 오지 않아요.
* `isFinal: false` 없이 `isFinal: true`만 올 수도 있어요.
* segment 크기는 Tiro가 정하고 약속하지 않아요. 지금은 발화가 끝나는 지점에서 나뉘어요.

### `translation`

번역 결과예요. 세션에 `translationLocale`을 지정했을 때만 와요.

```json theme={"system"}
{
  "type": "translation",
  "translationId": "t3-9",
  "isFinal": true,
  "locale": "en_US",
  "text": "Today's meeting will start with a schedule review.",
  "sourceSegmentId": null
}
```

| 필드 | 타입 | 설명 |
| - | - | - |
| `translationId` | string | 번역 조각 ID. 형식에 의미가 없는 문자열이에요 |
| `isFinal` | boolean | `false`면 아직 바뀔 수 있는 조각, `true`면 확정된 조각 |
| `locale` | string | 세션의 `translationLocale`. `null`이 아니에요 |
| `text` | string | 이 조각의 현재 전체 텍스트. 빈 문자열은 오지 않아요 |
| `sourceSegmentId` | string \| null | 원문 위치 힌트를 담을 자리예요. 지금은 항상 `null`이에요 |

* 교체 규칙은 `transcript`와 같아요. 같은 `translationId`의 `isFinal: false`는 이전 텍스트를 통째로 바꾸고, `isFinal: true`는 한 번만 와요.
* 번역 조각은 원문 segment와 1:1로 짝지어지지 않아요. 조각 하나가 여러 segment를 덮거나, segment 하나가 여러 조각으로 나뉠 수 있어요.
* 확정된 조각의 `text`를 도착한 순서대로 구분자 없이 이어 붙이면 번역문이 돼요. 조각은 단어 중간에서 나뉠 수 있으니 조각 사이에 공백을 넣지 마세요. 공백만 있는 확정 조각은 띄어쓰기라서 그대로 이어 붙이세요.
* 번역은 원문 확정보다 늦게 올 수도, 원문의 `isFinal: false`보다 먼저 올 수도 있어요.
* 자동 감지 세션에서 `transcript.locale`이 `translationLocale`과 같은 segment는 번역하지 않아요. 언어를 지정한 세션에서도 번역 대상 언어로 말한 구간은 번역이 빠질 수 있어요.

### `session.ended`

세션이 끝났다는 메시지예요. 세션의 마지막 메시지이고, Tiro는 이 메시지를 보낸 뒤 close `1000`으로 연결을 닫아요. 이 메시지를 받으면 다시 연결하지 마세요.

```json theme={"system"}
{
  "type": "session.ended",
  "id": "Rt7Yq2PbX9cDk",
  "endReason": "CLIENT_REQUESTED",
  "audioDurationMs": 3605120,
  "endedAt": "2026-11-05T10:01:30Z"
}
```

| 필드 | 타입 | 설명 |
| - | - | - |
| `id` | string | 세션 ID |
| `endReason` | string | 종료 사유. 아래 표를 보세요 |
| `audioDurationMs` | integer | 세션 전체에서 Tiro가 받은 오디오 길이 |
| `endedAt` | string | 종료 시각 |
| `incomplete` | boolean | 30초 안에 남은 결과를 다 보내지 못해 마지막 결과 일부가 빠졌을 수 있을 때만 `true`로 와요. 결과가 완전하면 필드가 없어요 |

| `endReason` | 조건 |
| - | - |
| `CLIENT_REQUESTED` | `session.end`를 보냈거나 REST로 세션을 닫았어요 |
| `IDLE_TIMEOUT` | 오디오가 15분 동안 오지 않았어요 |
| `MAX_DURATION` | 첫 연결부터 3시간이 지났어요 |
| `INTERNAL_ERROR` | Tiro가 세션을 이어갈 수 없다고 판단했어요. 지금은 5분 안에 연결이 세 번 연달아 `stt_unavailable`(`4503`)로 닫히면 이렇게 끝나요 |

연결이 없을 때 끝난 세션은 이 메시지를 보낼 곳이 없어요. 그때는 `GET /v1/external/realtime-transcriptions/{id}`로 상태를 확인하세요. 끝난 세션에 연결하면 `error`(`session_ended`)를 받아요.

### `error`

오류를 알리는 메시지예요.

```json theme={"system"}
{
  "type": "error",
  "reason": "audio_frame_too_large",
  "message": "Audio frame exceeds 6400 bytes",
  "retryable": false,
  "fatal": true,
  "maxFrameBytes": 6400,
  "receivedBytes": 32000
}
```

| 필드 | 타입 | 설명 |
| - | - | - |
| `reason` | string | 분기에 쓰는 식별자. [WebSocket 에러](#ws-errors) 표를 보세요 |
| `message` | string | 사람이 읽는 영문 설명. 문구가 바뀔 수 있으니 분기에 쓰지 마세요 |
| `fatal` | boolean | `true`면 Tiro가 이 메시지 직후 연결을 닫아요 |
| `retryable` | boolean | 이 메시지 뒤 연결이 닫혔을 때 다시 연결해도 되는지 알려줘요. `fatal: false`면 항상 `false`예요 |
| `retryAfterMs` | integer | `audio_rate_exceeded`에만 붙어요. 다시 연결하기 전에 기다릴 시간이에요 |
| `maxFrameBytes`, `receivedBytes` | integer | `audio_frame_too_large`에만 붙어요. 허용 크기와 받은 크기예요 |

## 종료

`session.end`를 보내야 마지막 결과까지 모두 받을 수 있어요.

**1. session.end 보내기**

마지막 오디오 프레임을 보낸 뒤 `{"type": "session.end"}`를 보내요. 이후 보낸 오디오는 무시해요.

**2. 남은 결과 받기**

Tiro가 남은 `transcript`와 `translation`을 모두 `isFinal: true`로 보내요. 아직 확정되지 않은 segment와 번역 조각은 마지막 텍스트로 확정해서 보내요.

**3. session.ended 받기**

Tiro가 `session.ended`를 보내고 close `1000`으로 연결을 닫아요.

`session.ended`를 받을 때까지 연결을 유지하세요. 남은 결과를 보내는 시간은 번역을 포함해 최대 30초예요. 30초 안에 끝나지 않으면 Tiro는 그때까지 보낸 결과로 `session.ended`를 보내고 `incomplete: true`를 붙여요.

REST로 세션을 닫거나 타임아웃에 걸려도 연결이 있으면 같은 순서로 끝나요. 이때 Tiro에 도착했지만 아직 전사를 시작하지 않은 오디오는 결과에 빠질 수 있어요.

WebSocket 연결만 닫으면 세션은 `OPEN`으로 남고 워크스페이스 동시 세션 한 자리를 차지해요. 연결이 그냥 끊기면 그 순간 확정되지 않은 결과도 받지 못해요.

## 타임아웃

| 항목 | 값 | 결과 |
| - | - | - |
| 오디오 없음 | 15분. 세션 생성 시각이나 마지막 오디오 프레임부터 재고, 연결이 없어도 시간이 흘러요 | 세션이 `IDLE_TIMEOUT`으로 끝나요 |
| 세션 최대 길이 | 3시간. 첫 연결부터 재고, 끊긴 시간도 포함해요 | 세션이 `MAX_DURATION`으로 끝나요 |
| ping | Tiro가 20초마다 WebSocket ping을 보내요. 60초 동안 pong이 없으면 close `1001`로 연결을 닫아요 | 세션은 `OPEN`으로 남아요 |

* 텍스트 메시지는 오디오 없음 타이머를 초기화하지 않아요.
* 음소거하는 동안 연결을 유지한 채 전송을 멈춰도 돼요. 15분 안에 다시 보내면 세션이 이어져요.
* Tiro는 타임아웃 시점보다 늦게 세션을 끝낼 수 있어요. 연결이 있는 세션은 최대 1분, 연결이 없는 세션은 최대 3분쯤 늦어요.

## 재접속

세션이 `OPEN`이면 연결이 끊겨도 같은 `wsUrl`과 `streamToken`으로 다시 연결해 이어 갈 수 있어요. 다시 연결할지는 마지막으로 받은 메시지로 정하고, close code로 분기하지 마세요.

```mermaid theme={"system"}
flowchart TD
    A[연결이 닫힘] --> B{session.ended를 받았나요?}
    B -- 예 --> X[세션 종료. 다시 연결하지 않아요]
    B -- 아니요 --> C{"연결을 닫은 error가 retryable: false인가요?"}
    C -- 예 --> Y[다시 연결하지 않아요]
    C -- 아니요 --> D{close 1012인가요?}
    D -- 예 --> E[바로 다시 연결]
    D -- 아니요 --> F[백오프 후 다시 연결]
```

1. `session.ended`를 받았으면 세션이 끝난 거예요. 다시 연결하지 마세요.
2. 연결을 닫은 `error`(`fatal: true`)가 `retryable: false`면 다시 연결하지 마세요. 연결이 유지되는 오류(`fatal: false`, 예: `invalid_message`)는 이 판단에 넣지 마세요.
3. 그 밖의 경우에는 다시 연결하세요. `retryable: true`인 `error`를 받았거나 메시지 없이 끊긴 경우예요. close `1012`는 서버 재시작이라 바로 다시 연결하고, 나머지는 백오프하세요.

백오프는 1초에서 시작해 두 배씩 늘리고 최대 30초까지, ±20% 지터를 주세요. `retryAfterMs`가 있으면 그 시간 이상 기다리세요. 세션이 `OPEN`인 동안에는 재접속 횟수 제한이 없어요.

재접속에 성공하면 `session.started`가 `resumed: true`로 와요. 세션이 이미 끝났으면 `error`(`session_ended`, `retryable: false`)를 받아요.

### 끊긴 동안의 오디오는 어떻게 다시 보내나요?

Tiro는 오디오를 다시 요청하지 않아요. 보내지 못한 오디오는 세션에 남지 않으니, 클라이언트가 직접 모아 두었다가 다시 보내야 해요.

```mermaid theme={"system"}
sequenceDiagram
    participant C as 고객 클라이언트
    participant W as Tiro WebSocket

    C->>W: PCM 오디오
    W--xC: 연결 끊김 (메시지 없음 또는 retryable 오류)
    Note over C: 최근 오디오를 최대 60초까지 모아 둠
    C->>W: 같은 wsUrl 연결 (같은 streamToken)
    W-->>C: session.started {resumed: true, audioOffsetMs}
    C->>W: 버퍼에서 audioOffsetMs 이후 오디오
    C->>W: 이어서 실시간 오디오
    W-->>C: transcript, translation
```

* 연결이 끊긴 동안 최근 오디오를 최대 60초까지 모아 두세요. 그보다 오래된 앞부분은 버리세요.
* 재접속하면 `session.started`의 `audioOffsetMs`를 확인하세요. 이 값은 Tiro가 처리를 마친 오디오 길이라서, 직전 연결로 실제 보낸 길이보다 짧을 수 있어요. Tiro가 직전 연결이 끊긴 것을 알기 전에 새 연결이 기존 연결을 대체하면 `0`일 수도 있어요.
* 세션 타임라인에서 `audioOffsetMs` 이후에 해당하는 오디오를 버퍼에서 먼저 보내고, 이어서 실시간 오디오를 보내세요. 이렇게 보내도 오디오 선행량 60초 안이에요.
* Tiro는 다시 보낸 오디오가 이미 받은 오디오인지 판단하지 않아요.
* close `1012`는 메시지 없이 닫혀서 직전에 보낸 프레임 일부가 Tiro에 닿지 않았을 수 있어요. 이때도 `audioOffsetMs` 이후 오디오를 다시 보내세요.
* 끊기기 직전까지 `isFinal: false`였던 segment와 번역 조각은 버리세요. Tiro가 새 연결에서 다시 보내지 않아요.

### 같은 세션에 두 번 연결하면 어떻게 되나요?

세션 하나에는 연결이 하나만 붙어요. 같은 토큰으로 새 연결이 들어오면 Tiro는 새 연결을 쓰고 기존 연결을 닫아요.

* 기존 연결은 `error`(`connection_replaced`, `retryable: false`)를 받고 close `4409`로 닫혀요. 기존 연결의 클라이언트는 다시 연결하지 마세요.
* 새 연결은 `session.started`를 `resumed: true`로 받고 세션을 이어 가요.

모바일 네트워크가 바뀌어 기존 연결이 끊겼는데 Tiro가 아직 모르는 경우에도 새 연결을 바로 쓸 수 있도록 이렇게 동작해요.

## WebSocket 에러

`reason`은 늘어날 수 있어요. 모르는 `reason`을 받아도 `fatal`과 `retryable` 값을 따르세요.

| `reason` | `fatal` | `retryable` | close | 상황과 대응 |
| - | - | - | - | - |
| `unauthorized` | true | false | `4001` | 토큰이 맞지 않거나, 다른 세션의 경로이거나, 토큰을 발급한 API Key가 삭제 또는 비활성화됐어요. 새 세션을 만드세요 |
| `realtime_api_not_enabled` | true | false | `4003` | 워크스페이스에 실시간 전사 API가 꺼져 있어요 |
| `session_ended` | true | false | `4004` | 이미 끝난 세션이에요. 새 세션을 만드세요 |
| `connection_replaced` | true | false | `4409` | 같은 세션에 새 연결이 붙었어요. 이 연결만 끝나고 세션은 새 연결에서 이어져요 |
| `audio_frame_too_large` | true | false | `1009` | 프레임이 6,400바이트를 넘었어요. 프레임을 더 작게 나누세요 |
| `invalid_audio_frame` | true | false | `1007` | 프레임 길이가 홀수 바이트예요. 16-bit 샘플 경계에서 자르세요 |
| `audio_rate_exceeded` | true | true | `4029` | 오디오 선행량이 60초를 넘었어요. `retryAfterMs` 이상 기다린 뒤 다시 연결하고 실시간 속도로 보내세요 |
| `stt_unavailable` | true | true | `4503` | Tiro가 전사 엔진에 1초, 2초, 4초 간격으로 세 번 다시 연결해도 실패했어요. 백오프하며 다시 연결하세요. 5분 안에 연결이 세 번 연달아 이 오류로 닫히면 세션이 `INTERNAL_ERROR`로 끝나고, 다음 연결은 `error`(`session_ended`)를 받아요 |
| `internal_error` | true | true | `4500` | 연결 단위 서버 오류예요. 세션은 `OPEN`으로 남아요. 백오프하며 다시 연결하세요 |
| `invalid_message` | false | false | 없음 | 텍스트 프레임이 올바른 JSON이 아니에요. 연결은 유지돼요 |
| `unknown_message_type` | false | false | 없음 | 모르는 `type`을 보냈어요. 연결은 유지돼요 |

## close code

아래 표는 로그를 볼 때 참고하세요. 재접속 여부는 [재접속](#reconnect) 규칙으로 정하고 close code로 분기하지 마세요. close code는 늘어날 수 있어요.

| code | 의미 | 앞선 메시지 |
| - | - | - |
| `1000` | 세션 종료 | `session.ended` |
| `1001` | 서버 종료(배포, 재시작)나 60초 동안 pong 없음. 백오프하며 다시 연결하세요 | 없음 |
| `1006` | 비정상 끊김. close 프레임이 없어요 | 없음 |
| `1007` | 오디오 프레임 형식 오류 | `error`(`invalid_audio_frame`) |
| `1009` | 오디오 프레임이 6,400바이트를 넘음 | `error`(`audio_frame_too_large`) |
| `1011` | Tiro가 이 연결로 메시지를 보내지 못함. 클라이언트가 메시지를 제때 읽지 못할 때 생길 수 있어요. 백오프하며 다시 연결하세요 | 없음 |
| `1012` | 서버 재시작. 바로 다시 연결하세요 | 없음 |
| `4001` | 인증 실패 | `error`(`unauthorized`) |
| `4003` | 실시간 전사 API가 꺼져 있음 | `error`(`realtime_api_not_enabled`) |
| `4004` | 이미 끝난 세션 | `error`(`session_ended`) |
| `4029` | 오디오 선행량 60초 초과 | `error`(`audio_rate_exceeded`) |
| `4409` | 다른 연결로 대체됨 | `error`(`connection_replaced`) |
| `4500` | 서버 내부 오류(연결 단위) | `error`(`internal_error`) |
| `4503` | 전사 엔진 사용 불가 | `error`(`stt_unavailable`) |

## REST 에러

세션 REST API는 [에러 처리](https://docs.tiro.ooo/ko/developers/fundamentals/error-handling.md)의 공통 형식으로 에러를 돌려줘요. 분기에는 `detail.reason`을 쓰세요. `detail.reason`이 없는 응답은 HTTP 상태로 분기하세요. 엔드포인트별 에러는 [API 레퍼런스](https://docs.tiro.ooo/api-reference/realtime-transcription/create-realtime-transcription.md)에 있어요.

| HTTP | `detail.reason` | 상황과 대응 |
| - | - | - |
| 400 | `invalid_request` | 세션 생성 요청 값이 규칙에 맞지 않아요. `translationLocale`이 `AUTO`이거나 전사 언어와 같을 때도 와요. `detail.fields`를 확인하세요 |
| 400 | `unsupported_language` | 지원하지 않는 로케일이에요. `en-GB`처럼 지원 목록에 없는 지역도 거절해요. `detail.field`와 `detail.value`를 확인하세요 |
| 400 | 없음 | 목록 조회의 `status`가 `OPEN`, `ENDED`가 아니에요 |
| 401 | 없음 | API Key가 없거나 올바르지 않아요 |
| 403 | `insufficient_scope` | 키에 필요한 scope가 없어요. OAuth 액세스 토큰도 이 응답을 받아요 |
| 403 | `workspace_bound_key_required` | 워크스페이스에 묶이지 않은 계정 API Key나 조직 API Key예요. 워크스페이스에 묶인 키를 쓰세요 |
| 403 | `workspace_access_denied` | `workspaceGuid`가 키의 워크스페이스와 다르거나, 계정 API Key의 주인이 워크스페이스 구성원이 아니에요 |
| 403 | `realtime_api_not_enabled` | 세션 생성 때만 와요. 워크스페이스에 실시간 전사 API가 꺼져 있으니 Tiro에 문의하세요 |
| 404 | 없음 | 키의 워크스페이스가 삭제됐어요 |
| 404 | `realtime_transcription_not_found` | 세션이 없거나 키의 워크스페이스에 속하지 않아요 |
| 409 | `concurrent_transcription_limit` | 워크스페이스의 `OPEN` 세션이 10개예요. 쓰지 않는 세션을 목록 API로 찾아 닫은 뒤 다시 시도하세요 |
| 429 | 없음 | API Key당 60초 600회 호출 한도를 넘었어요. `Retry-After`만큼 기다리세요 |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.