에러 처리 및 제약
에러 클래스 구조
모든 SDK 에러는 OpenEchoError 추상 클래스를 상속합니다.
import {
OpenEchoError,
CapabilityDenied,
UsageCapExceeded,
} from "@openecho/partner-sdk";
try {
await oe.broadcast.tts.send({
targetHandle,
text: "출입을 통제합니다",
language: "ko",
idempotencyKey: crypto.randomUUID(),
});
} catch (err) {
if (err instanceof CapabilityDenied) {
// TTS capability 미허가
} else if (err instanceof UsageCapExceeded) {
// 일일 quota 초과
} else if (err instanceof OpenEchoError) {
console.error(err.code, err.httpStatus, err.message);
}
}
SSE 에러는 throw되지 않고 subscribe의 onError 콜백으로 전달됩니다.
공통 에러 응답 구조
Open Echo API 는 오류를 아래 JSON 형태로 반환합니다.
{
"success": false,
"error": {
"code": "PARTNER_ORIGIN_FORBIDDEN",
"message": "..."
}
}
SDK는 이 응답을 OpenEchoError 서브클래스로 변환합니다. 서브클래스는 아래 필드를 보존합니다.
| 필드 | 타입 | 설명 |
|---|---|---|
code | string | 서버 wire 코드(위 예시의 error.code) |
httpStatus | number | HTTP 상태 코드 |
message | string | 에러 메시지 |
name | string | 에러 클래스명(로깅에 유용) |
details | Record<string, unknown> | undefined | 서버 error body 의 code/message 외 추가 필드. 없으면 undefined |
참고: 이 다섯 필드가 에러 계약의 전부입니다.
DeviceInUse의busyHandles/busyCount,MicUnavailable의reason/causeName/causeMessage처럼 일부 매핑된 클래스는details에서 파생한 고유 필드를 직접 노출합니다.
매핑된 에러 클래스(16종)
참고: 아래 표는 서버 wire 코드와 SDK 클래스가 1:1로 매핑된 16종만 다룹니다(비전수). 표에 없는 코드는
UnknownOpenEchoError로 도착하며code·httpStatus는 그대로 보존됩니다 — 항상instanceof OpenEchoError를 최종 fallback 으로 두세요. SDK 내부·브라우저에서 발생하는 클라이언트 측 에러(MicUnavailable·MicChunkLoadFailed·ProtocolError·GatewayMasked·InvalidApiResponse)는 wire 코드가 없으며 아래 별도 절에서 다룹니다.
| 에러 | HTTP 상태 | 원인 | 해결 방법 |
|---|---|---|---|
Unauthorized | 401 | publicKey 가 무효하거나 거부됨 | publicKey 값을 다시 확인하세요 |
TicketUnauthorized | 401 | SSE 스트림 티켓 인증 실패 | 새로 구독을 시작하세요 |
OriginForbidden | 403 | 허용되지 않은 origin(도메인 잠금) | 아래 "증상별 진단" 절을 참고해 origin 등록 여부를 확인하세요 |
CapabilityDenied | 403 | 해당 기능 capability grant 가 없음 | Open Echo 지원 채널로 grant 를 요청하세요 |
TargetUnresolved | 404 | targetHandle/deviceHandles 를 해석할 수 없음 | 대상 handle 이 유효한지 다시 확인하세요 |
ContentNotReady | 400 | 컨텐츠 오디오가 아직 준비되지 않음 | 합성이 끝난 뒤 다시 시도하세요 |
BroadcastIneligible | 409 | 현재 방송을 수행할 수 없는 상태(동시 방송 등) | 세션 상태를 확인한 뒤 다시 시도하세요 |
IdempotencyKeyReused | 409 | 동일한 키로 다른 페이로드를 재전송함 | 새 요청마다 새 idempotencyKey 를 생성하세요 |
TargetRevisionStale | 409 | 대상 revision 이 변경됨 | 최신 revision 값으로 expectedRevision 을 갱신하세요 |
DeviceInUse | 409 | 대상 중 사용 중인 디바이스가 포함됨(전체 거절) | 점유가 풀린 뒤 다시 시도하세요(사유 표시 방식은 아래 참고) |
SessionExpired | 410 | SSE 세션의 이벤트 보존 기간 초과 | 재연결로 복구할 수 없습니다. 새 방송을 시작하세요 |
SdkVersionUnsupported | 426 | 서버 최소 SDK 버전 미만(또는 버전 미전송·형식 오류) | SDK 를 최신 버전으로 업그레이드하세요. 재시도로는 해소되지 않습니다 |
UsageCapExceeded | 429 | 일일 TTS quota 초과 | 다음 날 자정(UTC) 이후 다시 시도하세요 |
RateLimited | 429 | 요청 속도 제한 초과 | 지수 백오프를 적용해 재시도하세요 |
StreamLimitExceeded | 429 | SSE 동시 스트림 수 한도 초과 | 사용하지 않는 구독을 close() 로 정리한 뒤 다시 시도하세요 |
RateLimitBackendUnavailable | 503 | 속도 제한 백엔드가 일시적으로 불가능한 상태 | 잠시 후 다시 시도하세요 |
DeviceInUse 표시 사다리 (3단)
일부 대상이 사용 중이면 방송 전체가 거절됩니다(부분 성공 없음). 사유 표시는 서버가 내려주는 정보의 입자도에 따라 3단으로 열화합니다.
busyHandles가 비어있지 않으면 — 기기 이름을 나열합니다 (예: "사용 중이라 방송할 수 없습니다: 강당 스피커").busyHandles가 빈 배열이고busyCount > 0이면 — 대수만 표시합니다 (예: "대상 중 3대가 사용 중입니다").- 둘 다 없으면 — 일반 문구로 표시합니다 (예: "대상 일부가 사용 중입니다. 잠시 후 다시 시도하세요").
미매핑 코드 — UnknownOpenEchoError (fallback)
위 표에 없는 wire code(예: 서버가 이후 버전에서 추가한 에러)는 UnknownOpenEchoError로 도착합니다. 이 클래스도 OpenEchoError를 상속하므로 code·httpStatus가 그대로 보존됩니다 — 알 수 없는 코드라도 instanceof OpenEchoError 분기와 err.code 로깅은 항상 동작합니다.
import { OpenEchoError } from "@openecho/partner-sdk";
try {
await oe.broadcast.tts.send({
targetHandle,
text: "출입을 통제합니다",
language: "ko",
idempotencyKey: crypto.randomUUID(),
});
} catch (err) {
if (err instanceof OpenEchoError) {
// 표의 클래스로 매핑되지 않은 코드는 UnknownOpenEchoError — code/httpStatus는 유효
console.error(err.code, err.httpStatus, err.message);
}
}
MIC 클라이언트 측 에러
wire 에러가 아니라 브라우저·SDK 내부에서 발생하는 MIC 관련 에러입니다.
| SDK 클래스 | 발생 시점 | 도입 | 의미 |
|---|---|---|---|
MicUnavailable | mic.start() | 0.5.0+ | 마이크 획득 실패(권한 거부·장치 없음 등). reason: "permission-denied" | "not-found" | "other" · causeName · causeMessage 제공. SDK가 서버 요청 전에 마이크를 확보하므로 서버 세션·디바이스 점유 없이 즉시 도착합니다 |
MicChunkLoadFailed | mic.start() | MIC 청크 다운로드 실패 — 네트워크 오류 또는 CSP 차단. CSP script-src 설정을 먼저 확인하세요 |
try {
await oe.broadcast.mic.start({
targetHandle,
idempotencyKey: crypto.randomUUID(),
});
} catch (err) {
if (err instanceof MicUnavailable) {
// err.reason 으로 권한 거부 / 장치 없음 구분 — 사용자에게 마이크 설정 안내
} else if (err instanceof MicChunkLoadFailed) {
// CSP·네트워크 확인
}
}
MIC 세션 종착 코드 (0.7.0+)
mic.start()가 반환한 세션이 서버 측에서 종료되면, SDK 내부 heartbeat가 아래 4종의 wire code를 감지해 콜백 지정 여부와 무관하게 마이크·실시간 오디오 룸 연결을 자동 정리합니다. 파트너 코드가 이 에러들을 직접 catch할 필요는 없습니다 — onSessionEnded 콜백으로 통지됩니다(MIC 방송 참조).
| wire code | 의미 |
|---|---|
MIC_SESSION_NOT_FOUND | 세션 부재·비소유 |
MIC_SESSION_ENDED | 종료된 세션 — 파트너 stop()·heartbeat 만료·기타 서버 종료의 union이며 원인을 구분하지 않습니다(구분이 필요하면 Phase 3으로 위임) |
MIC_JOIN_FAILED | 전 디바이스 조인 실패로 종착 |
MIC_SESSION_EXPIRED | 최대 시간 초과 종착 |
위 4종은 onSessionEnded(reason)의 reason으로 그대로 전달됩니다. 추가로 SDK는 연속 401/403 4회를 인증 상실로 간주해 reason: "AUTH_FAILED"(서버 wire 코드가 아니라 SDK가 합성하는 값)로 같은 콜백을 호출합니다.
function handleMicSessionEnded(reason: string) {
// reason: "MIC_SESSION_NOT_FOUND" | "MIC_SESSION_ENDED" | "MIC_JOIN_FAILED"
// | "MIC_SESSION_EXPIRED" | "AUTH_FAILED" | (그 외 값 — 확장 가능)
// 알 수 없는 값도 종료로 처리할 것
console.log(reason);
}
MIC_LEASE_HELD는 종착이 아닙니다 — heartbeat가 이 코드를 받으면 무시하고 다음 주기에 재시도합니다.
MIC 경로에서는 ContentNotReady 가 발생하지 않습니다 — 대상 판정이 device 조인 가능성 기준이기 때문입니다. TTS 방송은 컨텐츠 오디오 준비 여부로 여전히 이 에러가 발생할 수 있습니다.
이력 유형 정규화 에러 — ProtocolError (클라이언트 측)
history.list()/get() 응답의 방송 유형(type)을 SDK가 공개 계약값('MIC' | 'TTS')으로 정규화합니다(0.6.0+). 서버가 알 수 없는 wire 유형 값을 내려주면 조용히 오분류하지 않고 ProtocolError(code PROTOCOL_ERROR, httpStatus 502)를 throw 합니다. 정상 서버에서는 발생하지 않으며, 발생 시 서버–SDK 계약 불일치 신호입니다.
SDK 버전 게이트
서버는 최소 지원 SDK 버전을 설정할 수 있습니다. 활성 시:
- SDK 0.3.0+ 는 모든 요청에
X-OpenEcho-Sdk-Version을 자동 부착합니다 — 별도 코드 불필요 - 최소 버전 미만·버전 미전송 요청은 전 엔드포인트에서 HTTP 426 +
SDK_VERSION_UNSUPPORTED로 거부됩니다 (0.2.x 이하는 헤더가 없어 게이트 활성 시 모두 거부) - 응답 헤더
X-OpenEcho-Sdk-Min-Version에서 현재 차단선을 확인할 수 있습니다 - 차단 전 예고: 응답 헤더
X-OpenEcho-Sdk-Warn-Below가 설정되면, 자신의 버전이 그 미만인 SDK 가console.warn을 1회 출력합니다 — 이 경고가 보이면 다음 차단 상향 전에 업그레이드하세요 - SSE 구독 중 426 을 받으면 SDK 는 재연결하지 않고
onError후 종료합니다
멱등성 키 (Idempotency-Key)
idempotencyKey는 중복 방송을 방지하는 클라이언트 생성 키입니다.
// 새 방송마다 고유 키 생성
const receipt = await oe.broadcast.tts.send({
targetHandle,
text: "출입을 통제합니다",
language: "ko",
idempotencyKey: crypto.randomUUID(),
});
상황별 키 사용 규칙:
| 상황 | Idempotency-Key |
|---|---|
| 새 방송 결정 (사용자 클릭) | 새 키 |
| 네트워크 오류·5xx·429 재시도 | 동일 키 (자동 재시도 가능) |
| 4xx 수신 (409 DEVICE_IN_USE 포함) | 자동 재시도 금지 — 점유 해소 후 다시 시도할 때는 동일 키 사용 가능하나, 그 시도는 새로운 사용자 결정이어야 한다 |
| 페이로드 변경 | 새 키 (동일 키 재사용 시 IdempotencyKeyReused 409) |
일일 Quota (UsageCapExceeded)
파트너 앱별 일일 TTS 방송 횟수에 제한이 있습니다. 초과 시 UsageCapExceeded(USAGE_CAP_EXCEEDED, 429) 에러가 발생하며 다음 날 자정(UTC)에 초기화됩니다.
import { UsageCapExceeded } from "@openecho/partner-sdk";
try {
await oe.broadcast.tts.send({
targetHandle,
text: "출입을 통제합니다",
language: "ko",
idempotencyKey: crypto.randomUUID(),
});
} catch (err) {
if (err instanceof UsageCapExceeded) {
console.warn("일일 방송 한도를 초과했습니다. 내일 다시 시도해 주세요.");
}
}
Rate Limit (RateLimited)
단시간 내 과도한 요청 시 RateLimited(RATE_LIMITED, 429) 에러가 발생합니다. 지수 백오프(exponential backoff)를 적용해 재시도합니다.
RateLimitBackendUnavailable(503)은 속도 제한 백엔드가 일시적으로 불가능한 상태입니다. 잠시 후 재시도합니다.
증상별 진단 — 도메인 잠금과 게이트웨이 마스킹
호출이 예상과 다르게 실패하면 아래 순서로 원인을 좁혀 갑니다.
1단계 — 증상 확인
- 브라우저 DevTools → Network 탭에 요청이
CORS error로 표시된다 → 2단계로 진행하세요. OriginForbidden(403, JSON)을 정상적으로 받았다 → 서버가 이미 올바르게 응답한 것입니다. Open Echo 지원 채널로 origin 등록을 요청하세요 (킷 루트ONBOARDING.md).GatewayMasked/InvalidApiResponse를 받았다 → 4단계로 건너뛰세요.- 위 어느 쪽인지 판단하기 어렵다 → 3단계의 curl 로 직접 확인하세요.
2단계 — 브라우저가 CORS 로 차단하는 경우
브라우저는 등록되지 않은 origin 에서의 요청을 CORS preflight 단계에서 차단합니다.
원인: 파트너 앱 Allowed Origins 에 현재 사이트 origin 이 등록되지 않음.
해결: Open Echo 지원 채널로 해당 origin 등록을 요청합니다 (킷 루트 ONBOARDING.md).
3단계 — curl 로 wire error 직접 확인
브라우저 CORS 차단을 우회해 서버가 실제로 보내는 wire error 코드를 확인합니다.
curl -s -X POST "https://openecho.example.com/api/partner/v1/broadcasts/tts" \
-H "X-OpenEcho-Key: pk_test_..." \
-H "Content-Type: application/json" \
-H "Origin: https://not-registered.example.com" \
-d '{"targetHandle":"grp_main","text":"test","language":"ko","idempotencyKey":"test-key","partial":false}'
Open Echo API 는 오류 응답(403/404 포함)을 JSON 원형 그대로 반환합니다. OriginForbidden이면 위 "공통 에러 응답 구조" 형태로 code: "PARTNER_ORIGIN_FORBIDDEN"을 받습니다.
참고:
Unauthorized(publicKey 오류)와OriginForbidden(도메인 잠금)은 별개의 에러입니다. curl 응답의error.code로 정확히 구분합니다.
origin 미등록은 정상적으로 OriginForbidden(403 JSON)으로 도착하며 GatewayMasked로 나타나지 않습니다. 따라서 curl 로 JSON 에러를 받았다면 원인은 도메인 잠금(2단계)이고, HTML 이나 비-JSON 을 받았다면 4단계로 진행하세요.
4단계 — 여러분 측 인프라가 응답을 치환하는 경우
일부 게이트웨이/프록시 구성은 API 경로의 4xx 응답(401, 403, 404 등)을 HTML 오류 페이지(HTTP 200)로 치환합니다. 이 경우 SDK 는 다음을 throw 합니다.
- HTML 본문 수신 (2xx, HTML):
GatewayMasked(code:GATEWAY_MASKED) - 비-JSON 본문 수신 (2xx, 비-JSON):
InvalidApiResponse(code:INVALID_API_RESPONSE)
더 이상 null 또는 결손된 응답 구조를 반환하지 않습니다.
GatewayMasked가 보인다면 origin 문제가 아니라 여러분 측 인프라(리버스 프록시·CDN·게이트웨이)가 API 경로의 오류 응답을 HTML 페이지로 치환하고 있을 가능성이 1순위입니다.
-
API 경로는 오류 응답을 그대로 통과시키도록 설정하세요 (SPA 오류 페이지 fallback 을 API 경로에 적용하지 않기).
-
어느 쪽 인프라가 치환하는지 판단이 어려우면 프록시를 우회해 curl 로 직접 확인하세요 — JSON 이 오면 여러분 측 치환, HTML 이 오면 지원 채널로 문의 대상입니다.
bash# API 직접 호출 — 미등록 origin 이라도 JSON(OriginForbidden)이 와야 정상 curl -s -H "X-OpenEcho-Key: $KEY" -H "Origin: https://your-site.example.com" "$API/api/partner/v1/ping" curl -s -H "X-OpenEcho-Key: $KEY" -H "Origin: https://not-registered.example.com" "$API/api/partner/v1/ping"origin 미등록이 원인이면 위 호출이
PARTNER_ORIGIN_FORBIDDENJSON 을 반환합니다 — 등록을 요청하거나, 시작하기의 도메인 잠금 절에 맞춰 dev 포트를 조정하세요. -
둘 다 아니라고 판단되면 Open Echo 지원 채널로 문의하세요 (킷 루트
ONBOARDING.md).
목록 API 를 여러 개 동시에 호출하는 화면에서는 Promise.allSettled로 한 자원의 실패가 나머지를 무너뜨리지 않게 하면, 게이트웨이 마스킹이 간헐적으로 발생해도 화면 전체가 무너지지 않습니다.
const [devRes, grpRes] = await Promise.allSettled([
oe.targets.list(),
oe.groups.list(),
]);
const devices = devRes.status === "fulfilled" ? devRes.value : [];
const groups = grpRes.status === "fulfilled" ? grpRes.value : [];
const anyFailed = devRes.status === "rejected" || grpRes.status === "rejected";
if (anyFailed) {
// 부분 실패 UI 표시 (예: "일부 자원을 불러올 수 없습니다")
}
참고 코드
레퍼런스 앱은 위 방어에 더해 partner-app/src/lib/gateway-guard.ts의 assertNotMasked()로 응답 값을 한 번 더 단언하는 앱 수준 방어를 둡니다(앱 자체 에러 클래스명은 GatewayMaskedError — SDK 의 GatewayMasked와 별개). Promise.allSettled 부분 실패 처리 패턴은 partner-app/src/features/tts/TtsBroadcast.tsx에서 확인할 수 있습니다.
더보기
- 시작하기 — 도메인 잠금(allowed origin) 등록과 초기화
- Control-Plane 개요 — capability·grant 권한 모델 규범 서술
- MIC 방송 —
onSessionEnded종착 코드 소비

