Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/oytc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ func exitCode(err error) int {
}
var apiErr *youtube.APIError
if errors.As(err, &apiErr) {
// One normalization applies to every reason test below: Google returns
// camelCase reasons on older API surfaces (userRateLimitExceeded) and
// SCREAMING_SNAKE on newer ones (RATE_LIMIT_EXCEEDED), so separator
// characters carry no signal and must not affect classification.
reasons := strings.ToLower(strings.Join(apiErr.Reasons, ","))
normalizedReasons := strings.NewReplacer("_", "", "-", "").Replace(reasons)
if strings.Contains(normalizedReasons, "keyinvalid") || strings.Contains(normalizedReasons, "apikeyinvalid") || strings.Contains(normalizedReasons, "accessnotconfigured") || strings.Contains(normalizedReasons, "insufficientpermissions") || apiErr.HTTPStatus == 401 {
Expand All @@ -78,7 +82,7 @@ func exitCode(err error) int {
if apiErr.HTTPStatus == 404 {
return 4
}
if apiErr.HTTPStatus == 429 || strings.Contains(strings.ToLower(reasons), "quota") || strings.Contains(strings.ToLower(reasons), "ratelimit") {
if apiErr.HTTPStatus == 429 || strings.Contains(normalizedReasons, "quota") || strings.Contains(normalizedReasons, "ratelimit") {
return 5
}
if apiErr.HTTPStatus == 403 {
Expand Down
4 changes: 4 additions & 0 deletions cmd/oytc/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ func TestExitCodes(t *testing.T) {
{"not found", &youtube.APIError{HTTPStatus: 404, Code: 404}, 4},
{"local not found", errors.New("videos not found: missing"), 4},
{"quota", &youtube.APIError{HTTPStatus: 403, Code: 403, Reasons: []string{"quotaExceeded"}}, 5},
{"quota uppercase underscore", &youtube.APIError{HTTPStatus: 403, Code: 403, Reasons: []string{"QUOTA_EXCEEDED"}}, 5},
{"rate limit", &youtube.APIError{HTTPStatus: 429, Code: 429}, 5},
{"rate limit camel case", &youtube.APIError{HTTPStatus: 403, Code: 403, Reasons: []string{"userRateLimitExceeded"}}, 5},
{"rate limit uppercase underscore", &youtube.APIError{HTTPStatus: 403, Code: 403, Reasons: []string{"RATE_LIMIT_EXCEEDED"}}, 5},
{"rate limit hyphenated", &youtube.APIError{HTTPStatus: 403, Code: 403, Reasons: []string{"rate-limit-exceeded"}}, 5},
{"upstream", &youtube.APIError{HTTPStatus: 503, Code: 503}, 6},
}
for _, test := range tests {
Expand Down
7 changes: 5 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,8 +393,11 @@ List commands support:
```

The default is one page. `--limit` can stop an `--all` traversal early; the final page can
leave a valid `nextPageToken` in the JSON envelope for resumption. Batch-get commands split
IDs according to endpoint limits (50 for channels/videos/playlists, 100 for comments).
leave a valid `nextPageToken` in the JSON envelope for resumption. When `--limit` discards
items from the middle of a fetched page, no `nextPageToken` is reported — the server's
token would resume *past* the discarded items and silently skip them. A limit that lands
exactly on a page boundary keeps the token. Batch-get commands split IDs according to
endpoint limits (50 for channels/videos/playlists, 100 for comments).

Quota: most list requests cost 1 unit against the default 10,000-unit daily quota, while
`search.list` draws from its own default bucket of 100 calls/day. See
Expand Down
98 changes: 97 additions & 1 deletion internal/youtube/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package youtube
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
Expand Down Expand Up @@ -146,11 +148,105 @@ func TestListPaginationLimitAndToken(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 3 || result.Requests != 2 || result.NextPageToken != "unused" {
// The second page was truncated (item "4" discarded), so the server's
// token must not be reported: resuming from it would skip the discarded
// item.
if len(result.Items) != 3 || result.Requests != 2 || result.NextPageToken != "" {
t.Fatalf("unexpected result: %#v", result)
}
}

func TestListExactLimitBoundaryKeepsToken(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"items":[{"id":"1"},{"id":"2"}],"nextPageToken":"resume"}`))
}))
defer server.Close()

// The limit consumes the page exactly; nothing was discarded, so the
// token is a valid resume point and must be kept.
result, err := testClient(server, "key").List(context.Background(), "playlistItems", url.Values{}, PageOptions{All: true, Limit: 2, PageSize: 2})
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 2 || result.NextPageToken != "resume" {
t.Fatalf("unexpected result: %#v", result)
}
}

func TestListStopsOnRepeatedPageToken(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = w.Write([]byte(`{"items":[{"id":"1"}],"nextPageToken":"loop"}`))
}))
defer server.Close()

result, err := testClient(server, "key").List(context.Background(), "search", url.Values{}, PageOptions{All: true})
if err != nil {
t.Fatal(err)
}
// First page yields token "loop"; the second page repeats it, which can
// only re-fetch the same page, so the loop stops and reports no resume
// point instead of iterating forever.
if result.Requests != 2 || requests.Load() != 2 {
t.Fatalf("requests = %d (server saw %d), want 2", result.Requests, requests.Load())
}
if result.NextPageToken != "" {
t.Fatalf("NextPageToken = %q, want empty", result.NextPageToken)
}
}

func TestListStopsWhenInitialPageTokenIsEchoed(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.URL.Query().Get("pageToken") != "start" {
t.Errorf("pageToken = %q", r.URL.Query().Get("pageToken"))
}
// The server echoes the caller's starting token as nextPageToken.
_, _ = w.Write([]byte(`{"items":[{"id":"1"}],"nextPageToken":"start"}`))
}))
defer server.Close()

result, err := testClient(server, "key").List(context.Background(), "search", url.Values{}, PageOptions{All: true, PageToken: "start"})
if err != nil {
t.Fatal(err)
}
// Following the echoed token would re-fetch the same page, so the loop
// must stop after one request without duplicating its items.
if result.Requests != 1 || requests.Load() != 1 {
t.Fatalf("requests = %d (server saw %d), want 1", result.Requests, requests.Load())
}
if len(result.Items) != 1 {
t.Fatalf("items = %d, want 1", len(result.Items))
}
if result.NextPageToken != "" {
t.Fatalf("NextPageToken = %q, want empty", result.NextPageToken)
}
}

func TestListRequestCeiling(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Every page returns a distinct token, so loop detection never fires
// and only the request ceiling can end the pagination.
token := requests.Add(1)
fmt.Fprintf(w, `{"items":[{"id":"x"}],"nextPageToken":"t%d"}`, token)
}))
defer server.Close()

result, err := testClient(server, "key").List(context.Background(), "search", url.Values{}, PageOptions{All: true})
if err == nil {
t.Fatal("expected an error after hitting the request ceiling")
}
if !strings.Contains(err.Error(), "pagination did not terminate") || !strings.Contains(err.Error(), "--limit") {
t.Fatalf("error = %v", err)
}
if result.Requests != MaxListRequests {
t.Fatalf("requests = %d, want %d", result.Requests, MaxListRequests)
}
}

func TestListReturnsEmptySliceWhenResponseHasNoItems(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{}`))
Expand Down
42 changes: 39 additions & 3 deletions internal/youtube/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type ListResult struct {
Requests int `json:"requests"`
}

// MaxListRequests bounds the `--all` pagination loop. It is deliberately far
// above any real result set: at the largest page size any endpoint accepts
// (2000, live chat) it allows 20,000,000 items, and every other endpoint caps
// at 50 or 100 per page. A legitimate `--all` cannot reach it, so hitting it
// means the server is not terminating.
const MaxListRequests = 10000

func (c *Client) List(ctx context.Context, resource string, params url.Values, options PageOptions) (ListResult, error) {
if options.PageSize > 0 {
params.Set("maxResults", fmt.Sprint(options.PageSize))
Expand All @@ -31,6 +38,12 @@ func (c *Client) List(ctx context.Context, resource string, params url.Values, o
params.Set("pageToken", options.PageToken)
}
result := ListResult{Items: make([]map[string]any, 0)}
// Seed with the caller's starting token: a server echoing it back is the
// same loop as any other repeated token and must not re-fetch the page.
seenTokens := make(map[string]struct{})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if options.PageToken != "" {
seenTokens[options.PageToken] = struct{}{}
}
for {
response, err := c.Get(ctx, resource, params)
if err != nil {
Expand All @@ -47,15 +60,38 @@ func (c *Client) List(ctx context.Context, resource string, params url.Values, o
}
items = filtered
}
truncated := false
if options.Limit > 0 && len(result.Items)+len(items) > options.Limit {
items = items[:options.Limit-len(result.Items)]
truncated = true
}
result.Items = append(result.Items, items...)
result.NextPageToken = response.NextPageToken
if !options.All || response.NextPageToken == "" || (options.Limit > 0 && len(result.Items) >= options.Limit) {
// A page from which items were discarded reports no resume token:
// the server's token points past the discarded tail, so resuming from
// it would silently skip data. A page trimmed to exactly its own
// length is not truncated and keeps its token.
if truncated {
result.NextPageToken = ""
} else {
result.NextPageToken = response.NextPageToken
}
if !options.All || result.NextPageToken == "" || (options.Limit > 0 && len(result.Items) >= options.Limit) {
break
}
params.Set("pageToken", response.NextPageToken)
// A token we have already followed can only return the same page
// again. Stop and report "", which correctly says "no valid resume
// point" rather than handing back a token that loops.
if _, seen := seenTokens[result.NextPageToken]; seen {
Comment on lines +82 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Request ceiling rejects valid pagination

When --all uses --page-size 1 for more than 10,000 results, this ceiling rejects a traversal whose tokens are still advancing, causing the command to discard all accumulated output and return an error.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/youtube/list.go
Line: 82-84

Comment:
**Request ceiling rejects valid pagination**

When `--all` uses `--page-size 1` for more than 10,000 results, this ceiling rejects a traversal whose tokens are still advancing, causing the command to discard all accumulated output and return an error.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally skipping this one. Reaching the ceiling requires 10,000 requests, and every paginated request costs ≥1 unit of the default 10,000-unit daily quota — so the API's own quotaExceeded cuts the traversal off at or before the point the ceiling fires (and a mid-pagination quota error discards accumulated output the same way; that's the pre-existing behavior for any mid-traversal failure). Endpoints with page sizes above 100 (live chat, up to 2000) reject --all by design, so the 1-unit endpoints are the only ones that paginate. A bound has to exist somewhere to stop a non-terminating server — a legitimate 10,001st token is indistinguishable from a hostile one — and --limit is the documented escape hatch (the error message points to it). The value also stays in parity with the TypeScript port's pinned MAX_PAGES = 10000 (#2, D3).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a sound argument — the API quota acts as a natural ceiling below the guard for 1-unit endpoints, high-page-size endpoints reject --all by design, and the TypeScript port parity is a reasonable anchor. Understood, withdrawing the concern.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

result.NextPageToken = ""
break
}
seenTokens[result.NextPageToken] = struct{}{}
// Backstop for a server that emits distinct tokens forever, which the
// loop check cannot catch.
if result.Requests >= MaxListRequests {
return result, fmt.Errorf("pagination did not terminate after %d requests (the server kept returning a nextPageToken); re-run with --limit to bound the result", MaxListRequests)
}
params.Set("pageToken", result.NextPageToken)
}
return result, nil
}
Expand Down