fix(realtime): объединение частых синхронизаций и лог неожиданных ошибок
- клиент откладывает перечитывание комнат (окно 250 мс) и не запускает второй запрос, пока идёт первый: серия событий ролей и участников больше не даёт лавину GET members/roles и 429 на лимите API; - неожиданные ошибки API пишутся в лог (и в chi-, и в huma-ветке): под параллельной нагрузкой на стенде видели разовые 500 на GET members/roles, но без текста ошибки разобраться было нельзя.
This commit is contained in:
@@ -149,6 +149,11 @@ func humaError(err error) huma.StatusError {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
converted := newAPIError(err)
|
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}
|
return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"glchat/internal/auth"
|
"glchat/internal/auth"
|
||||||
@@ -74,6 +75,9 @@ func newAPIError(err error) apiError {
|
|||||||
func writeAPIError(w http.ResponseWriter, err error) {
|
func writeAPIError(w http.ResponseWriter, err error) {
|
||||||
apiErr := newAPIError(err)
|
apiErr := newAPIError(err)
|
||||||
if apiErr.Status >= http.StatusInternalServerError {
|
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"
|
apiErr.Message = "internal error"
|
||||||
}
|
}
|
||||||
httpx.WriteJSON(w, apiErr.Status, map[string]any{
|
httpx.WriteJSON(w, apiErr.Status, map[string]any{
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
// Права на комнату персональные: тело события их не несёт, поэтому
|
// Права на комнату персональные: тело события их не несёт, поэтому
|
||||||
// перечитываем список — иначе новая или переименованная комната не
|
// перечитываем список — иначе новая или переименованная комната не
|
||||||
// появится в сайдбаре без перезагрузки (AGENT.md 11.6).
|
// появится в сайдбаре без перезагрузки (AGENT.md 11.6).
|
||||||
void session.reloadGuildChannels(guildId);
|
session.scheduleGuildChannelsReload(guildId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
const guildId = parseGuildId(event.d);
|
const guildId = parseGuildId(event.d);
|
||||||
if (guildId !== null) {
|
if (guildId !== null) {
|
||||||
void invalidateMembershipQueries(guildId);
|
void invalidateMembershipQueries(guildId);
|
||||||
void session.reloadGuildChannels(guildId);
|
session.scheduleGuildChannelsReload(guildId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -301,7 +301,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
const role = parseRoleEvent(event.d);
|
const role = parseRoleEvent(event.d);
|
||||||
if (role !== null) {
|
if (role !== null) {
|
||||||
void invalidateMembershipQueries(role.guild_id);
|
void invalidateMembershipQueries(role.guild_id);
|
||||||
void session.reloadGuildChannels(role.guild_id);
|
session.scheduleGuildChannelsReload(role.guild_id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -366,7 +366,7 @@ export function dispatchGatewayEvent(event: GatewayDispatch): void {
|
|||||||
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
// участников, поэтому список комнат перечитываем целиком (AGENT.md 11.6).
|
||||||
const sync = parseGuildChannelsSyncEvent(event.d);
|
const sync = parseGuildChannelsSyncEvent(event.d);
|
||||||
if (sync !== null) {
|
if (sync !== null) {
|
||||||
void session.reloadGuildChannels(sync.guild_id);
|
session.scheduleGuildChannelsReload(sync.guild_id);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ interface SessionState {
|
|||||||
removeChannel: (guildId: string, channelId: string) => void;
|
removeChannel: (guildId: string, channelId: string) => void;
|
||||||
/** GUILD_CHANNELS_SYNC: перечитать видимые комнаты сервера (права изменились). */
|
/** GUILD_CHANNELS_SYNC: перечитать видимые комнаты сервера (права изменились). */
|
||||||
reloadGuildChannels: (guildId: string) => Promise<void>;
|
reloadGuildChannels: (guildId: string) => Promise<void>;
|
||||||
|
/** То же, но с объединением частых вызовов в один запрос. */
|
||||||
|
scheduleGuildChannelsReload: (guildId: string) => void;
|
||||||
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
|
/** Первая доступная (видимая) комната сервера — для выбора после удалений. */
|
||||||
firstChannelId: (guildId: string) => string | null;
|
firstChannelId: (guildId: string) => string | null;
|
||||||
selectGuild: (guildId: string | null) => void;
|
selectGuild: (guildId: string | null) => void;
|
||||||
@@ -195,6 +197,35 @@ function toSessionGuild(guild: GatewayGuild): SessionGuild {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Таймеры отложенной синхронизации комнат по серверам. */
|
||||||
|
const pendingChannelSync = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
/** Серверы, для которых синхронизация уже идёт: второй запрос не запускаем. */
|
||||||
|
const channelSyncInFlight = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scheduleChannelSync откладывает перечитывание комнат: серия событий (правка
|
||||||
|
* роли, выдача роли участнику, пакет изменений) даёт один запрос вместо
|
||||||
|
* десятка — иначе срабатывает лимит API (AGENT.md 8.6).
|
||||||
|
*/
|
||||||
|
function scheduleChannelSync(guildId: string, run: () => Promise<void>): 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<SessionState>((set, get) => ({
|
export const useSessionStore = create<SessionState>((set, get) => ({
|
||||||
...defaults,
|
...defaults,
|
||||||
|
|
||||||
@@ -354,6 +385,10 @@ export const useSessionStore = create<SessionState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
scheduleGuildChannelsReload: (guildId) => {
|
||||||
|
scheduleChannelSync(guildId, () => get().reloadGuildChannels(guildId));
|
||||||
|
},
|
||||||
|
|
||||||
reloadGuildChannels: async (guildId) => {
|
reloadGuildChannels: async (guildId) => {
|
||||||
try {
|
try {
|
||||||
const channels = await fetchChannels(guildId);
|
const channels = await fetchChannels(guildId);
|
||||||
|
|||||||
Reference in New Issue
Block a user