Integrar Uttera en tu código
No hay SDK. La API es HTTP y multipart/form-data corriente, así que se habla con ella desde cualquier lenguaje sin instalar nada nuestro. Abajo hay un cliente completo —subida, reintentos, lectura de lo facturado— en cuatro lenguajes.
Lo que conviene saber antes
Tiempos de espera: la regla es dos horas
El servidor mantiene la conexión abierta hasta 7200 segundos. Un cliente con el tiempo de espera por defecto —30 s en muchas librerías— corta trabajos que iban perfectamente. Es el fallo más común al integrar.
Como referencia medida: una grabación de 100 minutos se transcribe en 10 a 35 segundos según el nodo que la atienda. La espera larga no es para el caso normal, es para que el caso raro no se pierda.
Qué se reintenta y qué no
| Respuesta | ¿Reintentar? | Por qué |
|---|---|---|
429 | Sí, respetando Retry-After | Has pasado el cupo por segundo, o tienes demasiadas peticiones a la vez. |
502 503 504 | Sí, con espera creciente | Un nodo falló. El gatekeeper ya reintenta una vez por su cuenta antes de devolvértelo. |
400 401 402 403 404 413 415 422 | No | El problema está en la petición o en la cuenta. Reintentar da lo mismo y gasta cupo. |
Los errores que genera el borde llegan todos con la misma forma:
{"error": "quota_exceeded", "message": "Monthly credit balance depleted…"}
400 por un fichero que no se puede decodificar, o que pasa del tamaño máximo— la respuesta trae detail en vez de error y message:{"detail": "Maximum file size exceeded (parameter=audio_filesize_mb, value=112.9)"}Al leer un error, mira error y cae a detail si no está.Cabeceras que trae cada respuesta
| Cabecera | Cuándo aparece | Qué dice |
|---|---|---|
X-Request-Id | siempre | Identifica la petición. Es lo primero que te vamos a pedir si algo va mal. |
X-Audio-Duration | audio de entrada o generado | Segundos que se facturan. No está en el cuerpo, solo aquí. |
X-RateLimit-ServiceX-RateLimit-Limit-SecondX-RateLimit-Remaining-Second | siempre | Cupo por segundo del servicio que has usado, y lo que te queda. |
X-Credits-Limit-MonthlyX-Credits-Used-MonthlyX-Credits-Remaining-MonthlyX-Credits-Reset-Monthly | solo si tu plan tiene bolsa | Estado de los créditos del ciclo. |
X-Credits-Overage | al pasarte de la bolsa | true: sigues servido, pero en exceso facturable. |
X-Concurrency-LimitX-Concurrency-ActiveX-Concurrency-Slots | solo si tu plan tiene tope | Peticiones simultáneas permitidas, en curso, y cuántas consume esta. |
X-Cache | voz | HIT · MISS · BYPASS · ADHOC · DISABLED. Un acierto cuesta una décima parte; BYPASS confirma que no se guardó nada. |
Retry-After | 429 y 402 | Segundos que hay que esperar. Mándalo él, no tu propia cuenta. |
enterprise, con bolsa y concurrencia sin tope, no verás ninguna de las de créditos ni de concurrencia. No es un fallo: no hay nada que contar.Un endpoint mueve varios servicios
/v1/translate y /v1/summarize encadenan varios motores, y cada tramo consume un hueco de concurrencia. Con un plan de 3 simultáneas, dos resúmenes a la vez pueden darte 429 too_many_concurrent_requests aunque solo hayas lanzado dos peticiones. Mira X-Concurrency-Slots para saber cuántos huecos gasta cada una.
Normaliza el audio antes de enviarlo
El motor remuestrea todo a 16 kHz mono de todas formas. Si subes un WAV de 48 kHz estéreo estás pagando ancho de banda y tiempo de subida por información que se va a tirar, y te acercas antes al tope de tamaño.
ffmpeg -i original.wav -ar 16000 -ac 1 -b:a 64k listo.mp3
Dos horas de audio así pesan unos 58 MB, muy por debajo del límite. La misma grabación en WAV de 48 kHz estéreo serían 1,3 GB.
Bash
Para trabajos de una vez, cron y tuberías. Solo necesita curl.
#!/usr/bin/env bash
# Transcribir una grabación, con reintentos y lectura de lo facturado.
set -euo pipefail
: "${UTTERA_API_KEY:?exporta tu clave: export UTTERA_API_KEY=sk-echo-...}"
API=https://api.uttera.ai
FICHERO=${1:?uso: ./transcribir.sh grabacion.mp3}
# La regla es dos horas: el servidor mantiene la conexión hasta 7200 s, así que
# el cliente no debe rendirse antes o cortarás un trabajo que iba bien.
MAX_ESPERA=7200
intentar() {
curl -sS --max-time "$MAX_ESPERA" \
-D /tmp/uttera.cab -o /tmp/uttera.out -w '%{http_code}' \
-H "Authorization: Bearer $UTTERA_API_KEY" \
-F "file=@${FICHERO}" \
-F "model=whisper-1" \
-F "response_format=text" \
"$API/v1/audio/transcriptions"
}
for intento in 1 2 3 4 5; do
codigo=$(intentar) || codigo=000
# 2xx: hecho.
if [ "$codigo" -ge 200 ] && [ "$codigo" -lt 300 ]; then
cat /tmp/uttera.out
echo
echo "--- segundos facturados: $(grep -i '^x-audio-duration:' /tmp/uttera.cab | tr -d '\r' | cut -d' ' -f2)"
echo "--- id de petición: $(grep -i '^x-request-id:' /tmp/uttera.cab | tr -d '\r' | cut -d' ' -f2)"
exit 0
fi
# 4xx (menos 429): el problema es tuyo. Reintentar no lo arregla.
if [ "$codigo" -ge 400 ] && [ "$codigo" -lt 500 ] && [ "$codigo" != 429 ]; then
echo "error $codigo, no se reintenta:" >&2
cat /tmp/uttera.out >&2
exit 1
fi
# 429 y 5xx: temporal. Si viene Retry-After, mándalo él.
espera=$(grep -i '^retry-after:' /tmp/uttera.cab | tr -d '\r' | cut -d' ' -f2 || true)
[ -n "${espera:-}" ] || espera=$(( 2 ** intento ))
echo "error $codigo, intento $intento, reintento en ${espera}s" >&2
sleep "$espera"
done
echo "agotados los reintentos" >&2
exit 1
Python
Solo necesita requests. Incluye la consulta de lo cobrado, que se pide aparte porque el cargo se calcula después de responderte.
"""Cliente mínimo de Uttera. Solo necesita `requests`."""
import os
import time
import requests
API = "https://api.uttera.ai"
# La regla es dos horas: el servidor aguanta hasta 7200 s. El primer número es
# el tiempo para conectar, el segundo el de espera entre bytes de respuesta.
ESPERA = (10, 7200)
# 429 pide que esperes; 502/503/504 son un nodo que falló y se reintenta solo
# una vez en el servidor. El resto de 4xx son tuyos: reintentar no los arregla.
REINTENTABLES = {429, 502, 503, 504}
class ErrorUttera(RuntimeError):
def __init__(self, estado, cuerpo, request_id=None):
self.estado = estado
self.codigo = (cuerpo or {}).get("error")
self.request_id = request_id
super().__init__(f"{estado} {self.codigo}: {(cuerpo or {}).get('message')}")
class Uttera:
def __init__(self, clave=None, api=API, intentos=4):
self.clave = clave or os.environ["UTTERA_API_KEY"]
self.api = api
self.intentos = intentos
self.sesion = requests.Session()
def _llamar(self, metodo, ruta, **kw):
cabeceras = {"Authorization": f"Bearer {self.clave}"}
cabeceras.update(kw.pop("headers", {}))
for intento in range(1, self.intentos + 1):
r = self.sesion.request(metodo, self.api + ruta,
headers=cabeceras, timeout=ESPERA, **kw)
if r.ok:
return r
if r.status_code not in REINTENTABLES or intento == self.intentos:
try:
cuerpo = r.json()
except ValueError:
cuerpo = {"message": r.text[:200]}
raise ErrorUttera(r.status_code, cuerpo, r.headers.get("X-Request-Id"))
# Si el servidor dice cuánto esperar, manda él.
espera = float(r.headers.get("Retry-After", 2 ** intento))
time.sleep(espera)
def transcribir(self, ruta_audio, **campos):
campos.setdefault("model", "whisper-1")
with open(ruta_audio, "rb") as f:
r = self._llamar("POST", "/v1/audio/transcriptions",
files={"file": (os.path.basename(ruta_audio), f)},
data=campos)
return r
def sintetizar(self, texto, voz="alloy", formato="mp3"):
return self._llamar("POST", "/v1/audio/speech",
json={"model": "tts-1", "voice": voz,
"input": texto, "response_format": formato})
def ultimo_cargo(self, endpoint=None):
params = {"endpoint": endpoint} if endpoint else None
return self._llamar("GET", "/v1/usage/last", params=params).json()
if __name__ == "__main__":
import sys
u = Uttera()
r = u.transcribir(sys.argv[1], response_format="text")
print(r.text.strip())
# Los segundos que se facturan vienen en la cabecera, no en el cuerpo.
print("segundos facturados:", r.headers.get("X-Audio-Duration"))
print("id de petición: ", r.headers.get("X-Request-Id"))
# El cargo se calcula DESPUÉS de responderte, así que se consulta aparte.
print("cobrado:", u.ultimo_cargo()["credits"], "créditos")
JavaScript · Node
Sin dependencias: fetch, FormData y Blob son nativos en Node desde la 20. El fichero es un módulo ES, así que o lo llamas .mjs o pones "type": "module" en tu package.json.
// Cliente mínimo de Uttera. Sin dependencias: fetch, FormData y Blob son
// nativos en Node desde la 20.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";
const API = "https://api.uttera.ai";
// 429 pide que esperes; 5xx es un nodo que falló. El resto de 4xx son tuyos:
// reintentar no los arregla.
const REINTENTABLES = new Set([429, 502, 503, 504]);
export class ErrorUttera extends Error {
constructor(estado, cuerpo, requestId) {
super(`${estado} ${cuerpo?.error}: ${cuerpo?.message}`);
this.name = "ErrorUttera";
this.estado = estado;
this.codigo = cuerpo?.error;
this.requestId = requestId;
}
}
const dormir = (s) => new Promise((r) => setTimeout(r, s * 1000));
export class Uttera {
constructor(clave = process.env.UTTERA_API_KEY, { api = API, intentos = 4 } = {}) {
if (!clave) throw new Error("falta UTTERA_API_KEY");
this.clave = clave;
this.api = api;
this.intentos = intentos;
}
async #llamar(ruta, opciones = {}, rearmar = null) {
for (let intento = 1; intento <= this.intentos; intento++) {
// El cuerpo se consume al enviarlo: si hay que reintentar, se rearma.
const cuerpo = rearmar ? await rearmar() : opciones.body;
// La regla es dos horas: el servidor aguanta hasta 7200 s. Sin esto,
// fetch espera para siempre; con 30 s cortarías trabajos que iban bien.
const res = await fetch(this.api + ruta, {
...opciones,
body: cuerpo,
headers: { Authorization: `Bearer ${this.clave}`, ...opciones.headers },
signal: AbortSignal.timeout(7200_000),
});
if (res.ok) return res;
if (!REINTENTABLES.has(res.status) || intento === this.intentos) {
let detalle = null;
try { detalle = await res.json(); } catch { detalle = { message: await res.text() }; }
throw new ErrorUttera(res.status, detalle, res.headers.get("x-request-id"));
}
// Si el servidor dice cuánto esperar, manda él.
await dormir(Number(res.headers.get("retry-after")) || 2 ** intento);
}
}
async transcribir(ruta, campos = {}) {
const rearmar = async () => {
const fd = new FormData();
fd.append("file", await openAsBlob(ruta), basename(ruta));
fd.append("model", campos.model ?? "whisper-1");
for (const [k, v] of Object.entries(campos)) if (k !== "model") fd.append(k, v);
return fd;
};
return this.#llamar("/v1/audio/transcriptions", { method: "POST" }, rearmar);
}
async sintetizar(texto, voz = "alloy", formato = "mp3") {
return this.#llamar("/v1/audio/speech", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "tts-1", voice: voz, input: texto, response_format: formato }),
});
}
async ultimoCargo(endpoint) {
const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
return (await this.#llamar(`/v1/usage/last${q}`)).json();
}
}
// ── uso ────────────────────────────────────────────────────────────────────
const u = new Uttera();
const res = await u.transcribir(process.argv[2], { response_format: "text" });
console.log((await res.text()).trim());
// Los segundos facturados vienen en la cabecera, no en el cuerpo.
console.log("segundos facturados:", res.headers.get("x-audio-duration"));
console.log("id de petición: ", res.headers.get("x-request-id"));
// El cargo se calcula DESPUÉS de responderte, así que se consulta aparte.
console.log("cobrado:", (await u.ultimoCargo()).credits, "créditos");
TypeScript
El mismo cliente con los tipos de las respuestas y de los errores, que es lo que de verdad aporta aquí: codigo es estable y se puede ramificar sobre él, message es para que lo lea un humano.
node uttera.ts, sin paso de compilación. A cambio hay que quedarse en el subconjunto borrable del lenguaje: nada de enum, namespace ni «parameter properties» (constructor(private x: T)), porque eso emite código y no solo tipos. Con erasableSyntaxOnly en el tsconfig.json, el compilador te avisa si te sales.{
"compilerOptions": {
"target": "ES2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["ES2023", "DOM"],
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"types": ["node"]
}
}
// Cliente mínimo de Uttera en TypeScript. Sin dependencias.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";
const API = "https://api.uttera.ai";
const REINTENTABLES = new Set([429, 502, 503, 504]);
/** Forma de TODOS los errores de la API. Siempre la misma, se traten donde se traten. */
export interface CuerpoError {
error: string;
message: string;
/** Solo en los errores que genera el borde (404, 413, 502…). */
status?: number;
docs?: string;
}
/** Lo que devuelve GET /v1/usage/last. El desglose depende del endpoint. */
export interface Cargo {
endpoint: string;
service: string;
credits: number;
breakdown: Record<string, number>;
ts: number;
audio_seconds?: number;
input_tokens?: number;
output_tokens?: number;
}
export type FormatoRespuesta = "json" | "text" | "verbose_json" | "srt" | "vtt";
export interface OpcionesTranscribir {
model?: string;
language?: string;
prompt?: string;
response_format?: FormatoRespuesta;
temperature?: number;
}
export class ErrorUttera extends Error {
readonly estado: number;
readonly codigo: string;
readonly requestId: string | null;
constructor(estado: number, cuerpo: Partial<CuerpoError>, requestId: string | null) {
super(`${estado} ${cuerpo.error}: ${cuerpo.message}`);
this.name = "ErrorUttera";
this.estado = estado;
this.codigo = cuerpo.error ?? "unknown";
this.requestId = requestId;
}
}
const dormir = (s: number): Promise<void> =>
new Promise((r) => setTimeout(r, s * 1000));
export class Uttera {
// ⚠ Campos declarados y asignados a mano, NO «parameter properties»
// (`constructor(private clave: string)`). Esa forma emite código, no solo
// tipos, y Node se niega a ejecutarla directamente: con campos normales este
// fichero corre tal cual con `node uttera.ts`, sin paso de compilación.
private readonly clave: string;
private readonly intentos: number;
private readonly api: string;
constructor(
clave: string = process.env.UTTERA_API_KEY ?? "",
intentos: number = 4,
api: string = API,
) {
if (!clave) throw new Error("falta UTTERA_API_KEY");
this.clave = clave;
this.intentos = intentos;
this.api = api;
}
private async llamar(
ruta: string,
opciones: RequestInit = {},
rearmar?: () => Promise<BodyInit>,
): Promise<Response> {
for (let intento = 1; intento <= this.intentos; intento++) {
// El cuerpo se consume al enviarlo: si hay que reintentar, se rearma.
const body = rearmar ? await rearmar() : opciones.body;
// La regla es dos horas: el servidor aguanta hasta 7200 s.
const res = await fetch(this.api + ruta, {
...opciones,
body,
headers: { Authorization: `Bearer ${this.clave}`, ...opciones.headers },
signal: AbortSignal.timeout(7_200_000),
});
if (res.ok) return res;
if (!REINTENTABLES.has(res.status) || intento === this.intentos) {
const cuerpo = (await res.json().catch(() => ({}))) as Partial<CuerpoError>;
throw new ErrorUttera(res.status, cuerpo, res.headers.get("x-request-id"));
}
// Si el servidor dice cuánto esperar, manda él.
await dormir(Number(res.headers.get("retry-after")) || 2 ** intento);
}
throw new Error("inalcanzable");
}
async transcribir(ruta: string, opciones: OpcionesTranscribir = {}): Promise<Response> {
const rearmar = async (): Promise<FormData> => {
const fd = new FormData();
fd.append("file", await openAsBlob(ruta), basename(ruta));
fd.append("model", opciones.model ?? "whisper-1");
for (const [k, v] of Object.entries(opciones)) {
if (k !== "model" && v !== undefined) fd.append(k, String(v));
}
return fd;
};
return this.llamar("/v1/audio/transcriptions", { method: "POST" }, rearmar);
}
async ultimoCargo(endpoint?: string): Promise<Cargo> {
const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
return (await this.llamar(`/v1/usage/last${q}`)).json() as Promise<Cargo>;
}
}
// ── uso ────────────────────────────────────────────────────────────────────
const u = new Uttera();
try {
const res = await u.transcribir(process.argv[2]!, { response_format: "text" });
console.log((await res.text()).trim());
console.log("segundos facturados:", res.headers.get("x-audio-duration"));
const cargo = await u.ultimoCargo();
console.log("cobrado:", cargo.credits, "créditos", cargo.breakdown);
} catch (e) {
if (e instanceof ErrorUttera) {
// `codigo` es estable; `message` es para leerlo un humano, no para ramificar.
console.error(`fallo ${e.codigo} (request ${e.requestId})`);
process.exit(1);
}
throw e;
}
Go
Sin dependencias: todo es biblioteca estándar. Ojo a un detalle que muerde: el cuerpo multipart se consume al enviarlo, así que hay que rearmarlo en cada reintento, no reutilizar el mismo lector.
// Cliente mínimo de Uttera. Sin dependencias: solo biblioteca estándar.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
const api = "https://api.uttera.ai"
// La regla es dos horas: el servidor aguanta hasta 7200 s. Un Client sin
// Timeout espera para siempre; uno con 30 s corta trabajos que iban bien.
var cliente = &http.Client{Timeout: 2 * time.Hour}
// 429 pide que esperes; 5xx es un nodo que falló. El resto de 4xx son tuyos.
func reintentable(codigo int) bool {
return codigo == 429 || codigo == 502 || codigo == 503 || codigo == 504
}
type ErrorAPI struct {
Estado int `json:"-"`
Codigo string `json:"error"`
Mensaje string `json:"message"`
RequestID string `json:"-"`
}
func (e *ErrorAPI) Error() string {
return fmt.Sprintf("%d %s: %s (request %s)", e.Estado, e.Codigo, e.Mensaje, e.RequestID)
}
// multipart hay que rearmarlo en cada intento: el cuerpo se consume al enviarlo.
func cuerpoAudio(ruta string, campos map[string]string) (io.Reader, string, error) {
f, err := os.Open(ruta)
if err != nil {
return nil, "", err
}
defer f.Close()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
parte, err := w.CreateFormFile("file", filepath.Base(ruta))
if err != nil {
return nil, "", err
}
if _, err := io.Copy(parte, f); err != nil {
return nil, "", err
}
for k, v := range campos {
if err := w.WriteField(k, v); err != nil {
return nil, "", err
}
}
if err := w.Close(); err != nil {
return nil, "", err
}
return &buf, w.FormDataContentType(), nil
}
func Transcribir(clave, ruta string, campos map[string]string) (string, http.Header, error) {
for intento := 1; intento <= 4; intento++ {
cuerpo, tipo, err := cuerpoAudio(ruta, campos)
if err != nil {
return "", nil, err
}
req, err := http.NewRequest("POST", api+"/v1/audio/transcriptions", cuerpo)
if err != nil {
return "", nil, err
}
req.Header.Set("Authorization", "Bearer "+clave)
req.Header.Set("Content-Type", tipo)
res, err := cliente.Do(req)
if err != nil {
return "", nil, err
}
datos, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode < 300 {
return string(datos), res.Header, nil
}
if !reintentable(res.StatusCode) || intento == 4 {
e := &ErrorAPI{Estado: res.StatusCode, RequestID: res.Header.Get("X-Request-Id")}
if json.Unmarshal(datos, e) != nil {
e.Mensaje = string(datos)
}
return "", res.Header, e
}
// Si el servidor dice cuánto esperar, manda él.
espera := math.Pow(2, float64(intento))
if ra, err := strconv.ParseFloat(res.Header.Get("Retry-After"), 64); err == nil {
espera = ra
}
time.Sleep(time.Duration(espera * float64(time.Second)))
}
return "", nil, fmt.Errorf("agotados los reintentos")
}
func main() {
clave := os.Getenv("UTTERA_API_KEY")
if clave == "" || len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "uso: UTTERA_API_KEY=sk-echo-... ./uttera grabacion.mp3")
os.Exit(2)
}
texto, cab, err := Transcribir(clave, os.Args[1], map[string]string{
"model": "whisper-1",
"response_format": "text",
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(texto)
// Los segundos facturados vienen en la cabecera, no en el cuerpo.
fmt.Println("segundos facturados:", cab.Get("X-Audio-Duration"))
fmt.Println("id de petición: ", cab.Get("X-Request-Id"))
}
Rust
Con reqwest en modo bloqueante, que para un cliente de API se lee mucho mejor que el asíncrono. Se usa rustls en vez de native-tls para no depender del OpenSSL del sistema.
[package]
name = "uttera"
version = "0.1.0"
edition = "2021"
[dependencies]
# rustls en vez de native-tls: así no hace falta tener OpenSSL del sistema.
reqwest = { version = "0.12", default-features = false, features = [
"blocking", "multipart", "json", "rustls-tls", "charset", "http2",
] }
serde_json = "1"
//! Cliente mínimo de Uttera.
use std::{env, thread, time::Duration};
const API: &str = "https://api.uttera.ai";
/// 429 pide que esperes; 5xx es un nodo que falló. El resto de 4xx son tuyos:
/// reintentar no los arregla.
fn reintentable(codigo: u16) -> bool {
matches!(codigo, 429 | 502 | 503 | 504)
}
fn transcribir(clave: &str, ruta: &str) -> Result<(String, reqwest::header::HeaderMap), String> {
// La regla es dos horas: el servidor aguanta hasta 7200 s. Un timeout corto
// corta trabajos que iban bien.
let cliente = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(7200))
.build()
.map_err(|e| e.to_string())?;
for intento in 1..=4u32 {
// El formulario se consume al enviarlo: hay que rearmarlo cada vez.
let formulario = reqwest::blocking::multipart::Form::new()
.file("file", ruta)
.map_err(|e| e.to_string())?
.text("model", "whisper-1")
.text("response_format", "text");
let res = cliente
.post(format!("{API}/v1/audio/transcriptions"))
.bearer_auth(clave)
.multipart(formulario)
.send()
.map_err(|e| e.to_string())?;
let codigo = res.status().as_u16();
let cabeceras = res.headers().clone();
let cuerpo = res.text().unwrap_or_default();
if (200..300).contains(&codigo) {
return Ok((cuerpo, cabeceras));
}
if !reintentable(codigo) || intento == 4 {
let detalle = serde_json::from_str::<serde_json::Value>(&cuerpo)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
.unwrap_or(cuerpo);
let id = cabeceras
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
return Err(format!("{codigo}: {detalle} (request {id})"));
}
// Si el servidor dice cuánto esperar, manda él.
let espera = cabeceras
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1u64 << intento);
thread::sleep(Duration::from_secs(espera));
}
Err("agotados los reintentos".into())
}
fn main() {
let clave = env::var("UTTERA_API_KEY").expect("exporta UTTERA_API_KEY");
let ruta = env::args().nth(1).expect("uso: uttera grabacion.mp3");
match transcribir(&clave, &ruta) {
Ok((texto, cab)) => {
println!("{}", texto.trim());
// Los segundos facturados vienen en la cabecera, no en el cuerpo.
let leer = |n| cab.get(n).and_then(|v| v.to_str().ok()).unwrap_or("-");
println!("segundos facturados: {}", leer("x-audio-duration"));
println!("id de petición: {}", leer("x-request-id"));
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}