From e4bbc27a7c1e4d196d4b72fc7fd0944cefc8fed7 Mon Sep 17 00:00:00 2001 From: grendervill Date: Tue, 22 Sep 2026 20:58:49 +0300 Subject: [PATCH] =?UTF-8?q?fix(realtime):=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B9=20=D0=B8=20=D0=BB=D0=BE=D0=B3=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - клиент откладывает перечитывание комнат (окно 250 мс) и не запускает второй запрос, пока идёт первый: серия событий ролей и участников больше не даёт лавину GET members/roles и 429 на лимите API; - неожиданные ошибки API пишутся в лог (и в chi-, и в huma-ветке): под параллельной нагрузкой на стенде видели разовые 500 на GET members/roles, но без текста ошибки разобраться было нельзя. --- internal/server/api_context.go | 5 +++++ internal/server/errors.go | 4 ++++ web/src/stores/gateway.ts | 8 ++++---- web/src/stores/session.ts | 35 ++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/internal/server/api_context.go b/internal/server/api_context.go index 8c73dbd..a5a6bb7 100644 --- a/internal/server/api_context.go +++ b/internal/server/api_context.go @@ -149,6 +149,11 @@ func humaError(err error) huma.StatusError { return nil } converted := newAPIError(err) + if converted.Status >= http.StatusInternalServerError { + // Единственное место, где видно текст неожиданной ошибки: клиенту + // уходит «internal error», поэтому пишем причину в лог (AGENT.md 9.4). + slog.Error("unexpected API error", slog.String("code", converted.Code), slog.Any("error", err)) + } return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message} } diff --git a/internal/server/errors.go b/internal/server/errors.go index 9f2e393..08226cb 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "errors" + "log/slog" "net/http" "glchat/internal/auth" @@ -74,6 +75,9 @@ func newAPIError(err error) apiError { func writeAPIError(w http.ResponseWriter, err error) { apiErr := newAPIError(err) if apiErr.Status >= http.StatusInternalServerError { + // Неожиданную ошибку пишем в лог: клиент видит «internal error», а + // разобраться без текста ошибки невозможно (AGENT.md 9.4). + slog.Error("unexpected API error", slog.String("code", apiErr.Code), slog.Any("error", err)) apiErr.Message = "internal error" } httpx.WriteJSON(w, apiErr.Status, map[string]any{ diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts index f87c488..ca4f984 100644 --- a/web/src/stores/gateway.ts +++ b/web/src/stores/gateway.ts @@ -211,7 +211,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { // Права на комнату персональные: тело события их не несёт, поэтому // перечитываем список — иначе новая или переименованная комната не // появится в сайдбаре без перезагрузки (AGENT.md 11.6). - void session.reloadGuildChannels(guildId); + session.scheduleGuildChannelsReload(guildId); } return; } @@ -291,7 +291,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { const guildId = parseGuildId(event.d); if (guildId !== null) { void invalidateMembershipQueries(guildId); - void session.reloadGuildChannels(guildId); + session.scheduleGuildChannelsReload(guildId); } return; } @@ -301,7 +301,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { const role = parseRoleEvent(event.d); if (role !== null) { void invalidateMembershipQueries(role.guild_id); - void session.reloadGuildChannels(role.guild_id); + session.scheduleGuildChannelsReload(role.guild_id); } return; } @@ -366,7 +366,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void { // участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6). const sync = parseGuildChannelsSyncEvent(event.d); if (sync !== null) { - void session.reloadGuildChannels(sync.guild_id); + session.scheduleGuildChannelsReload(sync.guild_id); } return; } diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts index ef3c1e3..628a67c 100644 --- a/web/src/stores/session.ts +++ b/web/src/stores/session.ts @@ -116,6 +116,8 @@ interface SessionState { removeChannel: (guildId: string, channelId: string) => void; /** GUILD_CHANNELS_SYNC: перечитать видимые комнаты сервера (права изменились). */ reloadGuildChannels: (guildId: string) => Promise; + /** То же, но с объединением частых вызовов в один запрос. */ + scheduleGuildChannelsReload: (guildId: string) => void; /** Первая доступная (видимая) комната сервера — для выбора после удалений. */ firstChannelId: (guildId: string) => string | null; selectGuild: (guildId: string | null) => void; @@ -195,6 +197,35 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild { return summary; } +/** Таймеры отложенной синхронизации комнат по серверам. */ +const pendingChannelSync = new Map>(); +/** Серверы, для которых синхронизация уже идёт: второй запрос не запускаем. */ +const channelSyncInFlight = new Set(); + +/** + * scheduleChannelSync откладывает перечитывание комнат: серия событий (правка + * роли, выдача роли участнику, пакет изменений) даёт один запрос вместо + * десятка — иначе срабатывает лимит API (AGENT.md 8.6). + */ +function scheduleChannelSync(guildId: string, run: () => Promise): void { + const existing = pendingChannelSync.get(guildId); + if (existing !== undefined) { + clearTimeout(existing); + } + pendingChannelSync.set( + guildId, + setTimeout(() => { + pendingChannelSync.delete(guildId); + if (channelSyncInFlight.has(guildId)) { + scheduleChannelSync(guildId, run); + return; + } + channelSyncInFlight.add(guildId); + void run().finally(() => channelSyncInFlight.delete(guildId)); + }, 250), + ); +} + export const useSessionStore = create((set, get) => ({ ...defaults, @@ -354,6 +385,10 @@ export const useSessionStore = create((set, get) => ({ }); }, + scheduleGuildChannelsReload: (guildId) => { + scheduleChannelSync(guildId, () => get().reloadGuildChannels(guildId)); + }, + reloadGuildChannels: async (guildId) => { try { const channels = await fetchChannels(guildId);