2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// cloudTimeout is the budget for a single synthesis call of a cloud
|
|
|
|
|
// provider (connect + request + complete response). Announcements are
|
|
|
|
|
// short; once it expires the chain falls back to the next tier.
|
2026-10-18 16:34:43 +02:00
|
|
|
const cloudTimeout = 45 * time.Second
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// cloudClient is the shared HTTP client of the REST providers (with a
|
|
|
|
|
// timeout, unlike http.DefaultClient). If a response never arrives, the
|
|
|
|
|
// chain falls back instead of blocking forever.
|
2026-10-18 16:34:43 +02:00
|
|
|
var cloudClient = &http.Client{Timeout: cloudTimeout}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// maxErrBody limits how much of an error response is copied into the error
|
|
|
|
|
// message (diagnosis yes, spam no).
|
2026-10-18 16:34:43 +02:00
|
|
|
const maxErrBody = 200
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// postForBytes is the shared core of the REST providers: POST JSON to url
|
|
|
|
|
// with headers, expect 2xx and return the raw response bytes (audio).
|
|
|
|
|
// Everything else (non-2xx, network errors, timeouts, empty responses) is a
|
|
|
|
|
// clear error on which the chain falls back to the next tier.
|
2026-10-18 16:34:43 +02:00
|
|
|
func postForBytes(ctx context.Context, client *http.Client, providerName, url string, headers map[string]string, body []byte) ([]byte, error) {
|
|
|
|
|
if client == nil {
|
|
|
|
|
client = cloudClient
|
|
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// Apply our own time budget if the caller does not bring one; the chain
|
|
|
|
|
// must be able to fall through even without an external deadline.
|
2026-10-18 16:34:43 +02:00
|
|
|
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
|
|
|
|
var cancel context.CancelFunc
|
|
|
|
|
ctx, cancel = context.WithTimeout(ctx, cloudTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("%s: building request to %s: %w", providerName, url, err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
for k, v := range headers {
|
|
|
|
|
req.Header.Set(k, v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("%s: request to %s failed (network/timeout?): %w", providerName, url, err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
|
|
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrBody))
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("%s: HTTP %d from %s: %s", providerName, resp.StatusCode, url, bytes.TrimSpace(snippet))
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("%s: reading response from %s: %w", providerName, url, err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
if len(data) == 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("%s: empty response from %s (0 bytes of audio)", providerName, url)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
return data, nil
|
|
|
|
|
}
|