From 0381baa67cf037405aa1f7432b43be654da94800 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 18 Aug 2026 14:38:46 -0700 Subject: [PATCH 1/2] fix: stop archived-thread and guild paging when the cursor does not advance GuildMembers already errors on a stuck cursor. Apply the same guard to UserGuilds and archived thread catalogs. Signed-off-by: Sebastien Tardif --- internal/discord/client.go | 12 +++- internal/discord/client_test.go | 124 ++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/internal/discord/client.go b/internal/discord/client.go index 5e1de453..16710778 100644 --- a/internal/discord/client.go +++ b/internal/discord/client.go @@ -295,10 +295,17 @@ func (c *Client) Guilds(ctx context.Context) ([]*discordgo.UserGuild, error) { return out, nil } out = append(out, page...) - before = page[len(page)-1].ID if len(page) < 200 { return out, nil } + nextBefore := page[len(page)-1].ID + if nextBefore == "" { + return nil, fmt.Errorf("guild page missing id") + } + if nextBefore == before { + return nil, fmt.Errorf("guild page cursor did not advance") + } + before = nextBefore } } @@ -368,6 +375,9 @@ func (c *Client) ThreadsArchived(ctx context.Context, channelID string, private return uniqueChannels(out), nil } archiveAt := oldest.ThreadMetadata.ArchiveTimestamp + if before != nil && archiveAt.Equal(*before) { + return nil, fmt.Errorf("channel %s archived thread page cursor did not advance", channelID) + } before = &archiveAt } } diff --git a/internal/discord/client_test.go b/internal/discord/client_test.go index 5363d321..a31a6c41 100644 --- a/internal/discord/client_test.go +++ b/internal/discord/client_test.go @@ -314,6 +314,130 @@ func TestGuildMembersErrorsWhenCursorDoesNotAdvance(t *testing.T) { require.Equal(t, 2, requests) } +func TestGuildsErrorsWhenCursorDoesNotAdvance(t *testing.T) { + page := make([]map[string]any, 200) + for i := range page { + page[i] = map[string]any{ + "id": fmt.Sprintf("g%03d", i), + "name": "Guild", + } + } + + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/api/v10/users/@me/guilds", func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests > 5 { + writeJSON([]map[string]any{})(w, r) + return + } + writeJSON(page)(w, r) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + restore := patchDiscordEndpoints(server.URL + "/api/v10/") + t.Cleanup(restore) + + client, err := New("token") + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + guilds, err := client.Guilds(context.Background()) + require.ErrorContains(t, err, "guild page cursor did not advance") + require.Nil(t, guilds) + require.Equal(t, 2, requests) +} + +func TestGuildsErrorsWhenFullPageHasEmptyID(t *testing.T) { + page := make([]map[string]any, 200) + for i := range 199 { + page[i] = map[string]any{ + "id": fmt.Sprintf("g%03d", i), + "name": "Guild", + } + } + page[199] = map[string]any{"id": "", "name": "Guild"} + + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/api/v10/users/@me/guilds", func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests > 5 { + writeJSON([]map[string]any{})(w, r) + return + } + writeJSON(page)(w, r) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + restore := patchDiscordEndpoints(server.URL + "/api/v10/") + t.Cleanup(restore) + + client, err := New("token") + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + guilds, err := client.Guilds(context.Background()) + require.ErrorContains(t, err, "guild page missing id") + require.Nil(t, guilds) + require.Equal(t, 1, requests) +} + +func TestThreadsArchivedErrorsWhenCursorDoesNotAdvance(t *testing.T) { + archivedAt := "2024-01-15T12:00:00Z" + page := map[string]any{ + "threads": []map[string]any{ + { + "id": "t1", + "guild_id": "g1", + "parent_id": "c1", + "name": "archived-public", + "type": 11, + "thread_metadata": map[string]any{ + "archived": true, + "auto_archive_duration": 60, + "archive_timestamp": archivedAt, + "locked": false, + "invitable": true, + }, + }, + }, + "members": []any{}, + "has_more": true, + } + + requests := 0 + mux := http.NewServeMux() + mux.HandleFunc("/api/v10/channels/c1/threads/archived/public", func(w http.ResponseWriter, r *http.Request) { + requests++ + if requests > 5 { + writeJSON(map[string]any{ + "threads": []any{}, + "members": []any{}, + "has_more": false, + })(w, r) + return + } + writeJSON(page)(w, r) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + restore := patchDiscordEndpoints(server.URL + "/api/v10/") + t.Cleanup(restore) + + client, err := New("token") + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + threads, err := client.ThreadsArchived(context.Background(), "c1", false) + require.ErrorContains(t, err, "archived thread page cursor did not advance") + require.Nil(t, threads) + require.Equal(t, 2, requests) +} + func TestLastMemberUserID(t *testing.T) { t.Parallel() From 62c46dd0c4a2d5b57423b9d73dbd167925340119 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 18 Aug 2026 14:57:37 -0700 Subject: [PATCH 2/2] fix: use errors.New for static guild cursor errors Signed-off-by: Sebastien Tardif --- internal/discord/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/discord/client.go b/internal/discord/client.go index 16710778..562b9bc7 100644 --- a/internal/discord/client.go +++ b/internal/discord/client.go @@ -300,10 +300,10 @@ func (c *Client) Guilds(ctx context.Context) ([]*discordgo.UserGuild, error) { } nextBefore := page[len(page)-1].ID if nextBefore == "" { - return nil, fmt.Errorf("guild page missing id") + return nil, errors.New("guild page missing id") } if nextBefore == before { - return nil, fmt.Errorf("guild page cursor did not advance") + return nil, errors.New("guild page cursor did not advance") } before = nextBefore }