Skip to content

feat: Address pagination API gap with aligned approach#345

Merged
sichanyoo merged 6 commits into
decaffrom
feat/pagination-api-gap
Jul 1, 2026
Merged

feat: Address pagination API gap with aligned approach#345
sichanyoo merged 6 commits into
decaffrom
feat/pagination-api-gap

Conversation

@sichanyoo

@sichanyoo sichanyoo commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Background & Decisions

Responses are Struct subclasses, and Ruby's Struct includes Enumerable by default. Without this fix, calling .each on a paginated response silently iterated struct field values instead of pages, a possible silent data-loss bug. This PR fixes the gap by adding .each to PageableResponse (delegating to each_page), undefining each on Smithy::Schema::Structure as a safety net, and introducing a PageEnumerator class that exposes only safe enumeration methods (map, select, flat_map, reduce,first, take, lazy) while blocking dangerous ones (count, sort, to_a, etc.) that would silently trigger full pagination with real API calls and real cost.

We chose a custom PageEnumerator over mixing Enumerable into the response because starting with a narrow surface and expanding later is non-breaking, whereas removing a dangerous method after customers depend on it is breaking.

.each iterates pages rather than items because items is optional in Smithy's @paginated trait. About 21% of paginated operations at AWS have no items defined currently, and some operations return multiple result collections, making page iteration the only universally safe default.

@sichanyoo sichanyoo left a comment

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.

Comments for additional context

@@ -0,0 +1,40 @@
# frozen_string_literal: true

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.

This is the custom enumerator with limited API surface to safeguard against unsafe Enumerable methods

Comment thread gems/smithy-schema/lib/smithy-schema/structure.rb
@@ -36,6 +36,13 @@ def initialize(response)
# This yields one response object per API call made. The SDK retrieves additional
# pages of data to complete the request.
#

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.

Updates the PageableResponse module that gets added to Response to return the custom enumerator when not give a block, and if given a block, enumerate using the custom enumerator and yield results. As it has been, PageableResponse#each will paginate over pages instead of items due to uniqueness of AWS background discussed offline.

@jterapin jterapin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The overall approach looks good but curious what we can do about the enumerator being created at every method call. 🤔

Also - I was wondering if the PR description could contain overall info about the motivation of this approach and what decisions we make.

We should also have wangrch take a look when he comes back.

Comment on lines +35 to +37
def enum
Enumerator.new(&@block)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The current PageEnumerator creates a new Enumerator on every method call (map, select, first, etc.) via the private enum method. Is there a way to prevent this or was this intentional?

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.

The allocation cost is negligible compared to the API calls each iteration triggers & returning brand new enumerator each time I think has a subtle benefit of thread safety with interleaved paging operations when it's used concurrently. But changing it to reuse enumerator is a one line change so if this is based on rubyist convention / you feel strongly for reusing, we can do that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is fine for now, thanks for taking a look!

def reduce(*, &) = enum.reduce(*, &)

def first(val = (no_arg = true
nil))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fix alignment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we do: def first(*args) if we are just forwarding args?

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.

Good catch! Changed as suggested 👍

@@ -107,16 +126,29 @@ def each_page(&)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What about returning self here to enable chaining?

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.

Q: What could be the usecase for returning self to allow chaining here? If user doesn't provide a block, they get enumerator they can chain to already, and if user provides a block, whatever the user needs to do would be specified within that block. Fwiw, V3 also returns nil instead of self for each when user provides a block: https://github.com/aws/aws-sdk-ruby/blob/313cb58ad76dac97c27ff6d63b99eaadd5ad4d00/gems/aws-sdk-core/lib/aws-sdk-core/pageable_response.rb#L188-L196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense; I forgotten that V3 returns nil instead of self. Let's stay consistent with V3. Thanks for the pointer!

Comment thread gems/smithy-schema/lib/smithy-schema/structure.rb
Comment thread gems/smithy-client/lib/smithy-client.rb Outdated
Comment on lines +29 to +30
require_relative 'smithy-client/paginators/page_enumerator'
require_relative 'smithy-client/paginators/pageable_response'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ruby convention: a directory paginators/ implies a module Paginators. But PageEnumerator and PageableResponse don't live under a Smithy::Client::Paginators module

Feels longwinded if we add a new module so maybe we should keep this flat.

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.

I see! Updated to keep the paginator file structure flat.

response = response.next_page
@paginator.items(response.data).each(&)
# @return [PageEnumerator, nil]
def each_item(&block)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the API does not define items and customer calls .each_item, they'll get a NotImplementedError. Should we document this?

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.

Good point, added a @raise doc comment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One more question: if the items are modeled and there's no items in the response. Does that resolve to nil or some collection? We may want to handle it gracefully when nil is given instead of NoMethodError on nil.

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.

That's a good point; added safe navigation operator & to each call in both places so pages with zero items does not cause a failure.

@richardwang1124 richardwang1124 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good overall, can take a second pass review once the comments are addressed.

Comment on lines +134 to +138
def each(&block)
return each_page unless block

each_page(&block)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this could be one line? Maybe

def each(&block)
  each_page(&block)
end

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.

Good catch! Changed as suggested 👍

@sichanyoo sichanyoo left a comment

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.

Address initial review comments.

Comment on lines +35 to +37
def enum
Enumerator.new(&@block)
end

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.

The allocation cost is negligible compared to the API calls each iteration triggers & returning brand new enumerator each time I think has a subtle benefit of thread safety with interleaved paging operations when it's used concurrently. But changing it to reuse enumerator is a one line change so if this is based on rubyist convention / you feel strongly for reusing, we can do that

def reduce(*, &) = enum.reduce(*, &)

def first(val = (no_arg = true
nil))

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.

Good catch! Changed as suggested 👍

@@ -107,16 +126,29 @@ def each_page(&)
end

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.

Q: What could be the usecase for returning self to allow chaining here? If user doesn't provide a block, they get enumerator they can chain to already, and if user provides a block, whatever the user needs to do would be specified within that block. Fwiw, V3 also returns nil instead of self for each when user provides a block: https://github.com/aws/aws-sdk-ruby/blob/313cb58ad76dac97c27ff6d63b99eaadd5ad4d00/gems/aws-sdk-core/lib/aws-sdk-core/pageable_response.rb#L188-L196

Comment on lines +134 to +138
def each(&block)
return each_page unless block

each_page(&block)
end

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.

Good catch! Changed as suggested 👍

response = response.next_page
@paginator.items(response.data).each(&)
# @return [PageEnumerator, nil]
def each_item(&block)

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.

Good point, added a @raise doc comment.

Comment thread gems/smithy-client/lib/smithy-client.rb Outdated
Comment on lines +29 to +30
require_relative 'smithy-client/paginators/page_enumerator'
require_relative 'smithy-client/paginators/pageable_response'

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.

I see! Updated to keep the paginator file structure flat.

Comment thread gems/smithy-schema/lib/smithy-schema/structure.rb

@jterapin jterapin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Added some minor comments/question. other than that, approved.

Comment thread .github/workflows/ci.yml
Comment on lines +32 to +34
- name: Pin rdoc for JRuby
if: startsWith(matrix.ruby, 'jruby')
run: echo 'gem "rdoc", "< 8.0"' >> Gemfile

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Related: ruby/rbs#3018

Add a comment linking the ticket so that we can check on its status periodically

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.

Added comment link as suggested.

Comment on lines +35 to +37
def enum
Enumerator.new(&@block)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is fine for now, thanks for taking a look!

@@ -107,16 +126,29 @@ def each_page(&)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Makes sense; I forgotten that V3 returns nil instead of self. Let's stay consistent with V3. Thanks for the pointer!

response = response.next_page
@paginator.items(response.data).each(&)
# @return [PageEnumerator, nil]
def each_item(&block)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One more question: if the items are modeled and there's no items in the response. Does that resolve to nil or some collection? We may want to handle it gracefully when nil is given instead of NoMethodError on nil.

@sichanyoo sichanyoo merged commit e40969c into decaf Jul 1, 2026
33 checks passed
@richardwang1124 richardwang1124 deleted the feat/pagination-api-gap branch July 2, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants