UN-3770 [MISC] Follow DRF pagination on all clone list endpoints - #24
Conversation
Every list_* helper unwrapped a paginated envelope but never sent ?page
and never followed `next` — it read page one and stopped. Endpoints that
already paginate (tags/, pipeline/, api/deployment/) have therefore been
silently truncating at 50 rows, and the same would hit adapters,
connectors and prompt-studio once UN-3770 makes their pagination
unconditional. A clone that copies a subset without erroring is worse
than one that fails.
Add PlatformClient._paginate(), which short-circuits on a bare list so
the client keeps working against deployments where an endpoint is not
paginated, otherwise walks `next` to exhaustion. It refuses to return a
short read: the collected row count is checked against the reported
count, and a cyclic `next` raises instead of looping forever.
_request() is split so the absolute `next` URLs DRF emits can be issued
without going through org-relative path composition. Its signature is
unchanged, so every existing caller is untouched.
All 23 list helpers now route through it. list_lookup_versions keeps its
bespoke unwrap — that endpoint returns {"versions": [...]}, not a DRF
envelope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
Adds a paths-scoped GitHub Actions workflow that runs the clone test suite whenever clone code, its tests, or the dependency set changes. Gives a fast, dedicated signal for the pagination page-following logic in client._paginate — a silent-truncation regression there is worse than a hard failure, so it must stay guarded on every clone change. The full suite in test.yml still runs on every PR; this narrows the trigger and the run to tests/clone/ for quicker feedback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UN-3770 [CI] Add focused clone test workflow
|
| Filename | Overview |
|---|---|
| src/unstract/clone/client.py | Adds shared pagination and hardens subsequent-page payload and URL handling; the previously reported defects are addressed. |
| tests/clone/test_client.py | Adds coverage for multi-page traversal, malformed payloads and links, count mismatches, cycles, and origin pinning. |
| .github/workflows/clone-tests.yml | Adds focused Python 3.11/3.12 clone tests using immutable action commit references. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[List helper] --> B[_paginate]
B --> C[Request first page]
C --> D{Payload shape}
D -->|Bare list| E[Return rows]
D -->|DRF envelope| F[Validate results]
F --> G[Append rows]
G --> H{next present?}
H -->|No| I[Verify reported count]
H -->|Yes| J[Validate and pin URL to configured origin]
J --> K[Request next page]
K --> F
I --> E
Reviews (9): Last reviewed commit: "Wrap urlunparse args to satisfy ruff E50..." | Re-trigger Greptile
Addresses Greptile review on #24: - Validate the DRF envelope of every page, not just the first — a later page that is a bare list / non-envelope now raises PlatformAPIError instead of an incidental AttributeError on the next loop turn. - Reject a `next` link whose origin differs from the configured platform endpoint before following it, so a compromised/misconfigured response cannot forward the bearer key to another host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
… every page - _assert_same_origin compares normalised (scheme, host, port) so equivalent hosts (case, explicit default port) aren't rejected as off-site. - _results_or_raise validates results is a list on every page, so a non-list results value fails loudly instead of corrupting rows via extend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
|
Addressed both Greptile P1s in
Tests: The third finding (SHA-pin CI actions) is left as a repo-wide policy call — every workflow here uses mutable major-version tags, so pinning one file would be inconsistent. Happy to pin all workflows in a separate change if we want that policy. |
urlparse defers port parsing to attribute access, so a `next` link with a non-numeric or out-of-range port leaked a ValueError from `_origin` instead of the actionable PlatformAPIError used for every other malformed link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
|
Addressed the P1 Malformed ports escape error handling (cc0786d): The other four code findings were already resolved on c38d1ef:
Leaving the Mutable CI action references (SHA-pin) finding: |
Addresses Greptile: mutable major-version tags (checkout@v4, setup-python@v5, setup-uv@v6) could execute unreviewed code on an upstream tag move. Pinned to full commit SHAs with the version tracked in a trailing comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
|
Pinned the three
Trailing comment tracks the version for readability/dependabot. |
pk-zipstack
left a comment
There was a problem hiding this comment.
Premise and sequencing look right — the silent truncation on tags/, pipeline/ and api/deployment/ is a live bug, and fixing the client ahead of the backend flip is the correct order. The sweep is complete (29 helpers converted; list_users and list_lookup_versions correctly excluded), and the test coverage on _paginate is thorough.
Two things I'd want resolved before this merges — both are new hard-failure modes introduced by the change, on paths that previously succeeded (albeit truncated). Details inline.
Two blockers from review:
- _paginate raised "unrecognised list payload" on a 204/empty body
because None falls through to _results_or_raise. Restore the old
(result or {}).get("results", []) behaviour: an empty body returns [].
A next link that yields an empty body ends pagination; the count guard
still flags a genuine short read.
- The same-origin check compared scheme+host+port, so a TLS-terminating
proxy emitting http:// (or off-port) next links for an https:// client
aborted every paginated list. Compare host only -- the boundary the
bearer key is actually scoped to -- so the key still can't leak to
another host while legitimate proxy setups keep working.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com>
Two Greptile P1s on the previous review round: - Host-only origin check let an https client follow an http:// (or off-port) next link on the same host, putting the bearer key on the wire in plaintext or at an unrelated service. Follow the link but pin scheme+host+port to the configured base_url, keeping only the server's path+query, so the key only ever reaches the configured origin. An off-host next is still rejected. Replaces _origin/_assert_same_host with _same_origin_url. - A truthy non-string next (int/list) blew up in seen.add / urlparse with an incidental TypeError; guard it and raise PlatformAPIError. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
What & why
Part of the UN-3770 list-pagination workstream. This is step 1 of 4 and is the prerequisite for flipping the OSS list endpoints to unconditional pagination — it must be merged and released before that lands.
Every
list_*helper inPlatformClientalready unwrapped a paginated envelope:…but none of them ever sent
?pageor followednext. They read page one and stopped.That is already a live bug, not a future one.
tags/,pipeline/andapi/deployment/useCustomPaginationon the backend today, so cloning an org with more than 50 tags / pipelines / API deployments silently drops the rest — no error, no warning. The stale comment onlist_adapters("no pagination on this endpoint") is the assumption that made this easy to miss.Once unstract#2187's follow-up makes adapters, connectors, workflows and prompt-studio unconditionally paginated, the same truncation hits those too. Silent partial data loss is a far worse failure mode than a hard break, so the client has to be fixed first.
Approach
PlatformClient._paginate(path, params):nextto exhaustion.count; a mismatch raisesPlatformAPIError. This is the durable protection — any future truncation becomes loud instead of silent.nextraises rather than hanging a migration forever._request()is split into a URL-building wrapper over_send(), because DRF'snextlinks are absolute and can't go through org-relative path composition._request()'s signature is unchanged, so every existing caller is untouched.All 23 list helpers now route through
_paginate.list_lookup_versionsdeliberately keeps its bespoke unwrap — that endpoint returns{"versions": [...], "next_version_number"}, not a DRF envelope.Testing
tests/clone/test_client.py: 3 new tests — followsnextacross pages and hits the absolute URL verbatim; raises on a short read; raises on a cyclicnext. The two pre-existing tests pinning bare-list and single-page-envelope behaviour still pass unchanged, which is the backward-compatibility guarantee.ruff check+ruff formatclean.tags/already paginates in staging, solist_tagsexercises the real envelope →next→countpath end-to-end with no backend change required.Residual risk
A customer pinned to an older client version still truncates silently after the backend flip; nothing here helps them retroactively. Tracked in the rollout plan, along with the option of instrumenting bare-array responses for one release to measure who is still affected before anything breaks.
🤖 Generated with Claude Code
https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ