Când ceva eșuează
Ce vede efectiv cel care apelează API-ul, cel care primește webhook-uri și inginerul de gardă atunci când ceva merge prost, și ce ai de făcut în fiecare caz.
Tabelul complet de coduri e pe Coduri de eroare. Pagina asta e jumătatea practică: ce eșecuri se reîncearcă, care se repară și unde te uiți când un apel a mers prost și nimeni nu știe de ce.
Body-ul de eroare al API-ului
Fiecare răspuns non-2xx de la /api/v1 are forma asta. E snake_case și e împachetat în error:
{
"error": {
"type": "validation_error",
"message": "The request payload failed validation.",
"errors": { "secrets": ["The secrets field is required."] },
"documentation_url": "https://docs.callagent.dev/api/errors#validation",
"request_id": "01M1ZKS6SVXR3CHEV87WCSFM60"
}
}| Câmp | Când apare |
|---|---|
type | Mereu. Categoria — vezi tabelul de mai jos. |
message | Mereu. Se poate arăta unui dezvoltator; nu conține niciodată textul brut al excepției. |
request_id | Mereu. Un ULID, sau X-Request-Id-ul trimis de tine dacă avea cel mult 64 de caractere. Citează-l într-un raport de bug. |
errors | Doar la eșecuri de validare. Pe câmpuri, în forma Laravel. |
code | La eșecuri de furnizor și de plafon de plan. |
retryable | La eșecuri de furnizor, de rate limit și de server. |
provider, provider_request_id | La eșecuri de furnizor. |
documentation_url | De obicei. |
Valorile lui type
type | HTTP | Ce înseamnă | Ce faci |
|---|---|---|---|
validation_error | 422 | Body-ul tău e greșit. errors spune care câmp. | Repară și retrimite. |
authentication_error | 401 | Cheie lipsă, invalidă sau revocată. | Verifică cheia. Fereastra de suprapunere la rotire poate să fi expirat. |
permission_error | 403 | Cheii îi lipsește scope-ul, sau restricția de origin/IP a refuzat-o. | Lărgește scope-ul sau apelează de la un origin permis. |
not_found | 404 | Nu există resursa — și asta întoarce și o resursă a altui tenant. | Verifică id-ul și organizația căreia îi aparține cheia. |
rate_limit_error | 429 | Prea multe request-uri. | Așteaptă Retry-After secunde. Vezi Limite de rate. |
plan_limit_exceeded | 402 | Un plafon comercial, nu unul tehnic. limit și used sunt în body. | Fă upgrade sau șterge ceva. |
provider_error | 502 / 504 / 429 | Un vendor a respins sau a expirat. provider îl numește. | Citește retryable. |
ssrf_blocked | 422 | Un URL dat de tine se rezolvă undeva unde nu are voie. | Folosește un host public, care se rezolvă. |
invalid_state | 422 | Acțiunea nu se aplică în starea asta. | |
http_error | variază | O eroare HTTP de la nivelul framework-ului. | |
server_error | 500 | E a noastră. retryable: true, și e raportată pe server. | Reîncearcă o dată, apoi citează request_id. |
404 e intenționat identic pentru „nu există" și „există în altă organizație". Nu e o scăpare — dacă le-ai deosebi, i-ai spune unui atacator care id-uri sunt reale.
Două endpoint-uri răspund în afara acestui format
Middleware-ul de chei de API întoarce propriul body de 401/403, fără request_id, pentru că rulează înaintea renderer-ului. Iar POST /webhook-deliveries/{id}/redeliver își construiește propriul 422 pentru „livrarea asta nu poate fi retrimisă". Amândouă folosesc aceleași nume de câmp error.type / error.message, deci un receptor care se uită la ele funcționează în continuare.
Ce se reîncearcă
Zece coduri de eroare se pot reîncerca, iar retryable: true e derivat din lista aia, nu setat de mână:
provider_timeout · provider_rate_limit · provider_unavailable · provider_bad_response · mcp_connection_failed · mcp_timeout · tool_timeout · storage_failure · media_failure · rate_limited
Restul eșuează identic la a doua încercare. Reîncearcă-le pe cele zece; repară-le pe celelalte.
Când reîncerci POST /calls, trimite același idempotency_key. Altfel o reîncercare după un timeout sună omul de două ori. Vezi Telefonie.
Când un apel eșuează
POST /calls răspunde 201 de îndată ce există rândul de apel. Asta nu e o promisiune că a fost sunat cineva. Citește status și ended_reason.
Cele 20 de valori ended_reason
| Grup | Valori |
|---|---|
| A închis cineva | customer_hung_up, operator_hung_up, assistant_ended, assistant_ended_call_tool, transferred_away, api_request |
| Conversația s-a epuizat | silence_timeout, max_duration_reached, voicemail_detected |
| Carrier-ul a refuzat | no_answer, busy, rejected |
| Noi am refuzat | insufficient_credit, concurrency_limit, destination_blocked |
| S-a stricat ceva | pipeline_error, provider_error, telephony_error, gateway_lost |
unknown |
ended_by spune cine: customer, assistant, operator, system, api, supervisor.
Cele trei care înseamnă „am refuzat noi"
Astea sunt cele pentru care se deschid bug-uri, și fiecare are o cauză precisă:
concurrency_limit—included_concurrency + burst_concurrencyal organizației era deja ocupat, sau o limită per campanie, per număr sau per trunk era. Verifică apelurile concurente înainte să presupui un bug.destination_blocked— politica de destinație a refuzat. Blocklist, politică de țară, un prefix premium (1900,1976), de satelit (870–874,881–883) sau cu cost mare, un plafon orar, zilnic sau de minute pe zi, ori o rută de ieșire marcată explicit ca blocată.insufficient_credit— soldul din ledger, un plafon zilnic de cheltuială, un plafon de volum al planului sau un blocaj de dunning.
telephony_error acoperă un set precis: destinație lipsă, nicio rută, from lipsă, un caller ID neverificat (ai cerut să prezinți un număr care nu e al organizației tale), un furnizor necunoscut, un furnizor neimplementat, o credențială lipsă sau un eșec de conectare la carrier.
gateway_lost înseamnă că voice gateway-ul a murit în timpul apelului și un reaper a finalizat rândul. Transcrierea se oprește acolo unde s-a oprit gateway-ul.
Unde te uiți, în ordine
curl "$CALLAGENT_API_URL/calls/$CALL_ID" -H "Authorization: Bearer $KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/events" -H "Authorization: Bearer $KEY"
curl "$CALLAGENT_API_URL/calls/$CALL_ID/debug" -H "Authorization: Bearer $KEY"ended_reasonde pe apel. De nouă ori din zece e de ajuns.events— se populează chiar și pentru un apel care nu s-a conectat niciodată.call.created, apoicall.state.changedpentru fiecare tranziție, cuoffset_msde la începutul apelului. Ultimul eveniment dinaintea eșecului e cel care contează.metrics, dacă s-a conectat dar a părut lent — desface turul pe etape de pipeline.debugpentru tot pachetul.
Un apel care se conectează și apoi moare în tăcere
Eșecul care merită numit, pentru că e ușor de provocat și imposibil de văzut din API: configurația de apel a gateway-ului a fost respinsă, deci nu are ce rula.
Configurația e aruncată întreagă — nu parțial — dacă lipsește oricare dintre astea: model.provider, model.model, transcriber.provider, voice.provider, voice.voice_id. GET /assistants/{id}/validate verifică patru din cinci și ratează voice_id.
voice_id e și cel pe care nu îl poți seta prin API: POST/PATCH /assistants ciuntesc orice sub-cheie din voice, model, transcriber și server care nu are o regulă de validare, iar voice.voice_id e una dintre ele. Verifică cu GET /assistants/{id} — dacă voice se întoarce ca {"provider": "…"} și nimic altceva, asistentul acela nu poate porni un apel. Setează vocea din dashboard. Detalii complete pe Ce nu este construit.
E respinsă și dacă transcriber-ul e unul pentru care gateway-ul nu are adaptor. Pe aia validate o prinde, și de asta merită apelat înainte de publicare.
Când un webhook eșuează
Din partea ta, o livrare care nu ajunge e una dintre:
Endpoint-ul tău a întors 4xx | Nu se reîncearcă. Un 404 sau 410 e tratat exact ca un 400 — ai spus nu. |
Endpoint-ul tău a întors 5xx sau a expirat | Se reîncearcă la 10 s, 30 s, 2 min, 10 min, 30 min, apoi din oră în oră, până la max_attempts (implicit 6). |
| URL-ul tău a fost blocat de garda SSRF | Mort imediat. O reîncercare către o destinație blocată e blocată din nou. |
| Te-ai abonat la ceva ce nu se declanșează | O listă events goală exclude cele patru tipuri de volum mare. |
| 20 de eșecuri consecutive | Endpoint-ul e dezactivat. active: false, cu disabled_reason setat. Un singur succes resetează contorul. |
GET /webhook-deliveries?status=dead e primul lucru de verificat. Fiecare livrare păstrează statusul răspunsului, body-ul (redactat) și eroarea, iar pentru fiecare încercare există un rând separat, doar cu inserări, cu IP-ul rezolvat.
Dacă handler-ul tău a rulat dar ai răspuns încet, o să primești o reîncercare a unui eveniment pe care deja l-ai procesat. Deduplică pe X-CallAgent-Event-Id — vezi Webhooks.
Când un tool eșuează
Un eșec de tool nu doboară niciodată apelul. Întoarce un rezultat structurat către model, care poate spune ceva util și merge mai departe.
| Rezultat | Cauză |
|---|---|
not_found | Un tool function fără URL de server, sau un tool MCP care a dispărut. |
tool_timeout | Bugetul de execuție al tool-ului a expirat. Se poate reîncerca. |
internal_error | Tool-ul a aruncat. Caută tool.failed în GET /calls/{id}/events. |
| Respins | Politică, nivel de risc sau o confirmare refuzată. Asistentul spune linia din rejection_plan. |
Un tool pe care modelul nu l-a apelat deloc e de obicei:
- un tool de tip
bash,computer,text_editor,client_sidesaucustom_provider— astea sunt aruncate din fiecare apel, în tăcere; - un tool cu un nume pe care modelul nu îl poate adresa. Orice iese din
[A-Za-z0-9_-]e aruncat, nu stâlcit, pentru că un tool redenumit devine neapelabil într-un fel pe care nimic din interfață nu îl arată; - un tool
api_requestfărăconfig.url, aruncat la conversie; - un tool nepublicat, sau atașat cu
pin_mode: pinnedla o versiune care nu mai există.
GET /assistants/{id}/preview listează ce s-a rezolvat efectiv, cu exposed_name.
Cum raportezi o problemă
Include:
request_iddin body-ul de eroare, sau ULID-ul apelului;ended_reasonșiended_by;config_hashal asistentului, dinGET /assistants/{id}/versions, ca să se poată identifica exact configurația;- momentul, cu offset. Totul e stocat ISO 8601 cu offset explicit.
Mai departe
- Coduri de eroare — tabelul complet, cu statusurile HTTP.
- Limite de rate — 429-ul în detaliu.
- Ce nu este construit — înainte să deschizi un bug pentru o funcționalitate care nu a fost niciodată conectată.