Skip to content
Open
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
50 changes: 30 additions & 20 deletions packages/pagination/pagination.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package pagination

import (
"errors"
"net/http"
"reflect"

Expand Down Expand Up @@ -328,10 +329,6 @@ func (r *NextCursorPage[T]) UnmarshalJSON(data []byte) error {
// there is no next page, this function will return a 'nil' for the page value, but
// will not return an error
func (r *NextCursorPage[T]) GetNextPage() (res *NextCursorPage[T], err error) {
if len(r.Data) == 0 {
return nil, nil
}

if r.JSON.HasMore.Valid() && r.HasMore == false {
Comment on lines 331 to 332

@chatgpt-codex-connector chatgpt-codex-connector Bot Sep 4, 2026

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 Badge Implement the fix in the Castiron generator

This changes Castiron-owned shared pagination scaffolding, but the commit contains neither a generator change nor regenerated metadata, leaving the underlying template to keep emitting the faulty empty-page guard for future generations and sibling SDKs. Fix the shared Castiron template and regenerate this file rather than carrying only a repository-local patch.

AGENTS.md reference: AGENTS.md:L68-L70

Useful? React with 👍 / 👎.

return nil, nil
}
Expand Down Expand Up @@ -364,36 +361,49 @@ func (r *NextCursorPage[T]) SetPageConfig(cfg *requestconfig.RequestConfig, res
}

type NextCursorPageAutoPager[T any] struct {
page *NextCursorPage[T]
cur T
idx int
run int
err error
page *NextCursorPage[T]
cur T
idx int
run int
err error
seenCursors map[string]struct{}
paramObj
}

func NewNextCursorPageAutoPager[T any](page *NextCursorPage[T], err error) *NextCursorPageAutoPager[T] {
return &NextCursorPageAutoPager[T]{
page: page,
err: err,
page: page,
err: err,
seenCursors: make(map[string]struct{}),

@chatgpt-codex-connector chatgpt-codex-connector Bot Sep 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Seed cycle detection with the initial cursor

When ListAutoPaging starts with a nonempty After value, this map does not include the cursor used for the initial request. If that response has data, has_more:true, and echoes the same next cursor, the pager returns the first page's items, makes the identical request, and returns those items again before detecting the repetition on a later Next() call. Seed cycle detection from the initial request cursor, or track the cursor used to fetch each page, and add a public-entrypoint regression test for this resume-pagination case.

AGENTS.md reference: AGENTS.md:L71-L73

Useful? React with 👍 / 👎.

}
}

func (r *NextCursorPageAutoPager[T]) Next() bool {
if r.page == nil || len(r.page.Data) == 0 {
return false
}
if r.idx >= len(r.page.Data) {
for r.page != nil {
if r.idx < len(r.page.Data) {
r.cur = r.page.Data[r.idx]
r.run += 1
r.idx += 1
return true
}
if r.page.JSON.HasMore.Valid() && !r.page.HasMore {
r.page = nil
return false
}
if r.page.Next != "" {
if _, ok := r.seenCursors[r.page.Next]; ok {
r.err = errors.New("pagination cursor did not advance")
return false
}
r.seenCursors[r.page.Next] = struct{}{}

@chatgpt-codex-connector chatgpt-codex-connector Bot Sep 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound cursor history instead of retaining every page cursor

On a successful pagination with unique cursors, every exhausted page adds another string to seenCursors, and nothing removes entries until the entire pager is discarded. This changes auto-pagination from constant auxiliary memory to memory proportional to the total page count; for example, iterating a large group or user collection with a small limit can retain millions of opaque cursors despite processing items incrementally. Restrict history to the consecutive empty-page traversal or use constant-space cycle detection so normal large iterations do not accumulate all prior cursors.

AGENTS.md reference: AGENTS.md:L197-L198

Useful? React with 👍 / 👎.

}
r.idx = 0
r.page, r.err = r.page.GetNextPage()
if r.err != nil || r.page == nil || len(r.page.Data) == 0 {
if r.err != nil {
Comment on lines 400 to +402

@chatgpt-codex-connector chatgpt-codex-connector Bot Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop when empty pages repeat a cursor

When an empty page returns has_more: true and repeats the same nonempty next cursor, this loop issues the identical request indefinitely inside a single Next() call; with a successful server and a context without a deadline, the caller never regains control and the client can continuously hammer the endpoint. Detect a cursor that makes no progress and terminate with an error, with a public-entrypoint regression test covering the malformed response.

AGENTS.md reference: AGENTS.md:L38-L40

Useful? React with 👍 / 👎.

return false
}
}
r.cur = r.page.Data[r.idx]
r.run += 1
r.idx += 1
return true
return false
}

func (r *NextCursorPageAutoPager[T]) Current() T {
Expand Down
146 changes: 145 additions & 1 deletion pagination_transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func TestPaginationPreservesCustomHTTPClient(t *testing.T) {
option.WithHTTPClient(fallbackClient),
option.WithHTTPClient(customClient),
)
pager := client.FineTuning.Jobs.ListAutoPaging(context.Background(), openai.FineTuningJobListParams{})
pager := client.Admin.Organization.Groups.ListAutoPaging(context.Background(), openai.AdminOrganizationGroupListParams{})

var jobIDs []string
for pager.Next() {
Expand All @@ -80,3 +80,147 @@ func TestPaginationPreservesCustomHTTPClient(t *testing.T) {
t.Fatalf("fallback HTTP client calls = %d, want 0", fallbackCalls)
}
}

func TestNextCursorPaginationContinuesAfterEmptyPage(t *testing.T) {
customClient := paginationHTTPDoerFunc(func(req *http.Request) (*http.Response, error) {
var body string
switch req.URL.Query().Get("after") {
case "":
body = `{"data":[],"has_more":true,"next":"cursor-1"}`
case "cursor-1":
body = `{"data":[{"id":"job-1"}],"has_more":false,"next":""}`
default:
return nil, errors.New("unexpected pagination cursor")
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})

client := openai.NewClient(
option.WithBaseURL("https://example.com/v1"),
option.WithAPIKey("test-key"),
option.WithAdminAPIKey("admin-test-key"),
option.WithMaxRetries(0),
option.WithHTTPClient(customClient),
)
pager := client.Admin.Organization.Groups.ListAutoPaging(context.Background(), openai.AdminOrganizationGroupListParams{})

if !pager.Next() {
t.Fatalf("pager stopped before the page after the empty page: %v", pager.Err())
}
if got, want := pager.Current().ID, "job-1"; got != want {
t.Fatalf("group ID = %q, want %q", got, want)
}
if pager.Next() {
t.Fatal("pager returned an unexpected additional job")
}
if err := pager.Err(); err != nil {
t.Fatal(err)
}
}

func TestNextCursorPaginationStopsOnRepeatedEmptyCursor(t *testing.T) {
calls := 0
customClient := paginationHTTPDoerFunc(func(req *http.Request) (*http.Response, error) {
calls++
body := `{"data":[],"has_more":true,"next":"cursor-1"}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})

client := openai.NewClient(
option.WithBaseURL("https://example.com/v1"),
option.WithAPIKey("test-key"),
option.WithAdminAPIKey("admin-test-key"),
option.WithMaxRetries(0),
option.WithHTTPClient(customClient),
)
pager := client.Admin.Organization.Groups.ListAutoPaging(context.Background(), openai.AdminOrganizationGroupListParams{})

if pager.Next() {
t.Fatal("pager returned an item from an empty page")
}
if err := pager.Err(); err == nil || err.Error() != "pagination cursor did not advance" {
t.Fatalf("pager error = %v, want repeated cursor error", err)
}
if calls != 2 {
t.Fatalf("HTTP calls = %d, want 2", calls)
}
}

func TestNextCursorPaginationStopsOnCursorCycle(t *testing.T) {
customClient := paginationHTTPDoerFunc(func(req *http.Request) (*http.Response, error) {
var body string
switch req.URL.Query().Get("after") {
case "":
body = `{"data":[],"has_more":true,"next":"cursor-a"}`
case "cursor-a":
body = `{"data":[],"has_more":true,"next":"cursor-b"}`
case "cursor-b":
body = `{"data":[],"has_more":true,"next":"cursor-a"}`
default:
return nil, errors.New("unexpected pagination cursor")
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})

client := openai.NewClient(
option.WithBaseURL("https://example.com/v1"),
option.WithAPIKey("test-key"),
option.WithMaxRetries(0),
option.WithHTTPClient(customClient),
)
pager := client.Admin.Organization.Groups.ListAutoPaging(context.Background(), openai.AdminOrganizationGroupListParams{})

if pager.Next() {
t.Fatal("pager returned an item from empty pages")
}
if err := pager.Err(); err == nil || err.Error() != "pagination cursor did not advance" {
t.Fatalf("pager error = %v, want cursor cycle error", err)
}
}

func TestNextCursorPaginationStopsOnExplicitlyFinishedPage(t *testing.T) {
calls := 0
customClient := paginationHTTPDoerFunc(func(req *http.Request) (*http.Response, error) {
calls++
body := `{"data":[],"has_more":false,"next":"cursor-1"}`
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
})

client := openai.NewClient(
option.WithBaseURL("https://example.com/v1"),
option.WithAPIKey("test-key"),
option.WithMaxRetries(0),
option.WithHTTPClient(customClient),
)
pager := client.FineTuning.Jobs.ListAutoPaging(context.Background(), openai.FineTuningJobListParams{})

if pager.Next() {
t.Fatal("pager returned an item from an empty page")
}
if err := pager.Err(); err != nil {
t.Fatalf("pager error = %v, want nil", err)
}
if calls != 1 {
t.Fatalf("HTTP calls = %d, want 1", calls)
}
}