d5fbaa3034
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin and delivered by the best affordance each platform has (five on-device review rounds): - TG Android/desktop: native showPopup chooser -> native downloadFile dialog (bridge-only chain, activation-safe). - TG iOS: app-modal chooser -> OS share sheet with the fetched file (a popup callback cannot supply the activation the sheet needs). - VK iOS: VKWebAppDownloadFile for both formats. - VK Android: the PNG opens in VK's native image viewer, the GCG copies to the clipboard (the VK Android downloader hangs on any download, Content-Length/Range notwithstanding). - VK desktop iframe / desktop browsers: plain anchor downloads. - Mobile browsers: the OS share sheet (fetch-then-share). - Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option. The PNG is rasterized on demand by the new internal `renderer` sidecar (node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts) executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests; the backend rebuilds the render payload from the journal + engine.AlphabetTable, and the device date locale, IANA time zone and localized non-play labels ride the signed URL. Nothing is stored — the artifact re-derives from the immutable journal on each GET. The gateway forwards /dl/* (caddy @gateway matcher extended) behind the per-IP public rate limiter and serves bytes via http.ServeContent. Deploy: renderer service in compose + prod overlay + rolling order + prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN, TESTING, deploy/README, renderer/README.
87 lines
3.5 KiB
TypeScript
87 lines
3.5 KiB
TypeScript
// Retry policy + error classification for the gateway transport. When a unary call
|
|
// fails at the transport level the app retries it with capped exponential backoff while showing
|
|
// the "Connecting…" indicator, instead of flashing a red toast each time.
|
|
//
|
|
// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is
|
|
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
|
|
// been lost after the backend applied it), so only **read-only** ops are auto-retried on
|
|
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
|
|
// re-enables on reconnect, so the player re-issues it deliberately).
|
|
|
|
import { Code, ConnectError } from '@connectrpc/connect';
|
|
import { GatewayError } from './client';
|
|
|
|
/**
|
|
* toGatewayError normalises a thrown Connect/transport error to a GatewayError with a stable code.
|
|
* Connection-level failures — the server is unreachable, the request timed out, was reset or
|
|
* cancelled, or a raw network error — all collapse to **'unavailable'**, so they are handled as
|
|
* connectivity (the indicator + retry), never as a red error toast. A genuine server-side
|
|
* 'internal' or a domain code is preserved.
|
|
*/
|
|
export function toGatewayError(e: unknown): GatewayError {
|
|
if (e instanceof ConnectError) {
|
|
switch (e.code) {
|
|
case Code.Unauthenticated:
|
|
return new GatewayError('session_invalid', e.message);
|
|
case Code.ResourceExhausted:
|
|
return new GatewayError('rate_limited', e.message);
|
|
case Code.Unavailable:
|
|
case Code.DeadlineExceeded:
|
|
case Code.Canceled:
|
|
case Code.Aborted:
|
|
case Code.Unknown:
|
|
return new GatewayError('unavailable', e.message);
|
|
case Code.NotFound:
|
|
return new GatewayError('not_found', e.message);
|
|
default:
|
|
return new GatewayError('internal', e.message);
|
|
}
|
|
}
|
|
return new GatewayError('unavailable', String(e));
|
|
}
|
|
|
|
/** READ_OPS is the set of side-effect-free message types (safe to auto-retry on any failure). */
|
|
export const READ_OPS: ReadonlySet<string> = new Set([
|
|
'profile.get',
|
|
'games.list',
|
|
'game.state',
|
|
'game.history',
|
|
'game.gcg',
|
|
'game.export_url',
|
|
'game.evaluate',
|
|
'game.check_word',
|
|
'stats.get',
|
|
'lobby.poll',
|
|
'chat.list',
|
|
'draft.get',
|
|
'friends.list',
|
|
'friends.incoming',
|
|
'friends.outgoing',
|
|
'blocks.list',
|
|
'invitation.list',
|
|
]);
|
|
|
|
/**
|
|
* retryable reports whether a failed op should be auto-retried. A rate-limit rejection is always
|
|
* safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for
|
|
* read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final.
|
|
*/
|
|
export function retryable(code: string, op: string): boolean {
|
|
if (code === 'rate_limited') return true;
|
|
if (code === 'unavailable') return READ_OPS.has(op);
|
|
return false;
|
|
}
|
|
|
|
/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting
|
|
* indicator covers (so the UI suppresses its red toast). */
|
|
export function isConnectionCode(code: string): boolean {
|
|
return code === 'unavailable' || code === 'rate_limited';
|
|
}
|
|
|
|
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
|
|
* little jitter, so a fleet of clients does not retry in lockstep after an outage. */
|
|
export function backoffMs(attempt: number): number {
|
|
const base = Math.min(8000, 500 * 2 ** Math.max(0, attempt - 1));
|
|
return base + Math.floor(Math.random() * 250);
|
|
}
|