Skip to content

feat: fail user list pages that omit pagination data - #39

Open
Bencheng21 wants to merge 5 commits into
mainfrom
ben/CXE-1073
Open

feat: fail user list pages that omit pagination data#39
Bencheng21 wants to merge 5 commits into
mainfrom
ben/CXE-1073

Conversation

@Bencheng21

@Bencheng21 Bencheng21 commented Aug 31, 2026

Copy link
Copy Markdown

What

HubSpot's user list endpoint can return 200 with results and no paging object, which silently truncates the list. GetUsers read that as "no next page" and the sync finished short without any signal.

This wires up the SDK's new uhttp.WithPaginationData on the user list call so a success response missing pagination data fails loudly instead.

Changes

  • go.mod: bump baton-sdk to v0.26.1-0.20260831211052-b6d70299129f (the ben/CXE-1073 branch, where WithPaginationData lives) + revendor.
  • UsersResponse keeps its paging field and implements uhttp.PaginatedResponse.
  • Client.get takes variadic DoOptions, so the pagination assertion is just another option on the normal call — no dedicated paginated-get helper.
  • GetUsers passes uhttp.WithPaginationData(&userResponse) and keeps its paging guard.

Two decisions worth reviewing

The option's argument must mirror the top level of the response body. WithPaginationData calls WithResponse(response), which unmarshals the entire body into whatever it is handed. Passing the inner &userResponse.Paging makes it look for a top-level next, find nothing, and report no pagination data — on a response that has a perfectly good cursor:

passing &userResponse.Paging -> err=FailedPrecondition: *hubspot.PaginationData reported no pagination data. status code: 200
passing &userResponse        -> err=<nil>, decoded Paging={Next:{After:50}}, results=1

Paging is a pointer, not a value. encoding/json leaves a value field zero whether the key was absent or present-and-empty, and here those mean opposite things: absent is the truncation bug, empty is a normal last page. With Paging PaginationData and != PaginationData{}, every shape of last page fails the assertion:

Body value field pointer field
"paging":{"next":{"after":"50"}} OK OK
"paging":{"next":{"after":""}} ERROR OK
"paging":{} ERROR OK
paging absent ERROR (wanted) ERROR (wanted)

The value variant errors out the final page of every sync, so the pointer is load-bearing rather than stylistic.

Tests

New pkg/hubspot/client_test.go covers three cases against httptest servers:

Response Expected
paging.next.after present returns the cursor
paging present but empty last page, no error
paging absent error wrapping uhttp.ErrMissingPaginationData

go build ./..., go vet ./..., go test ./... -count=1, and golangci-lint run ./pkg/... are all clean.

Notes for reviewers

  • go.mod points at an unmerged SDK commit. This shouldn't land until conductorone/baton-sdk ben/CXE-1073 is merged and released — then the pseudo-version here should be swapped for the tagged version.
  • Worth confirming against a real response: this assumes 2026-09-beta returns the paging object on the final page (with an empty cursor) rather than omitting it. If it omits paging when there is no next page, the last page of every sync will now fail. I could not verify this without live credentials.
  • GetDeletedUsers and GetUserLastLogin still do the old empty-Paging check. Left alone since they weren't part of the ask.

🤖 Generated with Claude Code

HubSpot's user list endpoint can return 200 with results and no `paging`
object, which silently truncates the list. GetUsers treated that as "last
page" and finished the sync short.

Use the SDK's new uhttp.WithPaginationData on the list call so a success
response without pagination data becomes an error instead. UsersResponse
now implements PaginatedResponse with Paging as a pointer, so a missing
object is distinguishable from an empty one (the real last page).

Pulls in baton-sdk from ben/CXE-1073, where WithPaginationData lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

CXE-1073

Comment thread go.mod Outdated

require (
github.com/conductorone/baton-sdk v0.26.0
github.com/conductorone/baton-sdk v0.26.1-0.20260831211052-b6d70299129f

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.

🟠 Bug: This pseudo-version pins an unmerged commit. I checked the SDK repo: b6d70299129f is the head of ben/CXE-1073 only, 3 commits ahead of main and not merged, so WithPaginationData does not exist in any released baton-sdk. If that branch is rebased or deleted the pseudo-version becomes unresolvable and go mod download breaks for anyone not building from vendor/. Hold this PR until the SDK branch merges and is tagged, then repin to the release version.

Comment thread pkg/hubspot/client.go Outdated
}

return userResponse.Results, "", annos, nil
return userResponse.Results, userResponse.Paging.Next.After, annos, 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.

🟠 Bug: This makes a load-bearing assumption that 2026-09-beta emits a paging object on the final page. HubSpot's v3 envelope convention is the opposite — paging is present only when a next cursor exists and is omitted on the last page — and this repo's own pre-existing code assumed exactly that (GetDeletedUsers line 275, and the if (userResponse.Paging != PaginationData{}) check this PR deletes). Nothing in the repo, the endpoint comment at line 31-32, or the PR description verifies the empty-paging-on-last-page shape that TestGetUsersLastPage encodes.

If the assumption is wrong, this doesn't fail loudly on truncation — it fails every sync on the last page with ErrMissingPaginationData, across user.go:147, role.go:110, and account.go:87. Please confirm the last-page response against the live endpoint and record it (a fixture or a doc link in the comment). If paging really is omitted on the last page, absence alone can't distinguish "done" from "truncated" — you'd need a signal like len(results) < limit ⇒ legitimate final page, len(results) == limit with no paging ⇒ truncation error.

Comment thread pkg/hubspot/client.go Outdated
Comment on lines 66 to 71
// HasPaginationData reports whether HubSpot returned the paging object. The
// 2026-03 endpoint omits it and silently truncates, so uhttp.WithPaginationData
// turns that into an error instead of a short sync.
func (u *UsersResponse) HasPaginationData() bool {
return u.Paging != 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.

🟡 Suggestion: The comment says "the 2026-03 endpoint omits it", but UsersResponse is only used by GetUsers, which hits listUsersURL()settings/users/2026-09-beta (line 34). As written it reads as if the guard protects a call that isn't made here. Reword to describe what's being asserted about 2026-09-beta.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: feat: fail user list pages that omit pagination data

Blocking Issues: 1 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 16a0f9bfa809.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (connector source, go.mod/go.sum, and the vendored baton-sdk and dpop updates) for security and correctness. The earlier first-page-only getUsersVars.After gate is gone, so WithPaginationData now runs on every page and that prior finding is addressed. This revision also reverts the user list endpoint from 2026-09-beta back to 2026-03 and deletes the comment documenting that 2026-03 omits paging, which contradicts the new assertion; that is the blocking issue below. Prior notes about the duplicate decode on the first page, GetDeletedUsers keeping the old value check, and the missing non-2xx test case still stand and are not re-flagged here.

Security Issues

None found.

Correctness Issues

  • pkg/hubspot/client.go:153 — high confidence: GetUsers now targets settings/users/2026-03 (base used listUsersURL, i.e. 2026-09-beta) while asserting that paging must be present; the comment removed in this PR says 2026-03 omits paging, so all three list call sites will fail with ErrMissingPaginationData or resume silently truncating. The endpoint change is not mentioned in the PR description.

Suggestions

  • pkg/hubspot/client_test.go:20 — the test server answers any path with the same body, so no test pins the list endpoint; the endpoint revert passes green.
  • go.mod:6 — the PR description still describes an unmerged-branch pseudo-version while the diff pins v0.28.0; also worth confirming go mod vendor was re-run (the vendored pkg/sdk/version.go reads v0.27.0).
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Correctness Issues

In pkg/hubspot/client.go:
- Around line 153: GetUsers was changed to call c.usersURL, which is settings/users/2026-03, and the
  listUsersURL helper for settings/users/2026-09-beta was deleted along with its comment stating
  that 2026-03 omits the paging object and silently truncates. That is combined with the new
  uhttp.WithPaginationData assertion, which errors whenever the response has no paging key.
  Restore listUsersURL (the 2026-09-beta path) and have GetUsers call it, so the strict pagination
  assertion is applied to the endpoint that actually returns paging. This affects three sync paths:
  pkg/connector/user.go:147, pkg/connector/account.go:87, and pkg/connector/role.go:110.
  If the move to 2026-03 was deliberate, document why, with evidence that 2026-03 returns a paging
  object on every page including the last, and explain the change in the PR description.

## Suggestions

In pkg/hubspot/client_test.go:
- Around line 17-25: the newTestClient handler serves the same body for every request path, so no
  test asserts which endpoint GetUsers hits. Record r.URL.Path in the handler and assert it in at
  least one test so a change to the user list endpoint fails the suite.

In go.mod:
- Around line 6: the PR description says baton-sdk is pinned to an unmerged branch pseudo-version,
  but the diff pins released v0.28.0 — update the description. Also re-run go mod vendor and
  confirm it produces no diff; the vendored pkg/sdk/version.go reports v0.27.0 under a v0.28.0
  module, and go build -mod=vendor will not detect vendored content that differs from the
  released module.

@github-actions github-actions Bot 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.

Blocking issues found — see review comments.

WithPaginationData only needs the paging envelope, not the payload
around it. Pull it into PagingResponse so UsersResponse goes back to
being a plain data model and every list endpoint can reuse one
PaginatedResponse type instead of growing its own method and pointer
field.

Both targets decode from the same buffered body, so passing the payload
to WithJSONResponse and the envelope to WithPaginationData composes
without touching the response stream twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/hubspot/client.go Outdated
Comment on lines +65 to +69
// PagingResponse decodes just the paging envelope every HubSpot list endpoint
// returns, so uhttp.WithPaginationData can assert the API actually reported a
// page without caring about the payload alongside it. Paging is a pointer so a
// missing object (the 2026-03 endpoint omits it and silently truncates) stays
// distinguishable from an empty one, which is a legitimate last page.

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.

🟡 Suggestion: The first sentence ("the paging envelope every HubSpot list endpoint returns") contradicts the parenthetical three lines down ("the 2026-03 endpoint omits it"), and it isn't true of this codebase either — GetTeams/GetRoles go through plain c.get with no paging at all, and GetDeletedUsers decodes its own Paging. Scoping the claim to the endpoint this struct is actually wired to would make the invariant the code enforces easier to trust.

Suggested change
// PagingResponse decodes just the paging envelope every HubSpot list endpoint
// returns, so uhttp.WithPaginationData can assert the API actually reported a
// page without caring about the payload alongside it. Paging is a pointer so a
// missing object (the 2026-03 endpoint omits it and silently truncates) stays
// distinguishable from an empty one, which is a legitimate last page.
// PagingResponse decodes just the paging envelope returned by the paginated
// user list endpoint, so uhttp.WithPaginationData can assert the API actually
// reported a page without caring about the payload alongside it. Paging is a
// pointer so a missing object (the 2026-03 endpoint omits it and silently
// truncates) stays distinguishable from an empty one, which is a legitimate
// last page.

@github-actions github-actions Bot 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.

Blocking issues found — see review comments.

Move the paging field back onto UsersResponse and pass the response
itself to WithPaginationData. The option unmarshals the whole body into
its argument, so the receiver has to mirror the top level of the
response -- handing it the inner paging field decodes against the wrong
level and reports "no pagination data" even for a page with a cursor.

getPaginated is gone; get takes DoOptions instead, so the pagination
assertion is just another option on the normal call. The caller's paging
guard comes back as a nil check.

Paging stays a pointer: encoding/json leaves a value field zero whether
the key was absent or empty, and with `!= PaginationData{}` every shape
of last page (absent, {}, and an empty cursor) fails the assertion,
which would error out the final page of every sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/hubspot/client.go
c.listUsersURL(),
&userResponse,
queryParams,
uhttp.WithPaginationData(&userResponse),

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.

🟡 Suggestion: the body is now unmarshaled into userResponse twice. WithPaginationData(&userResponse) calls WithResponseWithJSONResponse(&userResponse) internally, and then doRequest appends its own WithJSONResponse(resourceResponse) for the same pointer (client.go:399-401). It is harmless today (identical JSON, same target), but it doubles the decode cost of the largest response in the sync and is easy to misread as two independent decodes. Passing nil as resourceResponse here, or a short comment noting the option already populates the struct, would make the intent explicit.

@github-actions github-actions Bot 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.

Blocking issues found — see review comments.

Comment thread pkg/hubspot/client.go
}

if (userResponse.Paging != PaginationData{}) {
if userResponse.Paging != 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.

If pagination is nil we should error the connector in a way that alerts us. Maybe we need a new well known error that pages. We expect this to be here. I think it is unlikely this will ever happen again for this service. This was a massive bug in their API, that feels like they will have resolved and will not flap back and forth. We generally should be able to expect that the documented API shape can be relied upon and when it doesn't in any way, not just missing pagination that it blows up in a way we or a self healing agent is notified to go fix right away.

func WithPaginationData(response PaginatedResponse) DoOption {
return func(resp *WrapperResponse) error {
if response == nil {
return status.Error(codes.InvalidArgument, "WithPaginationData: response is 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.

Oh that's what this does! I like it. If we know we can expect pagination as a guarantee in the API, erroring like this makes sense. We should only use this in connectors we know always will have pagination data!

@github-actions github-actions Bot 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.

No blocking issues found.

Comment thread pkg/hubspot/client.go Outdated
Comment on lines +157 to +160
var options []uhttp.DoOption
if getUsersVars.After == "" {
options = append(options, uhttp.WithPaginationData(&userResponse))
}

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.

🟡 Suggestion (high confidence): gating the assertion to the first page is a defensible tradeoff — the reported truncation (2026-03 returning 50 results with no paging) shows up on page 1, and skipping pages 2+ sidesteps the unverified risk that the final page omits paging — but nothing in the code says so. Every reader (and the PR title, "fail user list pages that omit pagination data") will read this as an oversight. Please add a short comment on the if explaining that only the first response is asserted and that a mid-sync page dropping paging will still end the sync silently, so the tradeoff is a recorded decision rather than an accident.


// A 200 with results but no paging object means the API truncated the list
// without telling us. That must be an error, not a short sync.
func TestGetUsersMissingPagingErrors(t *testing.T) {

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.

🟡 Suggestion (high confidence): all three tests call GetUsers with GetUsersVars{Limit: 50}, i.e. After == "", so the new first-page-only conditional is entirely unexercised — deleting the if would leave this suite green. Add a case that passes After: "50" against a body with no paging and asserts it returns no error and an empty next page, which pins the "later pages are deliberately not asserted" behavior in place.

@github-actions github-actions Bot 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.

No blocking issues found.

@Bencheng21

Copy link
Copy Markdown
Author

I may close this one. It looks like the paginated object is omitted when the requested page is the last page.

That makes it difficult to distinguish between an API response that is missing the paginated object due to a bug and one where the object is intentionally omitted because we've reached the last page.

this new WithPaginatedData() doesn't work really well for the hubspot api.

Comment thread pkg/hubspot/client.go
annos, err := c.get(
ctx,
c.listUsersURL(),
c.usersURL(),

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.

🟠 Bug: this switches the user list back to settings/users/2026-03 (base used listUsersURL()2026-09-beta), and the comment deleted in this same PR states that 2026-03 "omits paging and silently truncates". Pairing that endpoint with the new hard WithPaginationData assertion means every list call — user sync, account grants (pkg/connector/account.go:87), role grants (pkg/connector/role.go:110) — fails with ErrMissingPaginationData, or, if paging is sometimes present, re-introduces the truncation this PR is meant to close. The endpoint change isn't mentioned in the PR description; if it wasn't intentional, restore c.listUsersURL(). (high confidence)

t.Helper()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

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.

🟡 Suggestion: the handler replies with the same body for any path, so nothing in the suite pins which endpoint GetUsers calls — that's why the 2026-09-beta2026-03 switch on client.go:153 passes green. Capture r.URL.Path in the handler and assert it in at least one case so the list endpoint is covered by tests.

Comment thread go.mod

require (
github.com/conductorone/baton-sdk v0.26.0
github.com/conductorone/baton-sdk v0.28.0

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.

🟡 Suggestion: the PR description says this pins an unmerged SDK branch pseudo-version, but the diff now pins released v0.28.0 — worth updating the description. Also worth confirming go mod vendor was re-run after the retarget: the vendored pkg/sdk/version.go reports v0.27.0 under a v0.28.0 module, and go build -mod=vendor won't catch vendored content that doesn't match the released module. (low confidence — the const may simply lag upstream releases.)

@github-actions github-actions Bot 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.

Blocking issues found — see review comments.

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.

2 participants