Compare commits
27 Commits
c8601c0115
..
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 829e29a726 | |||
| 44117e906c | |||
| c90331b189 | |||
| 399508f2f0 | |||
| 0dfecc2af7 | |||
| 446ea2ac45 | |||
| 01a9249002 | |||
| 7dcd62fdd7 | |||
| d67e582c03 | |||
| 75fe07865a | |||
| 597e200f37 | |||
| 3ef18d33b6 | |||
| a0021d1994 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -335,6 +335,9 @@ jobs:
|
|||||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||||
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||||
|
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend + compose interpolation).
|
# Signs the finished-game export download URLs (backend + compose interpolation).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.TEST_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay: one account for every
|
# Transactional email via the shared Selectel relay: one account for every
|
||||||
@@ -396,6 +399,13 @@ jobs:
|
|||||||
mkdir -p "$conf"
|
mkdir -p "$conf"
|
||||||
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
cp -r caddy otelcol prometheus tempo grafana blackbox "$conf"/
|
||||||
export SCRABBLE_CONFIG_DIR="$conf"
|
export SCRABBLE_CONFIG_DIR="$conf"
|
||||||
|
# Maintenance page for the redeploy window, mirroring prod-deploy.sh so the SPA
|
||||||
|
# overlay is exercised on the test contour too (not only prod). Raised just before
|
||||||
|
# the recreate and lowered once caddy is back (below); the trap clears it if the
|
||||||
|
# step fails so the contour never sticks in maintenance (and the reseed above wipes a
|
||||||
|
# stale flag anyway). The caddy-routed probes run in the NEXT step, after it is lowered.
|
||||||
|
maint_flag="$conf/caddy/on"
|
||||||
|
trap 'rm -f "$maint_flag"' EXIT
|
||||||
# Derive the public URLs from the one canonical origin instead of storing each as
|
# Derive the public URLs from the one canonical origin instead of storing each as
|
||||||
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
|
# its own variable (paths are structural SPA routes / the Caddy /_gm sub-path).
|
||||||
# Exported before build so the VK ID redirect is baked into the SPA.
|
# Exported before build so the VK ID redirect is baked into the SPA.
|
||||||
@@ -427,12 +437,23 @@ jobs:
|
|||||||
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
# bot on its own host instead (deploy/docker-compose.bot.yml), and the prod
|
||||||
# main host omits both. Without the profile they would not start here.
|
# main host omits both. Without the profile they would not start here.
|
||||||
docker compose --ansi never --profile telegram-local build --progress plain
|
docker compose --ansi never --profile telegram-local build --progress plain
|
||||||
|
# Raise the maintenance page, THEN bring caddy onto the reseeded config mount so it
|
||||||
|
# actually carries the flag: the running caddy sits on the stale pre-reseed mount (the
|
||||||
|
# dir was rm'd + recreated — see the force-recreate note below), so a flag written to
|
||||||
|
# the new dir is invisible until caddy is recreated. With the fresh caddy up, an open
|
||||||
|
# SPA sees the 503 marker + overlay for the whole recreate window, not a bare reconnect.
|
||||||
|
: > "$maint_flag"
|
||||||
|
docker compose --ansi never up -d --force-recreate --no-deps caddy
|
||||||
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
docker compose --ansi never --profile telegram-local up -d --remove-orphans
|
||||||
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
# The config-only services bind-mount the reseeded config dir. A plain `up -d`
|
||||||
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
# leaves them on the previous bind mount (the dir was rm'd + recreated), so a
|
||||||
# changed Caddyfile or Grafana dashboard is ignored — force-recreate them to
|
# changed Grafana dashboard is ignored — force-recreate them to pick up the fresh
|
||||||
# pick up the fresh config.
|
# config. (Caddy was already recreated above so it would carry the maintenance flag.)
|
||||||
docker compose --ansi never up -d --force-recreate --no-deps caddy otelcol prometheus tempo grafana
|
docker compose --ansi never up -d --force-recreate --no-deps otelcol prometheus tempo grafana
|
||||||
|
# Lower the maintenance page: services are back. An open SPA's poll now gets through
|
||||||
|
# (once the gateway finishes booting) and reloads into the fresh client; the caddy
|
||||||
|
# probes in the next step see 200. The EXIT trap is a backstop if we failed earlier.
|
||||||
|
rm -f "$maint_flag"
|
||||||
|
|
||||||
- name: Probe the landing, gateway and backend
|
- name: Probe the landing, gateway and backend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ jobs:
|
|||||||
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
|
# VITE_GATEWAY_URL omitted: the SPA is served same-origin (compose ":-" default).
|
||||||
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
|
# `docker compose build` interpolates the WHOLE compose file, so every :?-guarded
|
||||||
|
# runtime var must be present at build even though it is not a build-arg — incl. the
|
||||||
|
# backend's EXPORT_SIGN_KEY (added with the finished-game export after v1.7.0, which is
|
||||||
|
# why the first v1.8.0 build tripped on it). POSTGRES_PASSWORD/GM_BASICAUTH_HASH above
|
||||||
|
# are here for the same reason; DICT_VERSION + the derived Mini App URL cover the rest.
|
||||||
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
|
# PUBLIC_BASE_URL drives the derived VK ID redirect (baked into the SPA) and the
|
||||||
# Mini App URL; both are computed in the build step, not stored variables.
|
# Mini App URL; both are computed in the build step, not stored variables.
|
||||||
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
@@ -98,6 +104,9 @@ jobs:
|
|||||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||||
|
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ jobs:
|
|||||||
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||||
|
|||||||
@@ -108,3 +108,10 @@ GATEWAY_VK_APP_SECRET=
|
|||||||
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
# the confidential OAuth 2.1 code exchange under this protected key. The app id + redirect URL
|
||||||
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET=
|
GATEWAY_VK_ID_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||||
|
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
|
||||||
|
# somewhere an attacker would find it; empty disables the trap. Gitea
|
||||||
|
# TEST_/PROD_GATEWAY_HONEYTOKEN (secret).
|
||||||
|
GATEWAY_HONEYTOKEN=
|
||||||
|
|||||||
+4
-2
@@ -111,6 +111,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||||
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
||||||
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
||||||
|
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
@@ -246,8 +247,9 @@ VITE_VK_APP_LINK, VITE_VK_APP_ID`. **Derived from `PUBLIC_BASE_URL` at deploy**
|
|||||||
**`PROD_` Gitea set** (per-contour, mirrors `TEST_`, mapped onto the unprefixed names above)
|
**`PROD_` Gitea set** (per-contour, mirrors `TEST_`, mapped onto the unprefixed names above)
|
||||||
— secrets:
|
— secrets:
|
||||||
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
`PROD_{POSTGRES_PASSWORD, GM_BASICAUTH_HASH, GRAFANA_ADMIN_PASSWORD, TELEGRAM_BOT_TOKEN,
|
||||||
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, REGISTRY_PASSWORD, SSH_KEY, SSH_KNOWN_HOSTS, BOTLINK_CA,
|
TELEGRAM_PROMO_BOT_TOKEN, EXPORT_SIGN_KEY, GATEWAY_HONEYTOKEN, REGISTRY_PASSWORD, SSH_KEY,
|
||||||
BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`; variables:
|
SSH_KNOWN_HOSTS, BOTLINK_CA, BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT,
|
||||||
|
BOTLINK_BOT_KEY}`; variables:
|
||||||
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
|
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, LOG_LEVEL,
|
||||||
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
|
TELEGRAM_GAME_CHANNEL_ID, TELEGRAM_CHAT_ID, TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_BOT_USERNAME,
|
||||||
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
|
VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, VITE_TELEGRAM_GAME_CHANNEL_NAME, SMTP_RELAY_FROM,
|
||||||
|
|||||||
@@ -231,8 +231,8 @@ services:
|
|||||||
# in prod — the test contour arrives as one shared NAT address, so a ban there
|
# in prod — the test contour arrives as one shared NAT address, so a ban there
|
||||||
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
|
# would be self-inflicted (the honeypot still logs). The prod deploy forces it on
|
||||||
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
|
# in env.sh (deploy/write-prod-env.sh), not via a Gitea variable. GATEWAY_HONEYTOKEN
|
||||||
# is the planted bearer trap; it is currently unset on every contour (no Gitea entry
|
# is the planted bearer trap, fed from the per-contour TEST_/PROD_GATEWAY_HONEYTOKEN
|
||||||
# feeds it), so the trap is inert until an operator wires a secret.
|
# secret; empty (unset secret) leaves the trap off.
|
||||||
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
|||||||
@@ -71,5 +71,6 @@ export SERVICE_EMAIL='$SERVICE_EMAIL'
|
|||||||
export GRAFANA_SMTP_PORT='$GRAFANA_SMTP_PORT'
|
export GRAFANA_SMTP_PORT='$GRAFANA_SMTP_PORT'
|
||||||
export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
|
export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
|
||||||
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
||||||
|
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The maintenance overlay is raised in prod by the edge 503 marker (X-Scrabble-Maintenance);
|
||||||
|
// the mock transport never produces one, so — like the offline indicator — the e2e drives it
|
||||||
|
// through the window.__maint hook (gateway.ts, mock-only). It is app-global (mounted outside
|
||||||
|
// the route blocks in App.svelte), so it shows without a session.
|
||||||
|
test('maintenance overlay covers the app and lifts on recovery', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
|
||||||
|
// A planned deploy window begins: a non-dismissable dimmed overlay covers the app.
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
|
||||||
|
const overlay = page.getByRole('alertdialog');
|
||||||
|
await expect(overlay).toBeVisible();
|
||||||
|
// The retry button is present (EN "Try again" / RU "Повторить" depending on locale).
|
||||||
|
await expect(overlay.getByRole('button', { name: /again|Повторить/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The window ends — in prod the store's poll lifts it on the first successful read; the mock
|
||||||
|
// has no probe, so it clears explicitly. The overlay disappears with no page reload.
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { off(): void } }).__maint.off());
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovery reloads the SPA to pick up the fresh client', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => (window as unknown as { __maint: { on(): void } }).__maint.on());
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible();
|
||||||
|
|
||||||
|
// Real recovery reloads the page (the deploy may have shipped an incompatible client, and the
|
||||||
|
// running bundle is the old one). Wait for the forced navigation, then confirm the app
|
||||||
|
// re-bootstrapped: the overlay is gone (the store reset by the reload) and the login is back.
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForEvent('load'),
|
||||||
|
page.evaluate(() => (window as unknown as { __maint: { recover(): void } }).__maint.recover()),
|
||||||
|
]);
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import Coachmark from './components/Coachmark.svelte';
|
import Coachmark from './components/Coachmark.svelte';
|
||||||
|
import MaintenanceOverlay from './components/MaintenanceOverlay.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
import DebugPanel from './components/DebugPanel.svelte';
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
@@ -128,6 +129,7 @@
|
|||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
<WelcomeRedeemModal />
|
<WelcomeRedeemModal />
|
||||||
<Coachmark />
|
<Coachmark />
|
||||||
|
<MaintenanceOverlay />
|
||||||
|
|
||||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||||
<Splash />
|
<Splash />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Non-dismissable maintenance cover. Shown while the edge is in a planned deploy window
|
||||||
|
// (maintenance.svelte.ts, raised from a caddy 503 carrying X-Scrabble-Maintenance). It has
|
||||||
|
// no close affordance — an in-session user cannot dismiss it — but the store's poll lifts
|
||||||
|
// it automatically the moment the gateway answers again; the "retry" button just forces an
|
||||||
|
// immediate re-check. Mirrors the static caddy maintenance page (deploy/caddy/maintenance.html)
|
||||||
|
// so a fresh load and an in-session user see the same thing.
|
||||||
|
import { maintenance, retryNow } from '../lib/maintenance.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if maintenance.active}
|
||||||
|
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="maint-title" aria-describedby="maint-body">
|
||||||
|
<div class="card">
|
||||||
|
<div class="tile" aria-hidden="true">Э</div>
|
||||||
|
<h1 id="maint-title">{t('maintenance.title')}</h1>
|
||||||
|
<p id="maint-body">{t('maintenance.body')}</p>
|
||||||
|
<button type="button" onclick={retryNow}>{t('maintenance.retry')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above the game (z 60) and toasts (z 50) — a planned window covers everything the user
|
||||||
|
could otherwise interact with; below the dev DebugPanel (z 10000). */
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 22rem;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
/* Mirrors a placed board tile (as Splash.svelte / the caddy page do). */
|
||||||
|
.tile {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--tile-bg);
|
||||||
|
color: var(--tile-text);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
|
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.55rem 1.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ import type { GatewayClient } from './client';
|
|||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||||
|
|
||||||
const isMock = import.meta.env.MODE === 'mock';
|
const isMock = import.meta.env.MODE === 'mock';
|
||||||
|
|
||||||
@@ -20,6 +21,15 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
offline: reportOffline,
|
offline: reportOffline,
|
||||||
online: reportOnline,
|
online: reportOnline,
|
||||||
};
|
};
|
||||||
|
// The mock never produces a real 503, so the e2e drives the maintenance overlay directly
|
||||||
|
// (the store's poll is inert in mock — no probe is registered — so `off` clears it).
|
||||||
|
(
|
||||||
|
window as unknown as { __maint?: { on(retryAfterMs?: number): void; off(): void; recover(): void } }
|
||||||
|
).__maint = {
|
||||||
|
on: (retryAfterMs = 15000) => reportMaintenance(retryAfterMs),
|
||||||
|
off: clearMaintenance,
|
||||||
|
recover: maintenanceRecovered,
|
||||||
|
};
|
||||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||||
// attaches a robot on a timer).
|
// attaches a robot on a timer).
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const en = {
|
|||||||
'app.brand': 'Erudit',
|
'app.brand': 'Erudit',
|
||||||
'dict.loading': 'Loading…',
|
'dict.loading': 'Loading…',
|
||||||
'connection.connecting': 'Connecting…',
|
'connection.connecting': 'Connecting…',
|
||||||
|
'maintenance.title': 'Under maintenance',
|
||||||
|
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
|
||||||
|
'maintenance.retry': 'Try again',
|
||||||
|
|
||||||
'blocked.title': 'Account blocked',
|
'blocked.title': 'Account blocked',
|
||||||
'blocked.permanent': 'Your account is blocked.',
|
'blocked.permanent': 'Your account is blocked.',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'app.brand': 'Эрудит',
|
'app.brand': 'Эрудит',
|
||||||
'dict.loading': 'Загрузка…',
|
'dict.loading': 'Загрузка…',
|
||||||
'connection.connecting': 'Подключение…',
|
'connection.connecting': 'Подключение…',
|
||||||
|
'maintenance.title': 'Технические работы',
|
||||||
|
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||||
|
'maintenance.retry': 'Повторить',
|
||||||
|
|
||||||
'blocked.title': 'Учётная запись заблокирована',
|
'blocked.title': 'Учётная запись заблокирована',
|
||||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Global maintenance signal + self-clearing poll. `active` is true while the edge is in a
|
||||||
|
// planned deploy window (a caddy 503 carried `X-Scrabble-Maintenance`; see maintenance.ts).
|
||||||
|
// A non-dismissable overlay (MaintenanceOverlay.svelte) covers the app while active, and a
|
||||||
|
// cheap read is retried on a capped-backoff cadence (mirroring connection.svelte.ts) — the
|
||||||
|
// first success lifts the overlay, so it can never get stuck even with no other traffic.
|
||||||
|
// Mock mode never reports maintenance, so it simply stays inactive.
|
||||||
|
|
||||||
|
import { backoffMs } from './retry';
|
||||||
|
|
||||||
|
let active = $state(false);
|
||||||
|
let retryHintMs = $state(0);
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let attempt = 0;
|
||||||
|
let probing = false;
|
||||||
|
let probe: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
export const maintenance = {
|
||||||
|
/** active is true while the edge is in a planned maintenance window. */
|
||||||
|
get active(): boolean {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
/** retryHintMs is the last Retry-After hint in milliseconds (0 when unknown). */
|
||||||
|
get retryHintMs(): number {
|
||||||
|
return retryHintMs;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** registerMaintenanceProbe installs the reachability read the poll fires while active
|
||||||
|
* (the transport wires a cheap authenticated read). */
|
||||||
|
export function registerMaintenanceProbe(fn: () => Promise<void>): void {
|
||||||
|
probe = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reportMaintenance raises the overlay and starts the self-clearing poll. Idempotent: a
|
||||||
|
* repeat hit only refreshes the hint, it never restarts a running poll. */
|
||||||
|
export function reportMaintenance(retryAfterMs: number): void {
|
||||||
|
retryHintMs = retryAfterMs;
|
||||||
|
if (active) return;
|
||||||
|
active = true;
|
||||||
|
attempt = 0;
|
||||||
|
if (probe) schedulePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clearMaintenance lowers the overlay and stops the poll without reloading (a reset, or the
|
||||||
|
* e2e hook). Prod recovery goes through maintenanceRecovered instead. */
|
||||||
|
export function clearMaintenance(): void {
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** maintenanceRecovered runs when the edge answers again after a window we were showing.
|
||||||
|
* The deploy that ended the window may have shipped a client-incompatible change (a wire /
|
||||||
|
* schema bump), and the running bundle is the OLD one — so reload to pick up the fresh
|
||||||
|
* client instead of merely hiding the overlay. A no-op unless the overlay was up; the cover
|
||||||
|
* stays over the brief reload flash. In prod recovery is the only way the overlay clears. */
|
||||||
|
export function maintenanceRecovered(): void {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
if (typeof location !== 'undefined') location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retryNow forces an immediate re-check (the overlay's button). It never closes the
|
||||||
|
* overlay itself — only a successful probe does. A no-op while a probe is in flight. */
|
||||||
|
export function retryNow(): void {
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
if (pollTimer) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
attempt = 0;
|
||||||
|
runProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll(): void {
|
||||||
|
pollTimer = setTimeout(runProbe, backoffMs(++attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runProbe(): void {
|
||||||
|
pollTimer = null;
|
||||||
|
if (!active || !probe || probing) return;
|
||||||
|
probing = true;
|
||||||
|
probe().then(
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
maintenanceRecovered();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
probing = false;
|
||||||
|
if (active) schedulePoll();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { Code, ConnectError } from '@connectrpc/connect';
|
||||||
|
import { maintenanceRetryMs, parseRetryAfterMs } from './maintenance';
|
||||||
|
|
||||||
|
// A raw ConnectError as connect-web builds it from a non-Connect HTTP 503: the response
|
||||||
|
// headers are preserved as `.metadata` (verified against @connectrpc/connect-web).
|
||||||
|
const err503 = (headers: Record<string, string>): ConnectError =>
|
||||||
|
new ConnectError('HTTP 503', Code.Unavailable, new Headers(headers));
|
||||||
|
|
||||||
|
describe('maintenanceRetryMs', () => {
|
||||||
|
it('detects the marker and parses Retry-After', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'x-scrabble-maintenance': '1', 'retry-after': '120' }))).toBe(120_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the header case-insensitively, defaulting the delay when Retry-After is absent', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'X-Scrabble-Maintenance': '1' }))).toBe(15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a 503 without the marker (a plain transient failure)', () => {
|
||||||
|
expect(maintenanceRetryMs(err503({ 'retry-after': '30' }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-Connect errors', () => {
|
||||||
|
expect(maintenanceRetryMs(new Error('boom'))).toBeNull();
|
||||||
|
expect(maintenanceRetryMs(undefined)).toBeNull();
|
||||||
|
expect(maintenanceRetryMs('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseRetryAfterMs', () => {
|
||||||
|
it('clamps into the 3s–120s band', () => {
|
||||||
|
expect(parseRetryAfterMs('120')).toBe(120_000);
|
||||||
|
expect(parseRetryAfterMs('1')).toBe(3_000); // floor
|
||||||
|
expect(parseRetryAfterMs('9999')).toBe(120_000); // ceiling
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default on absent / garbage / non-positive', () => {
|
||||||
|
expect(parseRetryAfterMs(null)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs(undefined)).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('soon')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('0')).toBe(15_000);
|
||||||
|
expect(parseRetryAfterMs('-5')).toBe(15_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Detection of a planned edge maintenance window. During a prod deploy roll the edge
|
||||||
|
// caddy returns HTTP 503 with an `X-Scrabble-Maintenance: 1` header (and a `Retry-After`)
|
||||||
|
// for every gated route. The SPA keys STRICTLY on that marker — not on any transport
|
||||||
|
// `unavailable`, which the "Connecting…" indicator already covers — so a planned window
|
||||||
|
// raises the maintenance overlay while an ordinary blip stays a soft reconnect.
|
||||||
|
//
|
||||||
|
// These helpers are pure (no DOM, no runes) so they unit-test in the node vitest env. The
|
||||||
|
// maintenance marker + Retry-After ride on the raw ConnectError's `metadata`, which
|
||||||
|
// toGatewayError (retry.ts) discards — so detection must run on the raw error.
|
||||||
|
|
||||||
|
import { ConnectError } from '@connectrpc/connect';
|
||||||
|
|
||||||
|
const DEFAULT_RETRY_MS = 15_000;
|
||||||
|
const MIN_RETRY_MS = 3_000;
|
||||||
|
const MAX_RETRY_MS = 120_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parseRetryAfterMs converts a `Retry-After` header (delta-seconds — the edge emits
|
||||||
|
* seconds, never an HTTP-date) into a millisecond hint clamped to a sane band. An absent
|
||||||
|
* or unparseable value falls back to a default.
|
||||||
|
*/
|
||||||
|
export function parseRetryAfterMs(header: string | null | undefined): number {
|
||||||
|
const secs = header == null ? NaN : Number(header);
|
||||||
|
if (!Number.isFinite(secs) || secs <= 0) return DEFAULT_RETRY_MS;
|
||||||
|
return Math.min(MAX_RETRY_MS, Math.max(MIN_RETRY_MS, Math.round(secs * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* maintenanceRetryMs returns the maintenance `Retry-After` hint in milliseconds when the
|
||||||
|
* thrown transport error is the edge maintenance 503 (carries `X-Scrabble-Maintenance: 1`),
|
||||||
|
* or null for any other error. Header lookup is case-insensitive (Headers.get).
|
||||||
|
*/
|
||||||
|
export function maintenanceRetryMs(e: unknown): number | null {
|
||||||
|
if (!(e instanceof ConnectError)) return null;
|
||||||
|
if (e.metadata?.get('x-scrabble-maintenance') !== '1') return null;
|
||||||
|
return parseRetryAfterMs(e.metadata.get('retry-after'));
|
||||||
|
}
|
||||||
+24
-3
@@ -12,6 +12,8 @@ import { GatewayError, type GatewayClient } from './client';
|
|||||||
import * as codec from './codec';
|
import * as codec from './codec';
|
||||||
import { browserOffset } from './profileValidation';
|
import { browserOffset } from './profileValidation';
|
||||||
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
|
||||||
|
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||||
|
import { maintenanceRetryMs } from './maintenance';
|
||||||
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
|
||||||
|
|
||||||
const MAX_RETRIES = 6;
|
const MAX_RETRIES = 6;
|
||||||
@@ -28,10 +30,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
|
|
||||||
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
// The reachability probe the connection watcher fires while offline: a cheap authenticated read
|
||||||
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
// (it must reject when there is no session, so the watcher keeps waiting rather than reporting up).
|
||||||
registerProbe(async () => {
|
const reachabilityProbe = async (): Promise<void> => {
|
||||||
if (!token) throw new Error('no session');
|
if (!token) throw new Error('no session');
|
||||||
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() });
|
||||||
});
|
};
|
||||||
|
registerProbe(reachabilityProbe);
|
||||||
|
// The maintenance overlay reuses the same cheap read to poll for the end of a deploy
|
||||||
|
// window; the first success lifts the overlay (maintenance.svelte.ts).
|
||||||
|
registerMaintenanceProbe(reachabilityProbe);
|
||||||
|
|
||||||
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
|
||||||
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
|
||||||
@@ -43,6 +49,14 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
|
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
|
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
|
||||||
|
const maintMs = maintenanceRetryMs(e);
|
||||||
|
if (maintMs !== null) {
|
||||||
|
// A planned deploy window (the edge 503 carried X-Scrabble-Maintenance): raise the
|
||||||
|
// overlay and fail fast — its own poll drives recovery — instead of burning the
|
||||||
|
// retry budget on every read for the length of the window.
|
||||||
|
reportMaintenance(maintMs);
|
||||||
|
throw toGatewayError(e);
|
||||||
|
}
|
||||||
const err = toGatewayError(e);
|
const err = toGatewayError(e);
|
||||||
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
|
||||||
reportOffline();
|
reportOffline();
|
||||||
@@ -53,6 +67,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
reportOnline();
|
reportOnline();
|
||||||
|
// A read got through: if the maintenance overlay was up, the deploy window has ended —
|
||||||
|
// reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
|
||||||
|
maintenanceRecovered();
|
||||||
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode);
|
||||||
return res.payload;
|
return res.payload;
|
||||||
}
|
}
|
||||||
@@ -310,7 +327,11 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
if (pe) onEvent(pe);
|
if (pe) onEvent(pe);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!ctrl.signal.aborted) onError?.(toGatewayError(e));
|
if (!ctrl.signal.aborted) {
|
||||||
|
const maintMs = maintenanceRetryMs(e);
|
||||||
|
if (maintMs !== null) reportMaintenance(maintMs);
|
||||||
|
onError?.(toGatewayError(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
|
|||||||
Reference in New Issue
Block a user