Skip to content

fix: resume receiving listener pagination after interrupted polls - #400

Merged
felipefreitag merged 4 commits into
resend:mainfrom
mayankbohradev:fix/receiving-listener-pagination-recovery
Oct 15, 2026
Merged

felipefreitag merged 4 commits into
resend:mainfrom
mayankbohradev:fix/receiving-listener-pagination-recovery

Conversation

@mayankbohradev

@mayankbohradev mayankbohradev commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

resend emails receiving listen can stop reporting new emails after a burst larger than 500 emails or an error on a later page. The email records remain in Resend, but later listener polls do not report the unfinished part of the range.

This change retains pagination progress between polls. It also preserves the output from successful pages when a later page fails.

What failed

The listener starts with one existing email as its boundary. Each poll then reads up to five pages of 100 emails. Before this change, every poll starts from the latest page, including the poll after a page limit or a page error.

The listener adds partial results to its seen-ID cache. On the next poll, it finds those IDs at the head and stops. It never returns to the unfinished pages.

For example, with 501 new emails, the first poll reports 500. The next poll starts at the head, finds an email that it has already reported, and stops. The oldest new email remains unreported. With a temporary error on page two of a 101-email range, the first 100 emails are reported but the last email is not recovered.

A separate part of the same recovery path loses accumulated results if processing a later page throws. The outer poll catches that error, but it no longer has the results from the earlier successful pages.

What changed

  • Retain the next after cursor when a poll reaches the page limit.
  • Retain the failing page's input cursor when the SDK returns an error or page processing throws.
  • Resume the pending range on the next poll.
  • Track the last completed range boundary separately from the seen-ID cache. Track the new range's head separately until that range is complete.
  • Advance the completed boundary only after the pending range reaches the previous boundary or the end of the list.
  • Keep results from successful pages when a later page fails.
  • Retain the current cursor for an empty continuation page with has_more: true.

The separate boundary is needed because the cache holds at most 10,000 IDs. A larger burst can remove the startup boundary from that cache. Keeping the boundary separately prevents old startup history from being reported when that happens.

The five-page limit per poll, the 10,000-ID cache, rate-limit retries and existing output format remain in place. Retry attempts follow the existing retry policy and do not change the page limit.

How I tested it

I updated local main from upstream and checked the current listener history and related PRs before changing the code. The base is a724718046efc0bb32faa7625765ffaec3a16cf4. I installed dependencies with the frozen lockfile on Node 24.19.0 and pnpm 11.8.0. The installed Resend SDK is 6.31.0.

The unchanged baseline passed 1,160 tests, with one skipped test. I then added regression tests before changing production code. Eight checks failed and three controls passed. The failures were output assertions, not setup errors.

The regression tests run the actual listener command through the installed SDK. HTTP responses are replaced with controlled fixtures. The fixtures return newest-first lists with exclusive after cursors. They reject unknown cursors. The tests do not replace SDK list methods.

Case Before After
501 new emails 500 reported All 501 reported once
1,201 new emails 500 reported All 1,201 reported once
10,501 new emails with older startup history 500 reported All 10,501 reported once; no startup history
Page two returns HTTP 500 once 100 of 101 reported All 101 reported once
Page two has a network error once 100 of 101 reported All 101 reported once
Page two returns malformed data once Successful-page output discarded on the first poll First 100 retained; final email recovered
Two arrivals while a 501-email range is pending 502 of 503 reported All 503 reported once
Empty continuation page with has_more: true once Pending email not recovered Same page retried; all 501 reported once

Healthy 101-email and 500-email ranges, plus an HTTP 429 retry, pass as controls. The large-range tests check the five-page budget, unique output IDs and exclusion of startup history. After the change, all 23 listener tests pass. The full suite passes 1,171 tests, with one existing skipped test. Lint, type checks and the production build pass.

I also ran the actual compiled CLI before and after the change. Each process used an isolated temporary home directory, fake credentials and intercepted HTTP responses. I recorded stdout, stderr, request cursors and status codes. Each process completed four observed polls, then stopped with exit code 130. No process reached the safety timeout.

Compiled CLI case Expected Before After
Healthy range 500 500 500
Range above the page cap 501 500 501
Range spanning several polls 1,201 500 1,201
Page-two HTTP 500, then recovery 201 100 201
New arrivals during a pending range 604 503 604

The fixed runs have no duplicate IDs and no startup-history IDs. The healthy range passes in both builds.

Commands:

pnpm install --frozen-lockfile
pnpm exec vitest run tests/commands/emails/receiving/listen.test.ts tests/commands/emails/receiving/listen-pagination.test.ts
pnpm test
pnpm lint
pnpm typecheck
pnpm build

Behavior and limits

New arrivals during recovery are read after the pending range completes. Each poll's output keeps its existing oldest-first order. This change does not promise global chronological order across polls.

Local checks ran on macOS. The PR title check, dependency pin check and Node bundle --version/--help smoke checks also pass. Windows/Linux installer checks, native credential-store integration checks and the packaged-binary matrix are left to GitHub Actions.

The tests use controlled responses and fake credentials. No live Resend account was used. Progress is held in memory. Restart recovery, deleted boundary records and retention expiry are outside this change.


Summary by cubic

Fixes resend emails receiving listen so it no longer reports only the first 500 emails of a large burst (or of a range where a later page errors) and then gets stuck on subsequent polls. Pagination progress is now retained between polls, and output from successful pages is preserved when a later page fails.

  • Retains the next after cursor when a poll reaches the page limit and resumes the pending range on the next poll.
  • Retains a failing page's input cursor on SDK errors or page-processing throws, but drops it on not_found so the next poll starts at the newest email instead of exiting after five failures.
  • Stops a poll at the saved boundary or at any already-seen email, so a removed boundary record no longer causes older inbox history to be reported.
  • Tracks the completed range boundary separately from the seen-ID cache (capacity 10,000 IDs) so large bursts don't cause startup history to be re-reported.
  • Keeps results from successful pages when a later page fails, and retries an empty continuation page with has_more: true.
  • New arrivals during recovery are read after the pending range completes; each poll keeps its existing oldest-first output.

Each poll still reads up to five pages of 100 emails, and rate-limit and non-404 error handling are unchanged. Progress is held in memory only; restart recovery and expired/deleted boundary records are outside this change.

Written for commit 18220e9. Summary will update on new commits.

View guided diff

@github-actions github-actions Bot added the linear-synced PR has been synced to Linear label Oct 7, 2026
A poll stopped only at the saved range boundary. If that email left the
list, the next poll showed older inbox history as new. Stop at the
boundary or at the first seen ID, as main did before.

Also remove two comments that restate the code.
The API returns not_found for an unknown after cursor. The listener
retried the same cursor on each poll and exited after five failures.
Drop the cursor on not_found so the next poll starts at the newest email.
@felipefreitag
felipefreitag merged commit 0cd4678 into resend:main Oct 15, 2026
8 of 9 checks passed
@mayankbohradev
mayankbohradev deleted the fix/receiving-listener-pagination-recovery branch October 28, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

linear-synced PR has been synced to Linear

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants