# 오류

> Nodaro TypeScript SDK가 발생시키는 모든 오류의 HTTP 상태, 코드, 필드와 함께 크레딧, 요청 한도, 충돌, 실패한 작업을 처리하는 방법을 안내합니다.

Source: https://nodaro.ai/ko/docs/developers/sdk/errors

Nodaro SDK가 API 응답에 대해 발생시키는 모든 **오류**는 `NodaroError` 또는 그 하위 클래스의 인스턴스입니다. 오류 상태로 실패한 요청은 그 상태에 맞는 하위 클래스를 발생시킵니다. 실행 후 대기하는 헬퍼는 작업이 실패하거나, 시간이 초과되거나, 중지되면 자체 하위 클래스를 발생시킵니다. 구체적인 클래스를 먼저 처리하고 `NodaroError`는 마지막에 처리하세요.

## 모든 오류 클래스
| 클래스 | 상태 | `code` | 추가 필드 | 발생 시점 |
| --- | --- | --- | --- | --- |
| `NodaroError` | 모두 | 서버의 코드 | | 기본 클래스이며, 더 구체적인 클래스가 없는 모든 오류 |
| `UnauthorizedError` | 401 | `unauthorized` | | 토큰이 없거나, 만료되었거나, 유효하지 않음 |
| `ForbiddenError` | 403 | `forbidden` | `missingScope?` | 권한이 거부되었거나, OAuth 토큰에 스코프가 없음 |
| `NotFoundError` | 404 | `not_found` | | 항목이 없거나, 내가 볼 수 없음 |
| `RateLimitedError` | 429 | `rate_limited` | | 요청이 너무 많음 |
| `InsufficientCreditsError` | 402 | `insufficient_credits` | `required?`, `available?` | 계정이 실행 비용을 지불할 수 없음 |
| `StorageExceededError` | 413 | `storage_exceeded` | `limitBytes?` | 계정의 저장 공간이 가득 참 |
| `WorkflowConflictError` | 409 | `workflow_conflict` 또는 `production_busy` | `currentUpdatedAt?`, `currentVersion?`, `currentRecord?` | 다른 사람이 먼저 항목을 변경함 |
| `JobBlockedError` | 422 | `job_blocked` | | 배포 환경의 콘텐츠 정책이 요청을 거부함 |
| `StudioOpError` | 4xx | 서버의 코드 | `opIndex` | 스튜디오 일괄 처리의 오퍼레이션 하나가 거부됨 |
| `JobFailedError` | 0 | `job_failed` | `jobId`, `jobStatus` | 기다리던 작업이 실패했거나 취소됨 |
| `JobTimeoutError` | 0 | `job_timeout` | `jobId`, `timeoutMs` | 작업이 `maxMs` 안에 끝나지 않음 |
| `JobAbortedError` | 0 | `job_aborted` | `jobId?` | 기다리는 동안 내 `AbortSignal`이 실행됨 |
| `JobHeldError` | 0 | `job_held` | `jobId` | 작업이 사람의 검토를 위해 보류됨 |
| `StudioPreviewUnavailable` | 0 | `studio_preview_unavailable` | | 배포 환경이 스튜디오 일괄 처리를 미리 볼 수 없음 |
| `StudioPreviewAppliedError` | 0 | `studio_preview_applied` | `applied` | 미리 보기를 요청한 스튜디오 일괄 처리가 적용됨 |

모든 클래스에는 필드가 3개 있습니다. 읽을 수 있는 문장인 `message`, 비교할 수 있는 고정 문자열인 `code`, HTTP 상태인 `status`입니다. `status`가 `0`이면 오류가 HTTP 응답에서 나온 것이 아니라는 뜻입니다. 예를 들어 `JobTimeoutError`는 SDK 자체의 폴링 루프가 발생시킵니다.

일부 상태는 서버가 보낸 코드와 상관없이 하나의 클래스로 대응됩니다. 403은 `code`가 `forbidden`인 `ForbiddenError`가 되고, 404는 `NotFoundError`가 됩니다. 이런 경우 서버의 사유는 `message`에서 읽으세요. 409는 코드가 `workflow_conflict`나 `production_busy`일 때만 `WorkflowConflictError`가 됩니다. 400이나 503 같은 다른 상태의 오류와 다른 코드를 가진 409는 서버 자체의 `code`를 담은 `NodaroError`로 전달됩니다.

## 순서대로 오류 처리하기
```ts

ForbiddenError,
InsufficientCreditsError,
NodaroError,
NotFoundError,
RateLimitedError,
StorageExceededError,
UnauthorizedError,
} from "@nodaro/sdk"

try {
await client.workflows.run(workflowId)
} catch (err) {
if (err instanceof UnauthorizedError) {
redirectToLogin()
} else if (err instanceof ForbiddenError) {
if (err.missingScope) requestAdditionalScopes([err.missingScope])
else showError("You do not have permission to do this.")
} else if (err instanceof InsufficientCreditsError) {
showCreditPaywall({ required: err.required, available: err.available })
} else if (err instanceof RateLimitedError) {
await retryWithBackoff()
} else if (err instanceof StorageExceededError) {
showError(`Storage limit of ${err.limitBytes} bytes reached.`)
} else if (err instanceof NotFoundError) {
showError("Not found.")
} else if (err instanceof NodaroError) {
console.error(`API error ${err.status} (${err.code}): ${err.message}`)
} else {
throw err // a network failure or a timeout, not an API answer
}
}
```

클라이언트의 `timeoutMs`를 넘긴 요청과 네트워크 오류는 `AbortError`나 `TypeError` 같은 런타임 자체의 오류로 거부됩니다. 이 오류들은 `NodaroError` 인스턴스가 아닙니다.

## 인증과 권한
### UnauthorizedError
HTTP 401입니다. 토큰이 없거나, 만료되었거나, 유효하지 않습니다. 새 토큰을 받거나 사용자를 다시 로그인하게 한 다음 다시 시도하세요.

### ForbiddenError
HTTP 403입니다. 호출자가 이 작업을 할 수 없습니다. 엔드포인트에 필요한 스코프가 OAuth 토큰에 승인되지 않았으면, `missingScope`가 `workflows:execute`처럼 그 스코프를 알려 줍니다. 사용자에게 승인을 요청한 다음 새 토큰으로 다시 시도하세요. [스코프와 누락된 권한](https://nodaro.ai/docs/developers/sdk/auth#scopes-and-missing-permissions)을 참조하세요.

그 밖에 에디션이 기능을 제공하지 않거나 역할의 권한이 부족한 경우도 있습니다. 모두 `code`가 `forbidden`으로 전달되므로, 어떤 사유인지 설명하려면 `message`를 표시하세요.

### NotFoundError
HTTP 404입니다. 항목이 없거나, 이 호출자에게 보이지 않습니다. Nodaro는 두 경우에 똑같이 응답하므로, 내가 볼 수 없는 항목이 존재하는지 여부가 ID로 드러나지 않습니다. 스튜디오 프로덕션이나 Recast처럼 Nodaro Cloud에만 있는 리소스도 셀프 호스팅 설치에서는 404로 응답합니다.

## 크레딧, 저장 공간, 한도
### InsufficientCreditsError
HTTP 402입니다. 계정이 실행 비용을 지불할 수 없어서 아무것도 시작되지 않았습니다. `required`는 실행에 필요한 크레딧이고, `available`은 계정에 있는 크레딧입니다. 둘 다 Nodaro Cloud가 설정하지만, 타입에는 선택 항목으로 표시되어 있습니다.

```ts
try {
await client.nodes.runAndWait("generate-video", params)
} catch (err) {
if (err instanceof InsufficientCreditsError) {
console.log(`Need ${err.required} credits, have ${err.available}`)
}
}
```

실행 전에 [`client.credits.balance()`](https://nodaro.ai/docs/developers/sdk/models-and-credits)로 잔액을 확인하세요. [크레딧](https://nodaro.ai/docs/concepts/credits)을 참조하세요.

### StorageExceededError
HTTP 413입니다. 계정이 저장 공간 한도에 도달했으며, `limitBytes`가 그 한도입니다. 업로드와 커뮤니티 복제처럼 내 저장 공간으로 복사하는 작업에서 발생합니다. 더 이상 필요 없는 미디어를 삭제한 다음 다시 시도하세요.

### RateLimitedError
HTTP 429입니다. 요청을 너무 많이 보냈습니다. 기다린 다음, 대기 시간을 늘려 가며 다시 시도하세요. 예를 들어 2초, 4초, 8초 순으로 기다립니다. 몇 번 시도한 뒤에는 멈추세요. [요청 한도](https://nodaro.ai/docs/developers/api/rate-limits)를 참조하세요.

## 작업 기다리기
`client.nodes.runAndWait()`, `client.nodes.runMany()`, 그리고 다른 리소스의 `...AndWait` 헬퍼는 작업이 끝날 때까지 폴링합니다. 기다리는 동안 다음 오류가 발생할 수 있습니다.

### JobFailedError
작업이 `failed` 또는 `cancelled` 상태로 끝났습니다. `jobStatus`가 어느 쪽인지 알려 주고, `jobId`가 작업을 가리키며, `message`에 작업 자체의 오류 메시지가 담겨 있습니다. `client.jobs.get(err.jobId)`로 작업 전체를 읽으세요. 그 `error_hint`가 안전 필터나 정책에 따른 차단을 설명합니다.

### JobTimeoutError
작업이 기본값 15분인 `maxMs` 안에 종료 상태에 도달하지 못했습니다. **작업은 취소되지 않습니다.** 대개 서버에서 계속 실행되어 완료되고 내 라이브러리에 저장됩니다. 나중에 `client.jobs.get(err.jobId)`로 가져오거나, 느린 모델에는 더 큰 `maxMs`를 전달하세요. 플랫폼이 복구 중인 작업은 상태에 `recovering: true`를 표시하며, 복구에는 수십 분이 걸릴 수 있습니다.

### JobAbortedError
내 `AbortSignal`이 실행되었습니다. SDK는 즉시 폴링을 멈춥니다. **작업은 취소되지 않습니다.** 서버에서 작업을 중지하고 예약된 크레딧을 환불받으려면 `client.jobs.cancel(err.jobId)`를 호출하세요.

### JobHeldError
작업이 `pending_review` 상태에 도달했습니다. 이 배포 환경의 콘텐츠 정책이 사람이 검토하도록 결과를 보류했습니다. SDK는 이 상태를 처음 확인한 폴링에서 기다리기를 멈춥니다. 작업은 취소되지 않으며, 검토하는 동안 크레딧은 예약된 상태로 유지됩니다. 중복 요청도 보류되므로 요청을 다시 실행하지 마세요.

나중에 `client.jobs.get(err.jobId)`로 작업을 확인하세요. 검토자가 승인하면 `completed`, 검토자가 거부하면 `failed`, 내가 취소하면 `cancelled`가 됩니다. 거부된 작업에는 `error_hint.kind`가 `policy-block`으로 설정되어 있고, 그대로 표시할 수 있는 `reason`이 들어 있습니다. 이 오류는 작업 정책을 등록한 배포 환경에서만 발생합니다.

### JobBlockedError
코드가 `job_blocked`인 HTTP 422입니다. 이 배포 환경의 콘텐츠 정책이 요청을 **실행하기 전에** 거부했습니다. 작업은 만들어지지 않았고 비용도 청구되지 않았습니다. `message`는 내 사용자를 위해 작성되었으므로 그대로 표시하세요. 같은 요청을 다시 시도하지 마세요. 이 오류는 작업 정책을 등록한 배포 환경에서만 발생합니다.

## 동시 변경
### WorkflowConflictError
HTTP 409입니다. 조건부 변경을 했는데, 다른 사람이 먼저 항목을 변경했습니다. 다음 두 코드 중 하나로 전달됩니다.

- `workflow_conflict`: `expectedVersion`이나 `expectedUpdatedAt`을 넣은 [`client.workflows.update()`](https://nodaro.ai/docs/developers/sdk/workflows)가 저장된 워크플로와 일치하지 않았습니다.
- `production_busy`: 서버가 내 변경을 적용하는 동안 스튜디오 프로덕션이 계속 바뀌어서, 서버가 재시도를 멈췄습니다.

해결 방법은 둘 다 같습니다. 항목을 다시 읽고, 새 사본에 변경 내용을 적용한 다음, 다시 보내세요. 서버가 포함해 보낸 경우에는 `currentRecord`에 현재 워크플로가 들어 있으므로, 다시 읽지 않고 병합할 수 있습니다.

```ts

try {
await client.workflows.update(id, { settings, expectedVersion: loadedVersion })
} catch (err) {
if (err instanceof WorkflowConflictError && err.currentRecord) {
const merged = mergeSettings(err.currentRecord.settings, settings)
await client.workflows.update(id, { settings: merged, expectedVersion: err.currentVersion })
} else {
throw err
}
}
```

장소와 오브젝트는 자체 충돌 코드인 `concurrent_modification`을 사용하며, 이 코드는 일반 `NodaroError`로 전달됩니다. 같은 방법으로 처리하세요. 항목을 다시 읽고, 병합하고, 다시 시도합니다.

## 스튜디오 일괄 처리
다음 세 클래스는 [스튜디오 프로덕션](https://nodaro.ai/docs/developers/sdk/studio)에 속합니다.

- **`StudioOpError`**: 오퍼레이션 묶음이 거부되었으며, `opIndex`는 원인이 된 오퍼레이션의 0부터 시작하는 위치입니다. 묶음의 어떤 내용도 기록되지 않았습니다. 그 오퍼레이션을 고친 다음 묶음 전체를 다시 보내세요.
- **`StudioPreviewUnavailable`**: `dryRun: true`로 미리 보기를 요청했지만, 이 배포 환경은 미리 보기를 제공할 수 없습니다. 묶음은 전송되지 않았습니다. 사용자에게 미리 보기를 사용할 수 없다고 알리고, 묻지 않은 채 묶음을 적용하지 마세요.
- **`StudioPreviewAppliedError`**: 미리 보기를 요청했는데 묶음이 그대로 적용되었습니다. `applied.production`과 `applied.version`을 현재 상태로 취급하세요. 묶음을 다시 보내지 마세요. `applied`가 `undefined`이면 무엇이든 결정하기 전에 프로덕션을 다시 읽으세요.

## NodaroError에서 볼 수 있는 코드
다음 코드는 일반 `NodaroError`로 전달됩니다. 처리하려면 `err.code`와 비교하세요.

| 상태 | 코드 | 위치 | 의미 |
| --- | --- | --- | --- |
| 400 | `validation_error` | 여러 메서드 | 필드가 없거나 유효하지 않습니다. `message`가 그 필드를 알려 줍니다. |
| 400 | `no_valid_inputs` | `client.reduce.run()` | 모든 입력이 비어 있습니다. |
| 400 | `invalid_edl` | `client.edit.applyEdl()` | 편집 결정 목록이 검증에 실패했습니다. |
| 400 | `limit_reached` | `client.developerApps.create()` | 이미 최대 개수의 앱이 있습니다. |
| 400 | `locked_field` | `client.apps.run()` | 재정의 값이 Webhook URL 같은 대상을 바꾸려고 했습니다. |
| 409 | `name_taken` | 캐릭터, 조직 | 이름이나 슬러그가 이미 사용 중입니다. |
| 409 | `concurrent_modification` | 장소, 오브젝트 | 내가 읽은 뒤에 항목이 바뀌었습니다. |
| 410 | `voice_cloning_retired` | `client.voices.createClone()` | 보이스 복제는 더 이상 제공되지 않습니다. 대신 보이스를 디자인하세요. |
| 503 | `provider_unavailable` | `client.llm.structuredJob()` | 인스턴스가 이 모델을 실행할 수 없습니다. 다시 시도하지 마세요. |
| 503 | `feature_disabled` | `client.copilot` | 이 배포 환경에서 기능이 꺼져 있습니다. |
| 503 | `nodaro_connection_required` | `client.edit.editPlan()` | 셀프 호스팅 설치에서 이 기능을 쓰려면 Nodaro Cloud 연결이 필요합니다. |

각 참조 페이지에 해당 메서드의 코드가 나와 있습니다. API가 보낼 수 있는 모든 코드는 [REST API 오류](https://nodaro.ai/docs/developers/api/errors) 페이지에 나와 있습니다.

## 안전하게 다시 시도하기
- **읽기는 자유롭게 다시 시도하세요.** `get`이나 `list`에는 부작용이 없습니다.
- **유료 요청은 무작정 반복하지 마세요.** 시간이 초과된 요청도 이미 실행을 시작했을 수 있습니다. 생성을 다시 시도하기 전에 멱등성 키를 전달하세요. `client.nodes.run(type, params, { idempotencyKey })`와 `runAndWait`에서 받습니다. 같은 요청을 다시 시도할 때 같은 키를 다시 쓰면, 플랫폼은 두 번째 실행을 시작해 비용을 청구하는 대신 첫 번째 실행을 반환합니다.
- **스튜디오와 Recast**는 자체 재시도 토큰을 사용합니다. 스튜디오 메서드에서는 `clientRequestId`, `client.recast.rescore()`에서는 `requestId`입니다.
- **5xx 응답은 잠시 기다린 뒤 다시 시도하세요.** 이 로직은 [`createClient`](https://nodaro.ai/docs/developers/sdk/client#timeouts-and-a-custom-fetch)의 사용자 지정 `fetch`에 두면 좋습니다.

## throwFromResponse(status, body)
```ts
throwFromResponse(status: number, body: {
error?: { code?: string; message?: string; [key: string]: unknown }
}): never
```

HTTP 상태와 Nodaro 오류 본문을 해당하는 오류 클래스로 대응시켜 발생시킵니다. SDK는 모든 응답에 이 함수를 사용하며, 클라이언트 없이 API를 호출하는 사용자 지정 전송 계층을 위해 내보내집니다.

<TypeTable
type={{
status: { type: 'number', required: true, description: "응답의 HTTP 상태입니다." },
body: { type: '{ error?: { code?, message?, ... } }', required: true, description: "파싱된 JSON 본문입니다. missingScope, required, available, limitBytes, opIndex 같은 추가 필드가 해당하는 오류 필드를 채웁니다." },
}}
/>

```ts

throwFromResponse(403, {
error: { code: "insufficient_scope", message: "Missing scope", missingScope: "workflows:execute" },
})
// throws a ForbiddenError whose missingScope is "workflows:execute"
```

대부분의 경로는 `{ error: { code, message } }` 형식으로 응답합니다. 일부 오래된 경로는 다른 형식으로 응답하며, SDK는 이 형식도 읽으므로 그 오류에도 실제 `code`와 `message`가 담깁니다.

| 본문 | `code` | `message` |
| --- | --- | --- |
| `{ error: "<code>", message? }`, 예: 캐릭터 학습 | 그 문자열 | `message`, 없으면 코드 |
| `{ error: "<code>", error_description }`, OAuth | 그 문자열 | `error_description` |
| `{ message, issues: [...] }`, HTTP 400의 유효성 검사 응답 | `validation_error` | `message` |
| `{ statusCode, error: "<reason phrase>", message }`, 예: 서버에 없는 경로 | `internal_error` | `message` |

HTTP 500 이상에서는 SDK가 이 형식들에서 **코드**만 읽고 텍스트는 읽지 않습니다. 이런 오류의 `message`는 그 코드이거나 “Request failed”입니다. 기본 형식의 `message`는 모든 상태에서 읽습니다.

오류 클래스는 여전히 상태를 따릅니다. 404는 본문 내용과 상관없이 `NotFoundError`입니다. 읽을 수 있는 텍스트가 없는 본문은 “Not found” 같은 클래스의 기본 메시지를 유지합니다. `InsufficientCreditsError`는 `required`와 `available`을 기본 형식에서 읽거나, 서버가 그 옆에 보내는 `required`와 `balance` 필드에서 읽습니다.

## 자주 묻는 질문

### TypeScript에서 Nodaro API 오류는 어떻게 처리하나요?

호출을 try/catch로 감싸고, 가장 구체적인 클래스부터 instanceof로 확인한 뒤 마지막에 NodaroError를 확인하세요. 모든 클래스는 @nodaro/sdk에서 내보냅니다.

### 계정의 크레딧이 부족하면 어떻게 되나요?

아무 작업도 시작되기 전에 HTTP 상태 402와 함께 InsufficientCreditsError가 발생합니다. required 필드와 available 필드가 실행에 필요한 크레딧과 계정에 있는 크레딧을 알려 줍니다.

### JobTimeoutError가 발생하면 작업이 취소되나요?

아니요. 작업은 계속 실행되며 대개 완료됩니다. 나중에 client.jobs.get(jobId)로 가져오거나, 느린 모델에는 maxMs를 늘리세요.

### RateLimitedError가 발생하면 다시 시도해야 하나요?

네, 잠시 기다린 뒤에 다시 시도하세요. 몇 초 기다리고, 429를 새로 받을 때마다 대기 시간을 두 배로 늘리고, 몇 번 시도한 뒤에는 멈추세요.

### 403 오류가 정확한 사유 코드를 알려 주지 않는 이유는 무엇인가요?

모든 403은 code가 forbidden인 ForbiddenError로 전달됩니다. 사유는 message에서 읽고, OAuth 스코프가 빠진 경우에는 missingScope를 확인하세요.
