https://api.echonova.aiالمطورون
أعد المحاولة بأمان
ميّز بين إعادة محاولة لا تكلّفك شيئًا وأخرى تنشئ مهمة ثانية، واحتفظ بمعرّف المهمة حتى لا تضطر إلى إرسال طلب مرتين، وتراجع بالشكل الصحيح عند الاستجابتين 429 و5xx.
يمكن تكرار GET بقدر ما تشاء. أما POST فيبدأ مهمة جديدة ويُحتسب عليها في كل مرة،
ولا يوجد header أو حقل يُعلِّم POST بأنه تكرار لـ POST آخر. تشرح هذه الصفحة كيف
تبني عميلًا يعيد المحاولة دون أن يدفع مرتين: احتفظ بـ jobId، واستعلم بدلًا من إعادة
إرسال POST، وتراجع عند الاستجابات التي تستحق إعادة المحاولة.
ما تحتاجه
- مفتاح API. راجع احصل على مفتاح API.
- دورة حياة المهمة من الاستدعاء المتزامن وغير المتزامن.
- مكان دائم تحفظ فيه معرّف مهمة واحدًا لكل طلب يرسله نظامك: صف في قاعدة بيانات، أو رسالة في طابور، أو ملف. تكتب الأمثلة في ملف.
- cURL مع
jqلأمثلة الطرفية.
الخطوات
الخطوة 1: صنّف الطلبات حسب ما تكلّفه إعادة المحاولة
| الطلب | آمن للتكرار | السبب |
|---|---|---|
GET /v1/text-to-speech/voices |
نعم | قراءة فقط. لا يُحتسب أي رصيد. |
GET /v1/text-to-speech/jobs، GET /v1/speech-to-text/jobs، GET /v1/sound-cleaning/jobs |
نعم | قراءة فقط. |
GET /v1/text-to-speech/jobs/{jobId}، GET /v1/speech-to-text/jobs/{jobId} |
نعم | قراءة فقط. كل استدعاء لمهمة تحويل النص إلى كلام يعيد audioUrl جديدًا. |
POST /v1/text-to-speech، POST /v1/text-to-speech/sync |
لا | كل استدعاء ينشئ مهمة جديدة بمعرّف جديد، ويحتسب النص مرة أخرى. |
POST /v1/speech-to-text، POST /v1/speech-to-text/sync |
لا | كل استدعاء ينشئ مهمة جديدة بمعرّف جديد، ويحتسب الملف كاملًا مرة أخرى. |
POST /v1/sound-cleaning |
لا | كل استدعاء ينشئ مهمة جديدة بمعرّف جديد، ويحتسب الملف كاملًا مرة أخرى. |
كل POST ينشئ مهمة جديدة بمعرّف جديد ويحتسبها. لا يُقارَن النص أو الملف بالمهام
السابقة، فطلبان متطابقان يعنيان مهمتين واحتسابين.
POST يُرسل مرتين يُحتسب مرتين. ولا يُسترد أي من الاحتسابين إلا إذا فشلت مهمته.
لا تضع POST خلف إعادة محاولة تلقائية لا تستطيع التمييز بين استجابة ضاعت وطلب
رُفض.
الخطوة 2: احفظ jobId قبل أي شيء آخر
كل نقطة POST لتحويل النص إلى كلام وتحويل الكلام إلى نص تعيد معرّف المهمة في data.jobId، للاستدعاء غير المتزامن والمتزامن
على حد سواء. وتعيده تنقية الصوت في data.id. اكتبه في تخزين دائم، مفتاحه معرّف الطلب لديك، قبل أن تحلل أي شيء آخر في
الاستجابة. من تلك اللحظة يصبح كل ما تحتاجه GET.
أرسل كل POST مرة واحدة، وتعافَ من استجابة ضاعت باستخدام GET. فطبقة إعادة المحاولة
التي تعيد إرسال POST لا تستطيع التمييز بين استجابة ضاعت وطلب رُفض، فتدفع ثمن الاثنين.
الموصى به
POSTواحد لكل طلب يرسله نظامك، مع حفظjobIdفورًا، ثمGET .../jobs/{jobId}حتى تنتهي المهمة.
الأقل فاعلية
- عميل HTTP بإعادة محاولة تلقائية على
POST، يعيد إرسال النص نفسه بعد كل مهلة.
{
"data": {
"jobId": "66e5f0c2a1b2c3d4e5f60718",
"status": "processing"
},
"success": true,
"statusCode": 200,
"errorMessage": null
}الخطوة 3: اقرأ استجابة الخطأ قبل إعادة المحاولة
تخبرك كل استجابة خطأ إن كانت إعادة المحاولة ستفيد. لا تترك استجابة الخطأ أي احتساب معلّقًا: إما أن الاحتساب لم يُنفَّذ، أو أنه يُسترد قبل إعادة الخطأ.
| الاستجابة | إعادة المحاولة | الاحتساب |
|---|---|---|
400 برسالة تحقق، مثل text must be shorter than or equal to 500 characters |
لا. أصلح الطلب. | لا. رُفض قبل أن يعمل أي شيء. |
401 Unauthorized، و403 Subscription expired or inactive، و403 This API key does not have access to Text-to-Speech services |
لا. أصلح المفتاح. راجع المصادقة. | لا. |
402 Insufficient subscription balance |
ليس قبل إضافة رصيد. | لا. |
404 Voice not found، و404 TTS Job not found، و404 STT Job not found |
لا. المعرّف خاطئ أو يخص مساحة عمل أخرى. | لا. |
429 Monthly spending limit exhausted، دون header باسم Retry-After |
لا. الانتظار لا يزيله. راجع الرصيد. | لا. |
429 ThrottlerException: Too Many Requests، مع Retry-After |
نعم، بعد ثواني Retry-After. راجع حد معدل الطلبات. |
لا. |
503 Speech generation is temporarily unavailable. Please try again.، و503 Transcription is temporarily unavailable. Please try again. |
نعم، مع تراجع تدريجي. | مُسترد. POST غير متزامن يحصل على هذا يترك مهمة failed في قائمتك. |
أي 5xx آخر، مثل 503 Database unavailable أو 500 Something went wrong |
نعم، مع تراجع تدريجي. | لم يُحتسب، أو مُسترد. |
ميّز بين الاستجابتين 429 من خلال errorMessage، لا من خلال رمز الحالة.
الخطوة 4: عند غياب الاستجابة، تحقّق قبل إعادة الإرسال
انتهاء مهلة العميل، أو انقطاع الاتصال، أو تعطّل العملية بعد إرسال الطلب، كلها تترك
لك بلا استجابة. ربما أُنشئت المهمة وحُسبت، والمهمة غير المتزامنة تواصل عملها. لا ترسل
POST مرة أخرى قبل أن تتحقق.
- إذا حفظت
jobId، فارجع إلى الاستعلام به. - إذا لم يصل الطلب إلى استجابة تحمل
jobId، فاعرض قائمة مهامك الأخيرة. تأتي مرتبة من الأحدث إلى الأقدم، بعشرين مهمة في الصفحة افتراضيًا، وبمئة مهمة كحد أقصى.
curl "https://api.echonova.ai/v1/text-to-speech/jobs?limit=20" \
-H "Authorization: Bearer YOUR_API_KEY" \
| jq '.data.items[] | {id, status, text, createdAt}'import requests
items = requests.get(
"https://api.echonova.ai/v1/text-to-speech/jobs",
headers={"Authorization": "Bearer YOUR_API_KEY"},
params={"limit": 20},
).json()["data"]["items"]
# TEXT is the text you sent. SENT_AT is the ISO 8601 UTC time you recorded
# just before the POST.
lost = next(
(job for job in items if job["text"] == TEXT and job["createdAt"] >= SENT_AT),
None,
)
if lost:
job_id = lost["id"] # resume polling with this id; do not POST againconst response = await fetch('https://api.echonova.ai/v1/text-to-speech/jobs?limit=20', {
headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
const { data } = await response.json();
// TEXT is the text you sent. SENT_AT is the ISO 8601 UTC time you recorded
// just before the POST.
const lost = data.items.find((job) => job.text === TEXT && job.createdAt >= SENT_AT);
if (lost) {
const jobId = lost.id; // resume polling with this id; do not POST again
}تحمل مهمة تحويل النص إلى كلام text الذي أرسلته، فيمكنك المطابقة عليه. أما مهمة
تحويل الكلام إلى نص فلا تحمل أي إشارة إلى الملف الذي رفعته، فطابق على createdAt
وحده: المهمة التي أُنشئت بعد لحظة إرسال الطلب، بهذا المفتاح، هي مهمتك. ولا تعيد
إرسال الطلب إلا عندما تُظهر القائمة أنه لا يوجد شيء.
الخطوة 5: استعلم بفاصل ثابت، وتراجع عند الأخطاء
هذا هو العميل الكامل لطلب واحد غير متزامن لتحويل النص إلى كلام. يرسل POST مرة
واحدة بالضبط، ويحفظ jobId، ثم يستعلم بـ GET فقط. يتراجع عند 429 وينتظر
Retry-After، ويتراجع عند 5xx أو خطأ اتصال، ويتوقف عند أي رمز 4xx آخر، لأن إعادة
المحاولة ستعيد الجواب نفسه. المهلة 15 دقيقة، وهي أطول مدة تستطيع المهمة أن تبقى فيها
processing قبل أن تُعلَّم failed.
BASE_URL="https://api.echonova.ai"
AUTH="Authorization: Bearer YOUR_API_KEY"
# Exactly one POST. Save the job id before anything else.
RESPONSE=$(curl -s -X POST "$BASE_URL/v1/text-to-speech" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"text": "مرحبا بكم في إيكونوڤا", "voiceId": "VOICE_ID"}')
if [ "$(echo "$RESPONSE" | jq -r '.success')" != "true" ]; then
echo "error: $(echo "$RESPONSE" | jq -r '.errorMessage')"; exit 1
fi
echo "$RESPONSE" | jq -r '.data.jobId' > job-id.txt
JOB_ID=$(cat job-id.txt)
# Poll with GET only. 429: wait Retry-After. 5xx or no response: back off. Other 4xx: stop.
DEADLINE=$(( $(date +%s) + 900 ))
TRANSIENT=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
CODE=$(curl -s -o job.json -D headers.txt -w '%{http_code}' \
"$BASE_URL/v1/text-to-speech/jobs/$JOB_ID" -H "$AUTH")
if [ "$CODE" = "200" ]; then
TRANSIENT=0
STATUS=$(jq -r '.data.status' job.json)
echo "$STATUS"
if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then break; fi
sleep 3
elif [ "$CODE" = "429" ]; then
WAIT=$(sed -n 's/^[Rr]etry-[Aa]fter: *\([0-9]*\).*/\1/p' headers.txt)
sleep "${WAIT:-60}"
elif [ "$CODE" = "000" ] || [ "$CODE" -ge 500 ]; then
TRANSIENT=$((TRANSIENT + 1))
if [ "$TRANSIENT" -gt 5 ]; then echo "API unreachable"; exit 1; fi
sleep $((2 ** (TRANSIENT - 1)))
else
echo "error: $(jq -r '.errorMessage' job.json)"; exit 1
fi
done
jq '.data' job.jsonimport random
import time
import requests
BASE_URL = "https://api.echonova.ai"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
POLL_SECONDS = 3 # recommendation, not a server value
GIVE_UP_SECONDS = 15 * 60 # a silent job is marked failed within this time
MAX_TRANSIENT = 5 # recommendation
def start_tts_job(text, voice_id):
# Exactly one POST. If this raises before a response arrives, do not call
# it again blindly: check the job list first (Step 4).
response = requests.post(
f"{BASE_URL}/v1/text-to-speech",
headers=HEADERS,
json={"text": text, "voiceId": voice_id},
timeout=30,
)
body = response.json()
if not body["success"]:
raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
job_id = body["data"]["jobId"]
with open("job-id.txt", "w") as f: # persist before anything else
f.write(job_id)
return job_id
def wait_for_job(job_id):
deadline = time.monotonic() + GIVE_UP_SECONDS
transient = 0
while time.monotonic() < deadline:
try:
response = requests.get(
f"{BASE_URL}/v1/text-to-speech/jobs/{job_id}",
headers=HEADERS,
timeout=30,
)
except requests.RequestException:
response = None
if response is not None and response.status_code == 200:
transient = 0
job = response.json()["data"]
if job["status"] in ("completed", "failed"):
return job
time.sleep(POLL_SECONDS)
elif response is not None and response.status_code == 429:
# Rate limit. Wait exactly what the server asks; a sooner call is rejected.
time.sleep(int(response.headers.get("Retry-After", "60")))
elif response is None or response.status_code >= 500:
transient += 1
if transient > MAX_TRANSIENT:
raise RuntimeError("API unreachable")
time.sleep(2 ** (transient - 1) + random.random())
else:
# 400, 401, 403, 404: a retry returns the same answer. Fix the request.
body = response.json()
raise RuntimeError(f'{body["statusCode"]}: {body["errorMessage"]}')
raise TimeoutError(f"job {job_id} still running after {GIVE_UP_SECONDS} s")
job_id = start_tts_job("مرحبا بكم في إيكونوڤا", "VOICE_ID")
job = wait_for_job(job_id)
print(job["status"], job.get("audioUrl") or job.get("error"))import { writeFile } from 'node:fs/promises';
const BASE_URL = 'https://api.echonova.ai';
const HEADERS = { Authorization: 'Bearer YOUR_API_KEY' };
const POLL_MS = 3000; // recommendation, not a server value
const GIVE_UP_MS = 15 * 60 * 1000; // a silent job is marked failed within this time
const MAX_TRANSIENT = 5; // recommendation
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function startTtsJob(text, voiceId) {
// Exactly one POST. If this throws before a response arrives, do not call
// it again blindly: check the job list first (Step 4).
const response = await fetch(`${BASE_URL}/v1/text-to-speech`, {
method: 'POST',
headers: { ...HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voiceId }),
});
const body = await response.json();
if (!body.success) throw new Error(`${body.statusCode}: ${body.errorMessage}`);
await writeFile('job-id.txt', body.data.jobId); // persist before anything else
return body.data.jobId;
}
async function waitForJob(jobId) {
const deadline = Date.now() + GIVE_UP_MS;
let transient = 0;
while (Date.now() < deadline) {
let response = null;
try {
response = await fetch(`${BASE_URL}/v1/text-to-speech/jobs/${jobId}`, { headers: HEADERS });
} catch {
// connection error: handled like a 5xx below
}
if (response?.status === 200) {
transient = 0;
const { data: job } = await response.json();
if (job.status === 'completed' || job.status === 'failed') return job;
await sleep(POLL_MS);
} else if (response?.status === 429) {
// Rate limit. Wait exactly what the server asks; a sooner call is rejected.
await sleep(Number(response.headers.get('Retry-After') ?? 60) * 1000);
} else if (response === null || response.status >= 500) {
transient += 1;
if (transient > MAX_TRANSIENT) throw new Error('API unreachable');
await sleep((2 ** (transient - 1) + Math.random()) * 1000);
} else {
// 400, 401, 403, 404: a retry returns the same answer. Fix the request.
const body = await response.json();
throw new Error(`${body.statusCode}: ${body.errorMessage}`);
}
}
throw new Error(`job ${jobId} still running after ${GIVE_UP_MS / 1000} s`);
}
const jobId = await startTtsJob('مرحبا بكم في إيكونوڤا', 'VOICE_ID');
const job = await waitForJob(jobId);
console.log(job.status, job.audioUrl ?? job.error);لتحويل الكلام إلى نص، غيّر المسارات إلى /v1/speech-to-text و
/v1/speech-to-text/jobs/{jobId}، وأرسل الملف كجزء file في طلب
multipart/form-data، واقرأ transcription بدلًا من audioUrl. تحتوي صفحة
أمثلة الكود على عملية الرفع باللغات الثلاث.
الخطوة 6: اختر الأرقام
العمود الثاني هو ما تفعله الواجهة، والعمود الثالث هو القيمة التي تستخدمها.
| الحالة | سلوك الواجهة | التوصية |
|---|---|---|
| فاصل الاستعلام | كل استعلام يُحسب من 100 طلب لكل 60 ثانية لكل مفتاح على تلك النقطة. الاستعلام الأسرع لا ينهي المهمة أبكر. | 3 ثوان لتحويل النص إلى كلام، و5 ثوان لتحويل الكلام إلى نص. لا تنزل أبدًا عن ثانية واحدة. |
429 عند حد المعدل |
يعطيك Retry-After عدد الثواني حتى تنتهي المدة. الطلبات المرسلة خلال المدة تُرفض، ولا تقصّرها. |
انتظر Retry-After ثانية بالضبط. لا حاجة إلى تشويش عشوائي، فالخادم وزّع طلباتك بالفعل. |
5xx أو غياب الاستجابة على GET |
القراءة مجانية. | انتظر 1، ثم 2، ثم 4، ثم 8، ثم 16 ثانية، مع تشويش عشوائي يصل إلى ثانية واحدة. توقف بعد 5 محاولات ونبّه فريقك. |
5xx على POST |
استُرد الاحتساب قبل إعادة الخطأ. | الجدول نفسه كما في GET. عند غياب الاستجابة كليًا، نفّذ الخطوة 4 قبل إعادة الإرسال. |
| التوقف عن الاستعلام | المهمة التي بقيت في processing أكثر من 10 دقائق تُعلَّم failed؛ وتتحول الحالة خلال 15 دقيقة من POST. |
توقف بعد 15 دقيقة من عودة POST، واقرأ المهمة مرة أخيرة، واعتبر أي مهمة ما زالت processing حادثًا يحتاج إلى متابعة. |
| الطلبات المتوازية | الحد لكل مفتاح ولكل نقطة. لمفتاحين عدادان منفصلان. | امنح كل نظام مفتاحه الخاص، وأبقِ استعلامات كل العاملين على مفتاح واحد أقل من 100 لكل 60 ثانية مجتمعة. |
ماذا يحدث بعد ذلك
- كل طلب أراد نظامك إرساله ينتج مهمة واحدة واحتسابًا واحدًا، ولديك
jobIdالخاص بها. - كل حلقة استعلام تنتهي. تصل المهمة إلى
completedأوfailedخلال 15 دقيقة من دخولهاprocessing، فلا تكون المهلة في الخطوة 5 هي أول ما يعمل في يوم سليم. - تحمل سجلاتك
jobId. وهوidالمهمة فيGET .../jobs، وحقلjobIdفي أحداث Webhook باسمjob.completedوjob.failed، فيمكن مطابقة المستعلِم ومعالج Webhook. راجع أحداث Webhook.
الحدود
| الحد | القيمة | ينطبق على |
|---|---|---|
| الطلبات | 100 لكل 60 ثانية لكل مفتاح ولكل نقطة | كل نقطة، بما فيها الاستعلامات |
Retry-After |
ثواني حتى تنتهي المدة | 429 حد المعدل فقط؛ و429 حد الرصيد لا يحمل هذا header |
الوقت في processing |
10 دقائق، ثم تُعلَّم failed خلال 15 دقيقة من POST |
المهام غير المتزامنة |
| المهام في صفحة القائمة | limit من 1 إلى 100، والافتراضي 20؛ وoffset 0 أو أكثر، والافتراضي 0 |
GET /v1/text-to-speech/jobs وGET /v1/speech-to-text/jobs |
استكشاف الأخطاء وإصلاحها
النص نفسه أنتج مهمتين ومبلغين محتسبين
أرسل عميلك POST مرتين. والأسباب المعتادة هي طبقة إعادة محاولة، أو مكتبة HTTP
بإعادة محاولة تلقائية على POST، أو عامل أُعيد تشغيله في منتصف الطلب. أوقف إعادة
المحاولة التلقائية لـ POST، واتبع الخطوة 4 عندما لا يحصل الطلب على استجابة. المهمتان
حقيقيتان: الاحتساب الثاني لا يُسترد إلا إذا فشلت تلك المهمة.
429 ThrottlerException: Too Many Requests أثناء الاستعلام الدوري
حلقة الاستعلام ضيقة أكثر من اللازم، أو أن عدة عاملين يستعلمون بالمفتاح نفسه. تحمل
الاستجابة Retry-After، فانتظر هذا العدد من الثواني. ثم ارفع فاصل الاستعلام، أو وزّع
العاملين على مفاتيح متعددة.
429 Monthly spending limit exhausted
سيتجاوز الطلب حد الرصيد الخاص بالمفتاح في دورة الفوترة هذه. هذا الخطأ ليس عابرًا
ولا يحمل Retry-After. ارفع الحد أو أزله من تعديل في البوابة، أو انتظر الدورة التالية.
انقطع الاتصال قبل وصول الاستجابة
ربما المهمة موجودة. إذا كان لديك jobId، فاستعلم به. وإذا لم يكن لديك، فاعرض قائمة
مهامك الأخيرة كما في الخطوة 4، وطابق على text وcreatedAt لتحويل النص إلى كلام، أو
على createdAt وحده لتحويل الكلام إلى نص، قبل أن تعيد إرسال الطلب.
404 TTS Job not found عند الاستعلام بـ jobId محفوظ
المعرّف يخص مساحة عمل أخرى، أو أُنشئ من البوابة، أو ليس معرّف مهمة أصلًا. المعرّف
المشوّه يعيد 400 Invalid identifier بدلًا من ذلك. تحقّق من مساحة العمل التي ينتمي
إليها مفتاح العامل.
503 Speech generation is temporarily unavailable. Please try again.
توليد الكلام لم يكن متاحًا في تلك المحاولة. استُرد الاحتساب. أعد المحاولة بالتراجع
من الخطوة 6. وإذا كانت المحاولة POST غير متزامن، فقائمة المهام تُظهر أيضًا مهمة
failed لهذه المحاولة؛ تجاهلها.
الأسئلة الشائعة
هل يوجد idempotency key؟
لا يوجد header أو حقل في الجسم يعلِّم POST بأنه تكرار لطلب سابق. احتفظ بـ jobId
من الاستجابة الأولى، واستخدم GET من تلك اللحظة.
هل تنجو المهمة إذا تعطّل عميلي؟
نعم. بعد أن يُقبل POST، تواصل المهمة طريقها إلى completed أو failed سواء كان
أحد يستعلم عنها أم لا، وتبقى في قائمة مهامك. استعدها كما في الخطوة 4.
هل يمكنني إلغاء مهمة؟
لا. المهمة التي بدأت تصل إلى نهايتها، وتُحتسب إلا إذا فشلت.
هل يكلّف الاستعلام الدوري رصيدًا؟
لا. فقط نقاط POST الخمس تُحتسب. وتكلفة GET الوحيدة هي الطلب نفسه من حصة حد
المعدل.
هل أعيد إرسال POST أعاد الرمز 503؟
نعم. استُرد الاحتساب لتلك المحاولة قبل إرسال 503، فطلب POST الجديد يُحتسب مرة واحدة. أعد المحاولة مع تراجع تدريجي، ولا تعد المحاولة دون تأخير.
كم يجب أن تستمر حلقة الاستعلام؟
خمس عشرة دقيقة تكفي. المهمة التي تبقى processing أكثر من 10 دقائق تُعلَّم failed،
وتتحول الحالة خلال 15 دقيقة من POST.