feat(uhttp): add WithPaginationData DoOption - #1116
Conversation
Adds an opt-in DoOption for endpoints whose pagination a connector depends on. The connector's response (or a dedicated pagination struct) implements PaginatedResponse; the option decodes the JSON body into it like WithJSONResponse does, then fails the request if HasPaginationData reports false. Without this, an API that stops returning its cursor produces a 200 that decodes cleanly and a sync that silently ends after the first page. The error matches ErrMissingPaginationData via errors.Is and carries a FailedPrecondition code. Non-2xx responses are skipped so the HTTP error isn't buried under a pagination error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| return WrapErrors( | ||
| codes.FailedPrecondition, | ||
| fmt.Sprintf("%T reported no pagination data. status code: %d", response, resp.StatusCode), | ||
| ErrMissingPaginationData, | ||
| ) |
There was a problem hiding this comment.
🟡 Suggestion: codes.FailedPrecondition makes this failure non-retryable. pkg/retry/retry.go:61 only retries Unavailable and DeadlineExceeded, so a single transient omission of the pagination object aborts the whole sync action (pkg/sync/parallel_syncer.go:288) instead of retrying the page. The failure this option targets — an API that intermittently drops its cursor — is often transient; codes.Unavailable would still be loud but let the existing syncer retry loop recover. Worth stating the intent either way, since the code choice silently picks the retry policy.
There was a problem hiding this comment.
yeah, this error needs investigation, probably needs oncall to take a look at.
non-rety is correct
| if response == nil { | ||
| return status.Error(codes.InvalidArgument, "WithPaginationData: response is nil") | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: this guard only catches an untyped nil. The likelier misuse — a typed nil pointer (var p *MyResp; WithPaginationData(p)) or a non-pointer value target — produces a non-nil interface, so it falls through to json.Unmarshal, which returns InvalidUnmarshalError wrapped as failed to unmarshal json response. No crash, but the connector gets a decode error instead of the InvalidArgument this guard is meant to give. A reflect.ValueOf(response) pointer/IsNil check (or a doc note that the target must be a non-nil pointer) would close the gap.
There was a problem hiding this comment.
This is used to detect missing pagination data. Connectors are responsible for opting in to it.
| var body string | ||
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Set(ContentType, applicationJSON) | ||
| w.WriteHeader(http.StatusOK) | ||
| _, _ = w.Write([]byte(body)) | ||
| })) |
There was a problem hiding this comment.
🟡 Suggestion: body is written by the test goroutine (lines 140/143) and read by the httptest handler goroutine with no synchronization between them. Client-side completion of Do establishes no happens-before edge with the server's handler goroutine, so this is an unsynchronized read/write that -race can flag. Guard it with an atomic.Pointer[string]/mutex, or serve the two bodies from distinct paths.
Decode via WithResponse instead of WithJSONResponse, so the option dispatches on content type and covers XML APIs as well as JSON. JSON behavior is unchanged: WithResponse delegates to WithJSONResponse for a JSON content type. Body-level pagination was previously JSON-only -- XML connectors decoded with WithXMLResponse and drove the bag by hand, with no protection against an API silently dropping its cursor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
General PR Review: feat(uhttp): add WithPaginationData DoOptionBlocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0 Review SummaryThe new commit removes the Risk triage (per Security IssuesNone found. Correctness IssuesNone found. Suggestions
Prompt for AI agents |
cca14c6 to
ac7e916
Compare
| func TestWithPaginationData_SkipsErrorResponses(t *testing.T) { | ||
| for _, statusCode := range []int{http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusFound} { | ||
| var target pagedResponse | ||
| resp := newPaginationResponse(statusCode, applicationJSON, `{"message":"nope"}`) | ||
| require.NoError(t, WithPaginationData(&target)(resp), "status %d", statusCode) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion (confidence: high): this only exercises pagedResponse, so nothing covers the documented contract that ParsePaginationHeaders "is called only for successful responses" (pagination.go:122). A connector's parser that assumes a 2xx shape — or one that mutates state it expects to be discarded — would run on 429/500 bodies without any test catching the regression. Adding headerPaged to this loop with an assertion that ParsePaginationHeaders was never invoked (e.g. a called bool on the fixture) closes it cheaply.
ac7e916 to
0b732ab
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| return err | ||
| } | ||
|
|
||
| if !response.HasPaginationData() { |
There was a problem hiding this comment.
🟡 Suggestion (confidence: high on the behavior): this check is only sound if the target is zero-valued for every request. json.Unmarshal leaves a field untouched when the key is absent, so a target declared outside the page loop (var page Response before for { ... cli.Do(req, WithPaginationData(&page)) }) keeps the previous page's non-nil Pagination pointer, HasPaginationData() returns true, and the exact silent truncation this option exists to catch goes undetected. The option can't reset the target through the interface, so this needs to be a stated precondition — allocate a fresh target per request.
There was a problem hiding this comment.
hm. this seems like a connector bug if this happens. It looks to me the user/connector should be responsible for it. Similar to a global variable and local variable when we write go code. right? @kans
| type PaginatedResponse interface { | ||
| HasPaginationData() bool | ||
| } | ||
|
|
||
| var ErrMissingPaginationData = errors.New("uhttp: response is missing pagination data") | ||
|
|
||
| func WithPaginationData(response PaginatedResponse) DoOption { |
There was a problem hiding this comment.
🟡 Suggestion: the final commit (b6d7029) strips the doc comments off three new exported SDK symbols, so PaginatedResponse, ErrMissingPaginationData, and WithPaginationData ship to connector authors with no godoc at all. The non-obvious contracts — implement HasPaginationData as presence, not "has a next page" (otherwise every sync fails on its last request), non-2xx responses are skipped so pair with WithErrorResponse, and the fresh-target requirement above — currently exist only in the PR description, which is not visible from an IDE or pkg.go.dev. Restoring those comments on the exported surface is the difference between a self-documenting SDK contract and a footgun.
| return status.Error(codes.InvalidArgument, "WithPaginationData: response is nil") | ||
| } | ||
|
|
||
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
There was a problem hiding this comment.
nit: I would use constants
| if response == nil { | ||
| return status.Error(codes.InvalidArgument, "WithPaginationData: response is nil") | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: this response == nil guard misses a typed nil — WithPaginationData((*myResponse)(nil)) still carries a type in the interface, so it passes here and instead surfaces as failed to unmarshal json response: json: Unmarshal(nil *myResponse) rather than InvalidArgument. No panic (json/xml always reject a nil pointer, so HasPaginationData() is never reached), so this is error quality only — but unmarshalXMLToMap in the same package added an explicit guard for exactly this class at wrapper.go:283, and matching it here would keep the two consistent. Confidence: medium.
| if !rv.Elem().IsZero() { | ||
| return status.Errorf(codes.InvalidArgument, "WithPaginationData: %T must be zero-valued. allocate a fresh response per request", response) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion (confidence: high on the behavior): this zero-value check makes the option order-dependent when the target is shared with another decode option, contradicting the PR description's "order-independent relative to other DoOptions". Do(req, WithJSONResponse(&page), WithPaginationData(&page)) fails with must be zero-valued because Do runs options in slice order (wrapper.go:574-580) and the first one already decoded into page; reversing the two works. The message ("allocate a fresh response per request") also misattributes that case — the caller did allocate fresh. Worth extending the doc comment on line 116 to say the target must not be shared with another decode option, since the pre-existing guidance is to pair this with WithJSONResponse/WithXMLResponse.
Callers that declare the response outside the page loop are no longer rejected; only nil and non-pointer responses are. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| var ErrMissingPaginationData = errors.New("uhttp: response is missing pagination data") | ||
|
|
||
| // WithPaginationData decodes the body into response and fails the request if its pagination data is absent, so an API that silently drops its cursor errors instead of ending the sync after one page. | ||
| // response must be a non-nil pointer, since the body is decoded into it. |
There was a problem hiding this comment.
🟡 Suggestion (confidence: high on behavior, medium on whether it needs a change): dropping the guard is the right call — it made the option order-dependent when sharing a target with another decode option — but this commit also strips the only statement of the fresh-target contract. With reuse (var page Resp outside the page loop), json.Unmarshal leaves an absent pagination key untouched and xml.Unmarshal appends to slices, so HasPaginationData() reports the previous page's cursor as present and the option silently passes on exactly the dropped-cursor response it exists to catch. Since the contract is now the caller's responsibility, please keep it in the godoc, e.g. "allocate a fresh response per request: a target reused across pages retains the prior page's pagination data and defeats this check."
What
Adds an opt-in
DoOptionfor endpoints whose pagination a connector depends on.The connector's response type — or a dedicated pagination struct — implements a new SDK interface:
WithPaginationDatadecodes the body into that target viaWithResponse(JSON or XML, dispatched on content type), then fails the request ifHasPaginationData()reports false.Why
Today, an API that stops returning its cursor produces a 200 that decodes cleanly into a struct with a zero-valued pagination field. The connector sees no next page, and the sync ends after the first page — no error, just missing data. This makes that failure loud for connectors that opt in.
This also gives XML connectors their first body-level pagination support.
WithNextLinkPaginationonly reads response headers, so XML APIs paginating in the body decoded withWithXMLResponseand drove the bag by hand, with nothing guarding against the same silent truncation.Scope
Body-sourced pagination only. An API whose cursor lives in a response header is not covered here beyond the existing
WithNextLinkPagination(which handlesLinkwithrel="next"and nothing else); that gap is worth a follow-up.Notes for reviewers
ErrMissingPaginationDataviaerrors.Isand carries aFailedPreconditiongRPC code (both, via the existingWrapErrorshelper).WithErrorResponse.DoOptions. Use it alone on the full response struct, or alongsideWithJSONResponse/WithXMLResponsewith a separate pagination-only target.HasPaginationDataoff a non-empty cursor string fails the last request of every sync. Key it off presence instead — a pointer field that is nil only when the API omitted the object. Covered by a test; works the same for both encodings, sinceencoding/xmlalso leaves a pointer field nil when the element is absent.xmltags. Amap[string]anytarget will not work:WithResponseroutes XML toxml.Unmarshal, which cannot decode into a map — onlyWithAlwaysXMLResponseandWithGenericResponsego through thexmlMapdecoder.WithNextLinkPaginationis untouched.Testing
go test ./pkg/uhttp/...passes. New cases cover, for JSON: cursor present, last page (present-but-empty cursor), missing pagination on a 200, error-response skip, nil target, and end-to-end throughDo; for XML: cursor present and missing pagination; plus a content type that is neither, asserting it errors without being misreported as missing pagination.🤖 Generated with Claude Code