OPWS Developers

에러 처리 및 제약

에러 클래스 구조

모든 SDK 에러는 OpenEchoError 추상 클래스를 상속합니다.

ts
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되지 않고 subscribeonError 콜백으로 전달됩니다.

공통 에러 응답 구조

Open Echo API 는 오류를 아래 JSON 형태로 반환합니다.

json
{
  "success": false,
  "error": {
    "code": "PARTNER_ORIGIN_FORBIDDEN",
    "message": "..."
  }
}

SDK는 이 응답을 OpenEchoError 서브클래스로 변환합니다. 서브클래스는 아래 필드를 보존합니다.

필드타입설명
codestring서버 wire 코드(위 예시의 error.code)
httpStatusnumberHTTP 상태 코드
messagestring에러 메시지
namestring에러 클래스명(로깅에 유용)
detailsRecord<string, unknown> | undefined서버 error body 의 code/message 외 추가 필드. 없으면 undefined

참고: 이 다섯 필드가 에러 계약의 전부입니다. DeviceInUsebusyHandles/busyCount, MicUnavailablereason/causeName/causeMessage처럼 일부 매핑된 클래스는 details에서 파생한 고유 필드를 직접 노출합니다.

매핑된 에러 클래스(16종)

참고: 아래 표는 서버 wire 코드와 SDK 클래스가 1:1로 매핑된 16종만 다룹니다(비전수). 표에 없는 코드는 UnknownOpenEchoError 로 도착하며 code·httpStatus 는 그대로 보존됩니다 — 항상 instanceof OpenEchoError 를 최종 fallback 으로 두세요. SDK 내부·브라우저에서 발생하는 클라이언트 측 에러(MicUnavailable·MicChunkLoadFailed·ProtocolError·GatewayMasked·InvalidApiResponse)는 wire 코드가 없으며 아래 별도 절에서 다룹니다.

에러HTTP 상태원인해결 방법
Unauthorized401publicKey 가 무효하거나 거부됨publicKey 값을 다시 확인하세요
TicketUnauthorized401SSE 스트림 티켓 인증 실패새로 구독을 시작하세요
OriginForbidden403허용되지 않은 origin(도메인 잠금)아래 "증상별 진단" 절을 참고해 origin 등록 여부를 확인하세요
CapabilityDenied403해당 기능 capability grant 가 없음Open Echo 지원 채널로 grant 를 요청하세요
TargetUnresolved404targetHandle/deviceHandles 를 해석할 수 없음대상 handle 이 유효한지 다시 확인하세요
ContentNotReady400컨텐츠 오디오가 아직 준비되지 않음합성이 끝난 뒤 다시 시도하세요
BroadcastIneligible409현재 방송을 수행할 수 없는 상태(동시 방송 등)세션 상태를 확인한 뒤 다시 시도하세요
IdempotencyKeyReused409동일한 키로 다른 페이로드를 재전송함새 요청마다 새 idempotencyKey 를 생성하세요
TargetRevisionStale409대상 revision 이 변경됨최신 revision 값으로 expectedRevision 을 갱신하세요
DeviceInUse409대상 중 사용 중인 디바이스가 포함됨(전체 거절)점유가 풀린 뒤 다시 시도하세요(사유 표시 방식은 아래 참고)
SessionExpired410SSE 세션의 이벤트 보존 기간 초과재연결로 복구할 수 없습니다. 새 방송을 시작하세요
SdkVersionUnsupported426서버 최소 SDK 버전 미만(또는 버전 미전송·형식 오류)SDK 를 최신 버전으로 업그레이드하세요. 재시도로는 해소되지 않습니다
UsageCapExceeded429일일 TTS quota 초과다음 날 자정(UTC) 이후 다시 시도하세요
RateLimited429요청 속도 제한 초과지수 백오프를 적용해 재시도하세요
StreamLimitExceeded429SSE 동시 스트림 수 한도 초과사용하지 않는 구독을 close() 로 정리한 뒤 다시 시도하세요
RateLimitBackendUnavailable503속도 제한 백엔드가 일시적으로 불가능한 상태잠시 후 다시 시도하세요

DeviceInUse 표시 사다리 (3단)

일부 대상이 사용 중이면 방송 전체가 거절됩니다(부분 성공 없음). 사유 표시는 서버가 내려주는 정보의 입자도에 따라 3단으로 열화합니다.

  1. busyHandles가 비어있지 않으면 — 기기 이름을 나열합니다 (예: "사용 중이라 방송할 수 없습니다: 강당 스피커").
  2. busyHandles가 빈 배열이고 busyCount > 0이면 — 대수만 표시합니다 (예: "대상 중 3대가 사용 중입니다").
  3. 둘 다 없으면 — 일반 문구로 표시합니다 (예: "대상 일부가 사용 중입니다. 잠시 후 다시 시도하세요").

미매핑 코드 — UnknownOpenEchoError (fallback)

위 표에 없는 wire code(예: 서버가 이후 버전에서 추가한 에러)는 UnknownOpenEchoError로 도착합니다. 이 클래스도 OpenEchoError를 상속하므로 code·httpStatus가 그대로 보존됩니다 — 알 수 없는 코드라도 instanceof OpenEchoError 분기와 err.code 로깅은 항상 동작합니다.

ts
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 클래스발생 시점도입의미
MicUnavailablemic.start()0.5.0+마이크 획득 실패(권한 거부·장치 없음 등). reason: "permission-denied" | "not-found" | "other" · causeName · causeMessage 제공. SDK가 서버 요청 전에 마이크를 확보하므로 서버 세션·디바이스 점유 없이 즉시 도착합니다
MicChunkLoadFailedmic.start()MIC 청크 다운로드 실패 — 네트워크 오류 또는 CSP 차단. CSP script-src 설정을 먼저 확인하세요
ts
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가 합성하는 값)로 같은 콜백을 호출합니다.

ts
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는 중복 방송을 방지하는 클라이언트 생성 키입니다.

ts
// 새 방송마다 고유 키 생성
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)에 초기화됩니다.

ts
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 코드를 확인합니다.

bash
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순위입니다.

  1. API 경로는 오류 응답을 그대로 통과시키도록 설정하세요 (SPA 오류 페이지 fallback 을 API 경로에 적용하지 않기).

  2. 어느 쪽 인프라가 치환하는지 판단이 어려우면 프록시를 우회해 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_FORBIDDEN JSON 을 반환합니다 — 등록을 요청하거나, 시작하기의 도메인 잠금 절에 맞춰 dev 포트를 조정하세요.

  3. 둘 다 아니라고 판단되면 Open Echo 지원 채널로 문의하세요 (킷 루트 ONBOARDING.md).

목록 API 를 여러 개 동시에 호출하는 화면에서는 Promise.allSettled로 한 자원의 실패가 나머지를 무너뜨리지 않게 하면, 게이트웨이 마스킹이 간헐적으로 발생해도 화면 전체가 무너지지 않습니다.

ts
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.tsassertNotMasked()로 응답 값을 한 번 더 단언하는 앱 수준 방어를 둡니다(앱 자체 에러 클래스명은 GatewayMaskedError — SDK 의 GatewayMasked와 별개). Promise.allSettled 부분 실패 처리 패턴은 partner-app/src/features/tts/TtsBroadcast.tsx에서 확인할 수 있습니다.

더보기