Errors
How the API reports failures, the envelope shapes you should parse, and how to handle each category.
The KI-AVA Amharic API uses conventional HTTP status codes and a small set of stable JSON envelopes to report failures. Errors come back in two places: up-front on the HTTP call (request rejected before a job is created), and inside successful job responses (individual files or translation units failed while the job as a whole completed). Both need to be handled.
Error envelope shapes
There are three distinct shapes. Every error you get from the API matches one of them.
Standard error
Used for 400, 401, 403, 404, 409, and 500 responses.
{
"detail": "Invalid API Key."
}Validation error (422)
Returned when the request body fails schema validation. FastAPI's field-level shape — detail is an array of per-field errors, each with a loc path, a msg, and a type.
{
"detail": [
{
"loc": ["body", "audio_urls"],
"msg": "field required",
"type": "value_error.missing"
}
]
}Per-unit translation error
Nested inside a successful 200 response from GET /translate/{job_id}/results, at outputs[].error. Has a stable code enum (listed below) and a human-readable message.
{
"code": "TRANSLATION_ERROR",
"message": "Lesan MT API returned a non-retryable error after 3 retries."
}HTTP status codes
Every endpoint documents its exact set of possible status codes in the API Reference. At a glance:
| Status | Meaning | Typical trigger | Retryable? |
|---|---|---|---|
400 | Bad Request | Request exceeds your tier's file or translation-unit limit | No — fix the request |
401 | Unauthorized | X-API-Key header missing | No — add credentials |
403 | Forbidden | API key invalid, deactivated, or lacks admin privileges | No — use a valid key |
404 | Not Found | Job ID does not exist (or is not of the expected type) | No |
409 | Conflict | Operation not allowed in the job's current state (e.g. reading results or deleting while still PROCESSING) | Yes — after the job leaves the running state |
422 | Unprocessable Entity | Request body failed Pydantic validation | No — fix the request |
429 | Too Many Requests | Per-minute / per-hour / per-day rate limit or max concurrent jobs exceeded for your tier | Yes — honor Retry-After |
500 | Internal Server Error | Server misconfiguration (STORAGE_BUCKET_NAME or LESAN_API_KEY not set) or an unhandled exception | Cautiously — contact support if it persists |
Error detail strings
The exact detail strings the API emits. Match on these if your client needs to distinguish specific error conditions programmatically.
| Status | detail value | When it fires |
|---|---|---|
401 | Missing API Key. Include X-API-Key header. | No X-API-Key header in the request |
403 | Invalid API Key. | Key not recognized |
403 | API Key has been deactivated. | Key exists but was disabled by an admin |
403 | Admin access required | Non-admin key used on an admin endpoint |
422 | {"detail": [{"type":…, "loc":[…], "msg":…}]} | FastAPI field-level validation (see shape above) |
429 | Rate limit exceeded: N requests/{minute,hour,day} | Any rate bucket exceeded; Retry-After header included |
Handling 429 rate limits
Rate limits are enforced per API key across three buckets — requests per minute, per hour, and per day — plus a cap on concurrent jobs. All endpoints share the same bucket. When you exceed any limit, the API returns 429 with a Retry-After header (seconds). Sleep at least that long before retrying.
import time, requests
def request_with_retry(method, url, **kwargs):
while True:
r = requests.request(method, url, **kwargs)
if r.status_code != 429:
return r
retry_after = int(r.headers.get('Retry-After', 5))
time.sleep(retry_after)For long polls, use exponential backoff (start at 2 s, multiply by 1.5, cap at 30 s) between successful calls as well — fixed sub-second polling wastes your budget and rarely returns fresher data.
Job-level vs per-file failures
Transcription and translation jobs can partially succeed. The HTTP call returns 200, but individual files/units are marked failed. You must inspect status and the per-item arrays after polling to a terminal state.
Transcription job statuses
PENDING → PROCESSING → one of COMPLETED, PARTIAL (some files failed), or FAILED (all files failed). A top-level error string is set on FAILED. Per-file errors appear as plain strings on each entry of the results[] array.
{
"job_id": "90310bc7-62a2-45c7-b92c-91fc5ccf3bcc",
"status": "PARTIAL",
"error": null,
"results": [
{
"original_url": "https://example.com/good.mp3",
"status": "COMPLETED",
"duration": 45.2,
"processing_time": 12.5,
"num_segments": 15,
"transcript_url": "https://storage.googleapis.com/.../transcript.json",
"error": null
},
{
"original_url": "https://example.com/broken.mp3",
"status": "FAILED",
"duration": 0,
"processing_time": 0.4,
"num_segments": 0,
"transcript_url": null,
"error": "Unable to download audio: HTTP 404"
}
]
}Translation job statuses
Both transcription and translation share the same status vocabulary: PENDING → PROCESSING → one of COMPLETED, PARTIAL, or FAILED. Detailed per-unit outcomes come from a follow-up GET /translate/{job_id}/results — calling that endpoint while the job is still PENDING or PROCESSING returns 409, so poll status first.
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"summary": {
"status": "PARTIAL",
"success_count": 1,
"failure_count": 1,
"total_characters": 15400
},
"outputs": [
{
"source_uri": "https://storage.googleapis.com/bucket/source/doc1.txt",
"target_language": "en",
"status": "COMPLETED",
"target_uri": "https://storage.googleapis.com/bucket/results/doc1_en.txt",
"error": null
},
{
"source_uri": "https://storage.googleapis.com/bucket/source/doc2.bin",
"target_language": "en",
"status": "FAILED",
"target_uri": null,
"error": {
"code": "UNSUPPORTED_FORMAT",
"message": "Source file is not valid UTF-8 text."
}
}
]
}Per-unit translation error codes
The code field on outputs[].error is one of five stable values:
| Code | Meaning | Retryable? |
|---|---|---|
DOWNLOAD_ERROR | Could not fetch the source file from its URI | Only after fixing the source URI |
UNSUPPORTED_FORMAT | Source file is not valid UTF-8 text | No — convert the file first |
TRANSLATION_ERROR | Lesan MT API returned a non-retryable error or retries were exhausted | Server already retried 3×; resubmit in a new job if needed |
UPLOAD_ERROR | Translated file could not be written to the destination bucket | Only after fixing the destination |
INTERNAL_ERROR | Unexpected exception during unit processing | Cautiously — contact support if it persists |
Recommended client pattern
One reference implementation that handles every documented error category:
import time, requests
class ApiError(Exception): pass
class AuthError(ApiError): pass
class ValidationError(ApiError): pass
def call(method, url, api_key, **kwargs):
headers = {'X-API-Key': api_key, **kwargs.pop('headers', {})}
delay, cap = 1.0, 30.0
while True:
r = requests.request(method, url, headers=headers, **kwargs)
if r.status_code == 429:
time.sleep(int(r.headers.get('Retry-After', 5)))
continue
if 500 <= r.status_code < 600:
time.sleep(delay); delay = min(delay * 2, cap)
continue
if r.status_code in (401, 403):
raise AuthError(r.json().get('detail', r.text))
if r.status_code == 422:
raise ValidationError(r.json().get('detail'))
if not r.ok:
raise ApiError(r.json().get('detail', r.text))
return r.json()
def surface_unit_errors(results):
# Transcription: results[].error is a plain string
# Translation: outputs[].error is {code, message} or null
for item in results.get('results', []) or results.get('outputs', []):
err = item.get('error')
if not err: continue
if isinstance(err, dict):
print(f"{item.get('source_uri')}: {err['code']} - {err['message']}")
else:
print(f"{item.get('original_url')}: {err}")See the API Reference for the exact status codes documented on each endpoint, and the Best Practices guide for higher-level reliability tips.