# Lỗi

Source: https://docs.vieneu.io/vi/docs/cloud-api/errors

Status cho bạn biết mức độ nghiêm trọng. Phần lớn lỗi từ chối còn kèm theo `code`, và
khi có `code` thì đó là phần duy nhất trong body an toàn để rẽ nhánh — `message` là
câu chữ tự nhiên, một phần bằng tiếng Việt, và tất cả đều có thể được viết lại.

Điều cần biết trước tiên là **không phải lỗi nào cũng có `code`**. Phần lớn các cách
một lời gọi `/v1` có thể bị từ chối là có `code`; một số ít chỉ trả về status kèm một
câu mô tả. Trang này nói rõ cái nào thuộc nhóm nào, vì một client viết theo giả định
rằng lỗi nào cũng có `code` sẽ lặng lẽ rơi xuống nhánh mặc định ở những lỗi không
có.

## Hai dạng body {#two-body-shapes}

Các endpoint gốc trả về dạng của nền tảng:

```json
{ "statusCode": 403, "message": "Insufficient tokens", "traceId": "0f7c…" }
```

`POST /v1/audio/speech` thì trả về dạng của OpenAI, để một client OpenAI đọc được mà
không cần lớp chuyển đổi nào:

```json
{ "error": { "message": "…", "type": "rate_limit_exceeded", "param": null, "code": null } }
```

Khác biệt chỉ có thế: một route, một envelope. Mọi thứ còn lại trên `/v1` — kể cả
`/v1/vapi/speech`, vốn phục vụ một bên thứ ba — đều dùng dạng thứ nhất.

Mọi body dạng gốc đều lặp lại status ở `statusCode`, và thêm `code` cùng các trường
đi kèm khi lỗi từ chối có mang `code`:

```json
{ "statusCode": 429,
  "message": "Server is at capacity. Please try again in a few minutes.",
  "code": "QUEUE_FULL", "traceId": "0f7c…" }
```

Trước đây một body có `code` thì lại thiếu `statusCode` — tức là đổi lấy một lý do
máy đọc được thì mất đi một trường bạn vẫn luôn đọc được. Khoảng trống đó đã khép
lại: giờ mọi body dạng gốc đều có trường này. Dòng status của HTTP vẫn là bản chuẩn
— chỗ nào đọc được từ response thì hãy đọc từ đó.

## Những lỗi từ chối có kèm `code` {#refusals-that-carry-a-code}

### Hạn mức và grant {#quota-and-grant}

Sáu route tính tiền theo ký tự hoặc theo thời lượng — `POST /v1/tts`,
`/v1/tts/stream`, `/v1/dialogue`, `/v1/dub`, `/v1/srt` và
`/v1/vapi/speech` — đều trả lời một lỗi hạn mức kèm `code`, và hai lỗi tự phục hồi
thì nói rõ khi nào:

| `code` | Status | Nghĩa là gì | Cần làm gì |
|---|---|---|---|
| `GRANT_EXPIRED` | 403 | Gói token đứng sau key đã quá hạn. | Gia hạn. **Thử lại không giúp được gì**, nạp thêm cũng vậy. |
| `GRANT_TOKENS_EXHAUSTED` | 403 | Số dư của grant nhỏ hơn giá của request này. Kèm `remaining` và `required`. | Nạp thêm. Thử lại không giúp được gì. |
| `GRANT_DAILY_LIMIT` | 429 | Trần ngày của gói đã dùng hết. Kèm `resetAt`, `remaining`, `required`. | Chờ tới `resetAt`. |
| `GRANT_WEEKLY_LIMIT` | 429 | Trần tuần của gói đã dùng hết. Kèm `resetAt`, `remaining`, `required`. | Chờ tới `resetAt`. |
| `CONCURRENT_CONFLICT` | 429 | Hai request của chính bạn tranh nhau cùng một số dư và request này thua. | Thử lại ngay — lỗi này tự hết. |

`resetAt` theo chuẩn ISO 8601, và nó thay cho việc đọc mốc thời gian ra từ `message`.
Hãy dùng nó thay vì đoán khoảng chờ, và thay vì mời người dùng nâng gói cho một thứ
sẽ tự reset sau một giờ.

Ranh giới 403/429 là bản thô của cùng quyết định đó: **403 nghĩa là thử lại không
giúp được gì** dù trông có vẻ nhất thời, còn 429 thì rồi sẽ được. Thứ status không
nói được là *khi nào* — lỗi hạn mức **không kèm `Retry-After`** (header đó đến từ
lớp rate limit, không phải lớp tính tiền). `code` mới là thứ tách một pha tranh nhau
đáng thử lại ngay khỏi một cái trần phải chờ, còn `resetAt` là thứ nói phải chờ bao
lâu.

`FREE_DAILY_LIMIT` và `GRANT_INACTIVE` có trong danh sách code nội bộ của nền tảng
nhưng không xuất hiện ở đây: một API key luôn tính tiền trên một grant nên không chạm
tới phần miễn phí, còn grant không còn hoạt động thì trả 403 kèm câu chữ, không có
code.

### Năng lực phục vụ {#capacity}

| `code` | Status | Nghĩa là gì | Cần làm gì |
|---|---|---|---|
| `QUEUE_FULL` | 429 | Hàng đợi job bất đồng bộ đã chạm giới hạn độ sâu. | Giãn nhịp rồi gửi lại. Bạn không bị tính tiền — khoản trừ trước được hoàn ngay tại chỗ. |
| `STREAM_CONCURRENCY` | 429 | Bạn đang giữ số stream mở tối đa cho phép. | Đóng bớt một stream, hoặc chờ một stream kết thúc. Không bị tính tiền. |
| `STREAM_BUSY` | 503 | Mọi node đều khoẻ nhưng năng lực stream đã dùng hết. | Kèm theo `"fallback": "generate"`, và đó là một chỉ dẫn thật — đường đi qua hàng đợi có năng lực riêng. Xem [Một stream kết thúc thế nào](./streaming.md#how-a-stream-ends). |

`QUEUE_FULL` chỉ đến với bạn từ `POST /v1/tts`; các route đồng bộ không xếp hàng
đợi.

### Nhân bản giọng {#cloning}

Nhân bản giọng đã chuyển hẳn về Studio web. Các route tạo giọng —
`POST /v1/voices`, `DELETE /v1/voices/{voiceId}`, `POST /v1/clone`,
`POST /v1/upload` và `POST /v1/prepare` — nay chỉ trả một lỗi từ chối, trước khi
tính tiền và trước khi chạm tới GPU:

| `code` | Status | Nghĩa là gì | Cần làm gì |
|---|---|---|---|
| `CLONE_WEB_ONLY` | 410 | Giọng nhân bản được tạo tại [vieneu.io/#/clone](https://vieneu.io/#/clone), không qua API. Kèm `cloneUrl`. | Tạo giọng một lần trên Studio. Phần còn lại của hệ tích hợp không đổi: giọng đó hiện trong `GET /v1/voices` khi bạn gửi kèm key, và id `clone_…` dùng được làm `voiceId` trên `POST /v1/tts` và `/v1/tts/stream`. |

Cố ý dùng 410 chứ không phải 404: các endpoint này từng tồn tại và từng có tài
liệu, nên status phải nói rằng chúng đã dời đi, chứ không phải bạn gõ sai đường
dẫn. Các code nhân bản cũ (`CLONE_QUOTA_EXCEEDED`, `CLONE_MONTHLY_CAP`,
`CLONE_DAILY_CAP`, `CLONE_REF_*`, `CLONE_TRANSCRIPT_DENSITY`,
`CLONE_TOKENS_INSUFFICIENT`…) thuộc về các route đó nên không còn tới được một
API key nữa — những giới hạn ấy vẫn còn nguyên trên Studio, nơi chúng hiện ra
ngay lúc bạn nhân bản.

Việc tạo tiếng **bằng** một giọng nhân bản không nằm trong nhóm này và không mang
code nhân bản nào. Một id `clone_…` không tồn tại hoặc không phải của bạn sẽ hỏng
như một lỗi `400` thường, có `message` và không có `code` — xem
[Giọng nhân bản](./streaming.md#cloned-voices).

## Những lỗi từ chối **không** có code {#refusals-that-do-not-carry-a-code}

### Route tương thích OpenAI {#the-openai-compatible-route}

`POST /v1/audio/speech` không mang bất kỳ code nào ở trên, dù envelope của OpenAI có
sẵn một trường cho việc này: trường `code` của envelope luôn là `null` trên route
này. Thứ bạn nhận được thay vào đó là `type`, và nó thô hơn một cách có chủ đích. Lỗi
hạn mức được gán `type` theo status — `insufficient_quota` cho các `403`, tức grant
đã cạn hoặc hết hạn, và `rate_limit_exceeded` cho các `429`, tức trần đã dùng hết
hoặc một pha tranh nhau. Vậy nên "hết tiền" và "hãy chờ" thì sống sót qua phép chuyển
đổi; còn `GRANT_EXPIRED` với `GRANT_TOKENS_EXHAUSTED` thì không, và `resetAt` — thứ
cho biết phải chờ bao lâu — cũng không. Mọi lỗi phát sinh trước khi handler chạy —
key sai, một trường bị bộ validate từ chối — thì được gán `type` chỉ theo status, và
phép ánh xạ đó gộp `401` với `403` thành một `authentication_error` duy nhất.

### API công khai không bao giờ trả 402 {#the-public-api-never-returns-402}

Không có `402 Payment Required` ở bất cứ đâu trên `/v1`. Mọi đường đi liên quan tới
tiền đều chạy qua cùng một bước trừ token, và bước đó chỉ trả lời `403` hoặc `429`.
Bản spec có kèm một ví dụ `402`; còn máy chủ thì không gửi. Một client coi `402` là
"hết tiền" sẽ đọc `403` thật thành lỗi phân quyền và ngừng thử lại vì một lý do sai.

### Hai phép kiểm văn bản {#two-text-validations}

Trước khi tiêu bất kỳ token nào, văn bản gửi lên được kiểm hai thứ mà mô hình có đọc
thành tiếng cũng không ra nghĩa gì. Cả hai đều trả về `400` trơn, có `message` và
không có `code`.

| Thông báo bắt đầu bằng | Quy tắc |
|---|---|
| `Text appears to be in an unsupported language…` | Hơn **34%** số chữ cái nằm ngoài hệ chữ Latin. Dấu tiếng Việt và `đ` đều là Latin, nên luật này bắt tiếng Trung, Nhật, Hàn, chữ Kirin và tương tự — kể cả một đoạn vốn là tiếng Việt nhưng có một trích dẫn dài không phải chữ Latin. |
| `Text does not look like readable words…` | Hoặc có một chuỗi **hơn 30 chữ cái** liền không ngắt, hoặc **hơn 60%** số token dạng từ (dài từ bốn chữ cái trở lên, và phải có ít nhất ba token như vậy) không chứa nguyên âm nào. |

Dấu câu cắt đứt một chuỗi chữ cái, nên một URL, một địa chỉ email hay một đường dẫn
tệp được đo theo từng phần và lọt qua; còn gõ bừa bàn phím thì vẫn tính là một chuỗi
liền và không lọt.

**Hai phép kiểm này chỉ chạy trên ba route:** `POST /v1/tts`, `POST /v1/dialogue` và
`POST /v1/srt`. `POST /v1/tts/stream`, `POST /v1/audio/speech`,
`POST /v1/vapi/speech` và `POST /v1/dub` không chạy phép kiểm nào — nên đoạn văn bản
mà route này từ chối, route kia vẫn tổng hợp và vẫn tính tiền. Đáng biết trước khi
bạn coi một `400` từ một route là bằng chứng rằng văn bản đó hỏng ở mọi nơi.

## Số stream đồng thời {#stream-concurrency}

`POST /v1/tts/stream` từ chối mở stream mới khi bạn đang giữ quá nhiều stream:

| Tính theo | Trần | Khi vượt |
|---|---|---|
| Token grant đứng sau API key của bạn | 4 | `429`, `code: STREAM_CONCURRENCY` |
| Một người dùng đã đăng nhập trong web app | 2 | `429`, cùng code đó |

Suất được giữ **trước** khi động tới số dư, nên một stream bị từ chối không để lại
dấu vết gì trên token của bạn. Hai chi tiết về cách đếm: đếm theo từng tiến trình
backend, và khoá đếm là **token grant**, không phải API key — nên nhiều key phát hành
trên cùng một grant sẽ chia nhau bốn suất chứ không phải mỗi key được bốn suất
riêng.

## `X-Request-Id` {#x-request-id}

Mọi response đều mang `X-Request-Id`. Đây chính là id mà log của chúng tôi đánh chỉ
mục theo: một giá trị phủ cả request tới API, lời gọi worker mà nó tạo ra, và mọi
dòng log do cả hai sinh ra.

Phần lớn body lỗi có lặp lại giá trị đó dưới tên `traceId`, nhưng **bản đáng đọc là
cái header** — chỉ nó mới luôn có mặt. `traceId` do bộ lọc exception đóng vào, nên
một body được ghi thẳng ra response thì không bao giờ có:

- **`POST /v1/audio/speech`**, ở mọi lỗi. Chính handler tự ghi envelope của OpenAI,
  nên bộ lọc lẽ ra thêm `traceId` không hề chạy — mà hình dạng của OpenAI cũng
  chẳng có trường nào cho nó.
- **`POST /v1/vapi/speech`**, ở lỗi `502` khi worker không sinh ra audio nào. Body
  đó được ghi giữa chừng response và chỉ mang `statusCode` với `message`.

Hãy dẫn nó ra khi gửi yêu cầu hỗ trợ. Không có nó, "một request lỗi khoảng 3 giờ
chiều" là một cuộc lục tìm; có nó thì chỉ là một lần tra.

Giá trị này luôn là của chúng tôi. Proxy ở biên đặt `X-Request-ID` cho mọi request
nó chuyển tiếp, vô điều kiện, nên id bạn gửi lên sẽ bị ghi đè chứ không được giữ
lại — hãy ghi log cái nhận về cạnh correlation id của bạn, đừng trông đợi id của
bạn sống sót.

## Tổng hợp status {#status-summary}

| Status | Ý nghĩa |
|---|---|
| 400 | Request sai — giọng không tồn tại, format không hợp lệ, văn bản bị bộ kiểm từ chối, một lỗi từ chối khi nhân bản giọng |
| 401 | API key thiếu, sai dạng hoặc đã bị thu hồi |
| 403 | Hết token, grant hết hạn, hoặc gói của bạn không bao gồm engine hay tính năng này |
| 413 | File tải lên quá 10 MB |
| 422 | Nội dung bị kiểm duyệt từ chối (chỉ khi `aiRefine` đang bật) |
| 429 | Rate limit, hạn mức token, độ sâu hàng đợi hoặc số stream đồng thời |
| 500 | Tổng hợp thất bại. Khoản đã trừ được hoàn tự động |
| 502 | Mọi worker của engine này đều lỗi. Đã hoàn tiền |
| 503 | Không có worker cho engine hoặc format được yêu cầu, hoặc năng lực stream đã đầy — thử lại sau chốc lát |

`429` là status duy nhất có những nguyên nhân chẳng liên quan gì tới nhau đứng sau,
và header sẽ cho biết là nguyên nhân nào — xem
[Giới hạn tần suất](./overview.md#rate-limits-and-request-ids).
