fix(install,e2e): установка на VPS владельца — четыре дефекта шаблонов и хелперов

Установка на VPS (Debian 13, 1 vCPU/2 ГБ) с внешним прокси на том же хосте
вскрыла дефекты, которые не проявлялись на стенде:

- compose: с --no-turn порты TURN всё равно публиковались, и стек не стартовал
  («failed to bind host port 0.0.0.0:3478/udp» — порт держит чужой coturn).
  Теперь строки портов TURN подставляет установщик (GLCHAT_TURN_PORTS).
- LiveKit: добавлен отдельный адрес публикации медиапортов
  (--livekit-media-bind-addr / GLCHAT_LIVEKIT_MEDIA_BIND): сигналинг остаётся
  внутренним для прокси, а 7881/tcp и 7882/udp доступны клиентам из интернета.
- external-proxy.conf: адреса апстримов берутся из фактической привязки
  (--app-bind-addr/--livekit-bind-addr), а не жёсткого 127.0.0.1.
- при --no-files-subdomain шаблоны внешнего прокси и встроенного Caddy писали
  два блока с одним адресом сайта (Caddy отвергает такой конфиг и в embedded
  режиме установка ломалась целиком): блок пользовательского контента теперь
  появляется только при отдельном домене (GLCHAT_FILES_BLOCK).
- e2e: postWithRetry/repeat429 читают retry_after_ms из конверта ошибки
  ({"error":{...}}) — без этого хелперы сдавались раньше, чем сервер разрешал
  повтор, и регистрация пробных аккаунтов на свежем инстансе падала.
- e2e: voice.spec.ts ищет комнату «Голос» точным именем: пока сайдбар
  дорисовывает сервер, видны комнаты предыдущего («Голос A»/«Голос B»).
This commit is contained in:
2026-09-27 17:40:58 +03:00
parent 1fd9734fff
commit a835903938
6 changed files with 110 additions and 39 deletions
+1 -7
View File
@@ -55,10 +55,4 @@ ${DOMAIN} {
} }
} }
${FILES_DOMAIN} { ${GLCHAT_FILES_BLOCK}
encode zstd gzip
import security_headers
handle {
reverse_proxy app:8080
}
}
+6 -12
View File
@@ -1,7 +1,8 @@
# Сгенерировано deploy/install.sh — не редактируйте вручную. # Сгенерировано deploy/install.sh — не редактируйте вручную.
# Режим: внешний reverse proxy (--external-proxy). Собственный Caddy не запускается: # Режим: внешний reverse proxy (--external-proxy). Собственный Caddy не запускается:
# блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его. # блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его.
# Приложение и LiveKit слушают только 127.0.0.1. # Приложение и LiveKit слушают ${APP_BIND}:${APP_PORT} и ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT}
# (по умолчанию 127.0.0.1 — в этом случае прокси и стек на одном хосте).
{ {
email ${ACME_EMAIL} email ${ACME_EMAIL}
} }
@@ -31,7 +32,7 @@ ${DOMAIN} {
# WebSocket Gateway (фаза 1): длинные соединения без таймаутов. # WebSocket Gateway (фаза 1): длинные соединения без таймаутов.
@gateway path /gateway @gateway path /gateway
handle @gateway { handle @gateway {
reverse_proxy 127.0.0.1:${APP_PORT} { reverse_proxy ${APP_BIND}:${APP_PORT} {
transport http { transport http {
read_timeout 1h read_timeout 1h
write_timeout 1h write_timeout 1h
@@ -42,7 +43,7 @@ ${DOMAIN} {
# LiveKit сигналинг (WebSocket). # LiveKit сигналинг (WebSocket).
@rtc path /rtc* @rtc path /rtc*
handle @rtc { handle @rtc {
reverse_proxy 127.0.0.1:${LIVEKIT_WS_PORT} { reverse_proxy ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT} {
transport http { transport http {
read_timeout 1h read_timeout 1h
write_timeout 1h write_timeout 1h
@@ -52,16 +53,9 @@ ${DOMAIN} {
# API и статика клиента. # API и статика клиента.
handle { handle {
reverse_proxy 127.0.0.1:${APP_PORT} reverse_proxy ${APP_BIND}:${APP_PORT}
} }
} }
# Пользовательский контент — отдельный origin (изоляция cookies/CSP). ${GLCHAT_FILES_BLOCK}
${FILES_DOMAIN} {
encode zstd gzip
import glchat_security_headers
handle {
reverse_proxy 127.0.0.1:${APP_PORT}
}
}
# --- glchat: конец блока -------------------------------------------------- # --- glchat: конец блока --------------------------------------------------
+13 -9
View File
@@ -76,15 +76,19 @@ services:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
ports: ports:
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_WS_PORT}:7880" - "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_WS_PORT}:7880"
# Медиапорты: тот же адрес привязки (для инстанса за NAT это адрес туннеля). # Медиапорты: по умолчанию тот же адрес привязки (инстанс за NAT
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_TCP_PORT}:7881" # публикует их на адресе туннеля). Когда прокси стоит на том же хосте,
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_UDP_PORT}:7882/udp" # что и стек, адреса расходятся: сигналинг отдаётся наружу через прокси и
# TURN: UDP и TURN/TLS. На хосте без белого IP эти порты доступны только # публикуется только для него, а медиапорты обязаны быть доступны
# из туннеля (внешний прокси/релей публикует их на своём адресе). # клиентам из интернета — GLCHAT_LIVEKIT_MEDIA_BIND (по умолчанию равен
- "${GLCHAT_LIVEKIT_BIND}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp" # адресу привязки, то есть поведение прежнее).
- "${GLCHAT_LIVEKIT_BIND}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp" - "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_TCP_PORT}:7881"
# Диапазон релея TURN (медиа через relay). - "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_UDP_PORT}:7882/udp"
- "${GLCHAT_LIVEKIT_BIND}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp" # TURN: UDP, TURN/TLS и диапазон релея. Строки подставляет установщик
# (GLCHAT_TURN_PORTS): при --no-turn встроенного TURN в стеке нет, и порты
# не публикуются вовсе — иначе занятый на хосте порт (например, чужим
# coturn) ломает запуск стека, а наружу открывались бы лишние порты.
${GLCHAT_TURN_PORTS}
volumes: volumes:
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro - ./livekit/livekit.yaml:/etc/livekit.yaml:ro
- ${GLCHAT_TURN_CERT_DIR}:/etc/certs:ro - ${GLCHAT_TURN_CERT_DIR}:/etc/certs:ro
+62 -5
View File
@@ -78,6 +78,11 @@ APP_BIND_ADDR="${APP_BIND_ADDR:-127.0.0.1}"
ALLOW_PORTS="${ALLOW_PORTS:-}" ALLOW_PORTS="${ALLOW_PORTS:-}"
ALLOW_FROM="${ALLOW_FROM:-10.77.0.0/24}" ALLOW_FROM="${ALLOW_FROM:-10.77.0.0/24}"
LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}" LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}"
# Адрес публикации медиапортов LiveKit (TCP/UDP): по умолчанию совпадает с
# адресом привязки сигналинга. Разделять нужно, когда прокси стоит на том же
# хосте: сигналинг публикуется только для прокси, а медиа обязано быть
# доступно клиентам из интернета (AGENT.md 10.4, 10.13).
LIVEKIT_MEDIA_BIND_ADDR="${LIVEKIT_MEDIA_BIND_ADDR:-}"
TURN_CERT_REMOTE="${TURN_CERT_REMOTE:-}" TURN_CERT_REMOTE="${TURN_CERT_REMOTE:-}"
TURN_CERT_REMOTE_CRT="${TURN_CERT_REMOTE_CRT:-}" TURN_CERT_REMOTE_CRT="${TURN_CERT_REMOTE_CRT:-}"
TURN_CERT_REMOTE_KEY="${TURN_CERT_REMOTE_KEY:-}" TURN_CERT_REMOTE_KEY="${TURN_CERT_REMOTE_KEY:-}"
@@ -189,7 +194,11 @@ install.sh — установка glchat (AGENT.md 10).
--allow-port <порт/прото> разрешить порт только из подсети --allow-from --allow-port <порт/прото> разрешить порт только из подсети --allow-from
(можно указывать несколько раз), например 8080/tcp (можно указывать несколько раз), например 8080/tcp
--allow-from <cidr> источник для --allow-port (по умолчанию 10.77.0.0/24) --allow-from <cidr> источник для --allow-port (по умолчанию 10.77.0.0/24)
--livekit-bind-addr <addr> адрес LiveKit (по умолчанию 127.0.0.1) --livekit-bind-addr <addr> адрес публикации LiveKit WS (по умолчанию 127.0.0.1)
--livekit-media-bind-addr <addr> адрес публикации медиапортов LiveKit TCP/UDP
(по умолчанию — как у WS; отдельный адрес нужен,
когда прокси стоит на том же хосте: WS публикуется
только для прокси, медиа — для клиентов из интернета)
--turn-cert-remote <user@host> тянуть сертификат TURN/TLS с внешнего узла по SSH --turn-cert-remote <user@host> тянуть сертификат TURN/TLS с внешнего узла по SSH
--turn-cert-remote-crt <путь> путь к сертификату на внешнем узле --turn-cert-remote-crt <путь> путь к сертификату на внешнем узле
--turn-cert-remote-key <путь> путь к ключу на внешнем узле --turn-cert-remote-key <путь> путь к ключу на внешнем узле
@@ -254,6 +263,7 @@ parse_args() {
--allow-port) ALLOW_PORTS="${ALLOW_PORTS} ${2:?}"; shift 2 ;; --allow-port) ALLOW_PORTS="${ALLOW_PORTS} ${2:?}"; shift 2 ;;
--allow-from) ALLOW_FROM="${2:?}"; shift 2 ;; --allow-from) ALLOW_FROM="${2:?}"; shift 2 ;;
--livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;; --livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;;
--livekit-media-bind-addr) LIVEKIT_MEDIA_BIND_ADDR="${2:?}"; shift 2 ;;
--turn-cert-remote) TURN_CERT_REMOTE="${2:?}"; shift 2 ;; --turn-cert-remote) TURN_CERT_REMOTE="${2:?}"; shift 2 ;;
--turn-cert-remote-crt) TURN_CERT_REMOTE_CRT="${2:?}"; shift 2 ;; --turn-cert-remote-crt) TURN_CERT_REMOTE_CRT="${2:?}"; shift 2 ;;
--turn-cert-remote-key) TURN_CERT_REMOTE_KEY="${2:?}"; shift 2 ;; --turn-cert-remote-key) TURN_CERT_REMOTE_KEY="${2:?}"; shift 2 ;;
@@ -430,11 +440,49 @@ resolve_derived_values() {
fi fi
GLCHAT_APP_BIND="${APP_BIND_ADDR}" GLCHAT_APP_BIND="${APP_BIND_ADDR}"
GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}" GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}"
# Пустое значение — прежнее поведение: медиа публикуется там же, где сигналинг.
[ -n "$LIVEKIT_MEDIA_BIND_ADDR" ] || LIVEKIT_MEDIA_BIND_ADDR="$LIVEKIT_BIND_ADDR"
GLCHAT_LIVEKIT_MEDIA_BIND="${LIVEKIT_MEDIA_BIND_ADDR}"
# Порты TURN публикуются только при включённом TURN. С --no-turn встроенного
# TURN в стеке нет: публикация занимала бы порты хоста (3478/udp и диапазон
# релея) и ломала запуск, если их держит чужой сервис — например, отдельный
# coturn на том же VPS (найдено при установке на VPS 27.09.2026), а наружу
# открывались бы порты, за которыми ничего не слушает.
if [ "$TURN_ENABLED" = "true" ]; then
GLCHAT_TURN_PORTS=" # TURN: UDP, TURN/TLS и диапазон релея (медиа через relay).
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp\"
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp\"
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp\""
else
GLCHAT_TURN_PORTS=" # TURN выключен (--no-turn): порты 3478/5349 и диапазон релея не публикуются"
fi
# Блок пользовательского контента (files.<домен>) в конфиг прокси попадает
# только когда для него есть отдельный домен. Иначе в Caddyfile оказывались
# два блока с одним и тем же адресом, и Caddy отказывался стартовать
# («ambiguous site definition») — найдено при установке с --no-files-subdomain
# на VPS 27.09.2026. Адрес апстрима зависит от режима: во встроенном прокси
# это имя контейнера, во внешнем — адрес привязки приложения.
if [ "$PROXY_MODE" = "embedded" ]; then
files_upstream="app:8080"
else
files_upstream="${APP_BIND_ADDR}:${APP_PORT}"
fi
if [ "$FILES_DOMAIN_RESOLVED" != "$DOMAIN" ]; then
GLCHAT_FILES_BLOCK="$(printf '# Пользовательский контент — отдельный origin (изоляция cookies/CSP).\n%s {\n\tencode zstd gzip\n\timport glchat_security_headers\n\thandle {\n\t\treverse_proxy %s\n\t}\n}' \
"${FILES_DOMAIN_RESOLVED}" "${files_upstream}")"
if [ "$PROXY_MODE" = "embedded" ]; then
# Во встроенном Caddy сниппет заголовков называется иначе.
GLCHAT_FILES_BLOCK="${GLCHAT_FILES_BLOCK/import glchat_security_headers/import security_headers}"
fi
else
GLCHAT_FILES_BLOCK="# Отдельного домена для пользовательского контента нет
# (--no-files-subdomain): файлы отдаются с основного домена по /files/*."
fi
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \ export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
livekit_extra_codecs \ livekit_extra_codecs GLCHAT_TURN_PORTS GLCHAT_FILES_BLOCK \
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \ PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \ GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \ GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND GLCHAT_LIVEKIT_MEDIA_BIND TLS_ENABLED \
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \ TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
TURN_RELAY_USER TURN_RELAY_PASSWORD TURN_RELAY_USER TURN_RELAY_PASSWORD
} }
@@ -521,6 +569,7 @@ print_plan() {
if [ "$PROXY_MODE" = "external" ]; then if [ "$PROXY_MODE" = "external" ]; then
printf ' %-18s %-10s %s\n' "app (API+web)" "${APP_BIND_ADDR}:${APP_PORT}" "внутренний, наружу не публикуется" printf ' %-18s %-10s %s\n' "app (API+web)" "${APP_BIND_ADDR}:${APP_PORT}" "внутренний, наружу не публикуется"
printf ' %-18s %-10s %s\n' "LiveKit WS" "${LIVEKIT_BIND_ADDR}:${LIVEKIT_WS_PORT}" "внутренний" printf ' %-18s %-10s %s\n' "LiveKit WS" "${LIVEKIT_BIND_ADDR}:${LIVEKIT_WS_PORT}" "внутренний"
printf ' %-18s %-10s %s\n' "LiveKit медиа" "${LIVEKIT_MEDIA_BIND_ADDR}:${LIVEKIT_TCP_PORT}/${LIVEKIT_UDP_PORT}" "TCP+UDP для клиентов"
printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)" printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)"
else else
printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS" printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS"
@@ -851,6 +900,9 @@ GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@ GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@ GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@ GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@
# Адрес публикации медиапортов LiveKit: обычно совпадает с адресом WS, но при
# прокси на том же хосте отличается (WS — только для прокси, медиа — в интернет).
GLCHAT_LIVEKIT_MEDIA_BIND=@GLCHAT_LIVEKIT_MEDIA_BIND@
# --- порты --- # --- порты ---
APP_PORT=@APP_PORT@ APP_PORT=@APP_PORT@
@@ -974,6 +1026,7 @@ CADDY_PROFILE=${CADDY_PROFILE}
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR} GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
GLCHAT_APP_BIND=${APP_BIND_ADDR} GLCHAT_APP_BIND=${APP_BIND_ADDR}
GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR} GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}
GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}
APP_PORT=${APP_PORT} APP_PORT=${APP_PORT}
LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT} LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}
LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT} LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}
@@ -1258,6 +1311,8 @@ render_all_templates() {
"GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \ "GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \
"GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}" \ "GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}" \
"GLCHAT_APP_BIND=${APP_BIND_ADDR}" "GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \ "GLCHAT_APP_BIND=${APP_BIND_ADDR}" "GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
"GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}" \
"GLCHAT_TURN_PORTS=${GLCHAT_TURN_PORTS}" \
"TURN_UDP_PORT=${TURN_UDP_PORT}" "TURN_TLS_PORT=${TURN_TLS_PORT}" \ "TURN_UDP_PORT=${TURN_UDP_PORT}" "TURN_TLS_PORT=${TURN_TLS_PORT}" \
"TURN_RELAY_START=${TURN_RELAY_START}" "TURN_RELAY_END=${TURN_RELAY_END}" \ "TURN_RELAY_START=${TURN_RELAY_START}" "TURN_RELAY_END=${TURN_RELAY_END}" \
"LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \ "LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \
@@ -1266,11 +1321,13 @@ render_all_templates() {
if [ "$PROXY_MODE" = "external" ]; then if [ "$PROXY_MODE" = "external" ]; then
render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \ render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \ "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" "APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
"APP_BIND=${APP_BIND_ADDR}" "LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
"GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
else else
render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \ render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \ "DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
"PROXY_MODE=${PROXY_MODE}" "PROXY_MODE=${PROXY_MODE}" "GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
fi fi
render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \ render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \
+22 -5
View File
@@ -63,12 +63,22 @@ export async function postWithRetry(
api: APIRequestContext, api: APIRequestContext,
path: string, path: string,
data: Record<string, unknown>, data: Record<string, unknown>,
attempts = 6, attempts = 8,
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> { ): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
let response = await api.post(path, { data }); let response = await api.post(path, { data });
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; // Пауза лежит в конверте ошибки (§8.5: {"error":{"retry_after_ms":…}}), а не
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 20_000); // в корне тела: без `error` хелпер ждал фиксированную секунду и сдавался
// раньше, чем сервер разрешал повтор — на свежем инстансе регистрация
// пробных аккаунтов из-за этого падала (VPS, 27.09.2026).
const payload = (await response.json().catch(() => ({}))) as {
retry_after_ms?: number;
error?: { retry_after_ms?: number };
};
const wait = Math.min(
Math.max(payload.error?.retry_after_ms ?? payload.retry_after_ms ?? 1_000, 500),
20_000,
);
await new Promise((resolve) => setTimeout(resolve, wait + 200)); await new Promise((resolve) => setTimeout(resolve, wait + 200));
response = await api.post(path, { data }); response = await api.post(path, { data });
} }
@@ -359,9 +369,16 @@ export async function repeat429(
): Promise<APIResponse> { ): Promise<APIResponse> {
let response = await request(); let response = await request();
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) { for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number }; const payload = (await response.json().catch(() => ({}))) as {
retry_after_ms?: number;
error?: { retry_after_ms?: number };
};
// Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту. // Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту.
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 1_000), 30_000); // Пауза лежит в конверте ошибки (§8.5), поэтому читаем и `error`.
const wait = Math.min(
Math.max(payload.error?.retry_after_ms ?? payload.retry_after_ms ?? 1_000, 1_000),
30_000,
);
await new Promise((resolve) => setTimeout(resolve, wait + 250)); await new Promise((resolve) => setTimeout(resolve, wait + 250));
response = await request(); response = await request();
} }
+6 -1
View File
@@ -177,7 +177,12 @@ test.describe('голос: два клиента в одной комнате',
await session.page await session.page
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true }) .getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
.click(); .click();
await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).click(); // Имя комнаты — точно: пока сайдбар дорисовывает новый сервер, в нём
// могут быть видны комнаты предыдущего (например, «Голос A»/«Голос B»
// служебных проверок), и нечёткий локатор ловил бы две ссылки.
await session.page
.getByRole('link', { name: 'Открыть комнату Голос', exact: true })
.click();
await joinVoice(session.page); await joinVoice(session.page);
} }