Files
tiennm99 c18006fabc fix(renderer): raise the default render timeout to 30s
Under normal load on the shared host, a /wheelofnames GIF takes 11-12s
and a /gacha wish 14-15s to render, so the 15s default turned ordinary
renders into 504s. The default is now 30s, and the bot waits 45s so it
never abandons a render the renderer is still allowed to finish.
2026-10-03 13:06:04 +07:00

201 lines
5.9 KiB
Go

package random
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
// rendererURLEnv holds the renderer's base URL, e.g. http://renderer:3000.
// The standard renderer is the renderer/ service in this repository, wired
// in by compose.yml and reachable only on the compose network, so requests
// carry no credentials. The client appends each animation's /api/<name>
// path itself.
rendererURLEnv = "RENDERER_URL"
rendererMaxBytes = 12 << 20
// rendererTimeout outlasts the renderer's own default render limit
// (RENDERER_RENDER_TIMEOUT_MS, 30s) plus upload time, so a slow render
// ends in the renderer's 504 rather than the bot abandoning a render
// that was about to finish.
rendererTimeout = 45 * time.Second
wheelRemoteDurationMs = 6000
wheelRemoteHoldMs = 1000
wheelRemoteFPS = 20
wheelRemoteSize = 512
wheelRemoteTheme = "classic"
wheelRemoteDuration = (wheelRemoteDurationMs + wheelRemoteHoldMs) / 1000
)
var errRendererNotConfigured = errors.New("renderer not configured")
// rendererClient calls the animation renderer. BaseURL is the service root;
// every animation lives under its /api path.
type rendererClient struct {
HTTP *http.Client
BaseURL string
}
type wheelRenderRequest struct {
Options []string `json:"options"`
WinnerIndex int `json:"winnerIndex"`
DurationMs int `json:"durationMs"`
HoldMs int `json:"holdMs"`
FPS int `json:"fps"`
Size int `json:"size"`
Theme string `json:"theme"`
}
// renderedAnimation is a rendered clip plus the metadata Telegram's
// sendAnimation wants.
type renderedAnimation struct {
Data []byte
Duration int
Width int
Height int
}
func newRendererClientFromEnv() rendererClient {
return rendererClient{
BaseURL: strings.TrimSpace(os.Getenv(rendererURLEnv)),
}
}
// RenderWheel returns the wheel-of-names GIF landing on options[winner].
func (c rendererClient) RenderWheel(ctx context.Context, options []string, winner int) ([]byte, error) {
endpoint, err := c.endpoint("gif")
if err != nil {
return nil, err
}
if len(options) == 0 {
return nil, fmt.Errorf("renderer wheel options empty")
}
if winner < 0 || winner >= len(options) {
return nil, fmt.Errorf("renderer wheel winner index %d out of range %d", winner, len(options))
}
body, err := json.Marshal(wheelRenderRequest{
Options: options,
WinnerIndex: winner,
DurationMs: wheelRemoteDurationMs,
HoldMs: wheelRemoteHoldMs,
FPS: wheelRemoteFPS,
Size: wheelRemoteSize,
Theme: wheelRemoteTheme,
})
if err != nil {
return nil, fmt.Errorf("renderer wheel request encode failed: %w", err)
}
return c.post(ctx, endpoint, body, "image/gif", isWheelGIF)
}
// post sends one JSON render request to endpoint and returns the response
// body once it is a 2xx of the expected media type that passes isValid.
func (c rendererClient) post(ctx context.Context, endpoint *url.URL, body []byte, mediaType string, isValid func([]byte) bool) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("renderer request build failed: %w", err)
}
req.Header.Set("Accept", mediaType)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient().Do(req)
if err != nil {
return nil, errors.New("renderer request failed")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("renderer status %d", resp.StatusCode)
}
if err := requireContentType(resp.Header.Get("Content-Type"), mediaType); err != nil {
return nil, err
}
data, err := io.ReadAll(io.LimitReader(resp.Body, rendererMaxBytes+1))
if err != nil {
return nil, fmt.Errorf("renderer response read failed: %w", err)
}
if len(data) > rendererMaxBytes {
return nil, fmt.Errorf("renderer response too large")
}
if len(data) == 0 {
return nil, fmt.Errorf("renderer response empty")
}
if !isValid(data) {
return nil, fmt.Errorf("renderer response is not %s", mediaType)
}
return data, nil
}
func (c rendererClient) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: rendererTimeout}
}
// endpoint resolves the renderer route /api/<name> under BaseURL. A path
// prefix on BaseURL is kept, so a renderer mounted below a reverse-proxy path
// still works.
func (c rendererClient) endpoint(name string) (*url.URL, error) {
base, err := rendererBaseURL(c.BaseURL)
if err != nil {
return nil, err
}
return base.JoinPath("api", name), nil
}
// rendererBaseURL validates the configured base URL.
func rendererBaseURL(rawURL string) (*url.URL, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, errRendererNotConfigured
}
base, err := url.Parse(rawURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return nil, fmt.Errorf("renderer url invalid")
}
if base.Scheme != "http" && base.Scheme != "https" {
return nil, fmt.Errorf("renderer url scheme %q unsupported", base.Scheme)
}
return base, nil
}
func requireContentType(contentType, want string) error {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType != want {
return fmt.Errorf("renderer content type %q unsupported", contentType)
}
return nil
}
func isWheelGIF(data []byte) bool {
return bytes.HasPrefix(data, []byte("GIF87a")) || bytes.HasPrefix(data, []byte("GIF89a"))
}
func renderWheelOfNamesAnimation(ctx context.Context, options []string, winner int) (renderedAnimation, error) {
data, err := newRendererClientFromEnv().RenderWheel(ctx, options, winner)
if err != nil {
return renderedAnimation{}, err
}
return renderedAnimation{
Data: data,
Duration: wheelRemoteDuration,
Width: wheelRemoteSize,
Height: wheelRemoteSize,
}, nil
}