Skip to content

Commit 6dae42a

Browse files
authored
fix autopagination loop on non paginated api endpoint (#182)
1 parent d0492b7 commit 6dae42a

16 files changed

Lines changed: 224 additions & 23 deletions

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ These are mistakes a competent Python developer would make if they hadn't read t
7373
- **Don't catch `httpx` errors directly.** The transport already translates them into `TFEError` subclasses. Catching `httpx.HTTPError` in a resource means the typed error never propagates.
7474
- **Always send the bearer token, even to absolute URLs returned by the API.** Endpoints like `hosted_state_download_url`, `hosted_state_upload_url`, plan `json-output`, and apply `errored-state` redirect to `archivist.terraform.io` — which is HashiCorp infrastructure that *requires* the bearer. go-tfe does the same (see `state_version.go::Download` + `tfe.go::NewRequest`). Stripping the bearer breaks downstream consumers (notably the Ansible collection's statefile + dynamic-inventory flows). `HTTPTransport.request` accepts `include_auth=False` only as an opt-out for the hypothetical case of calling a genuinely non-HashiCorp host; do not use it for Archivist URLs.
7575
- **Don't write a custom page loop.** `self._list(path, params=...)` handles pagination + non-paginated endpoints transparently. Rolling your own loop will diverge from the rest of the codebase.
76+
- **Disable pagination for endpoints that ignore page params.** A few endpoints return the *whole* collection on every request and ignore `page[number]`/`page[size]` (workspace `/vars` and `/all-vars`). Call `self._list(path, params=..., paginated=False)` for those — otherwise they infinite-loop once the collection reaches the page size (the [#181](https://github.com/hashicorp/python-tfe/issues/181) bug). See [ITERATORS.md](docs/ITERATORS.md).
7677
- **Don't reuse generators.** Iterators returned by `list_*` are single-use. If you need to traverse twice, `materialized = list(client.foo.list_bars(...))` first.
7778
- **Don't add features beyond what was asked.** This codebase is approaching v1.0.0. Adding "while I'm here" refactors or speculative abstractions slows reviews and risks breaking the Ansible collection.
7879
- **Don't assume every successful response is `{"data": ...}`.** Check the docs/go-tfe/spec for each endpoint: some return a JSON:API envelope, some return a bare resource object, `204 No Content`, `null`, raw bytes, or a redirect to a blob URL. Add tests for non-standard shapes.

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Unreleased
22

3+
## Bug Fixes
4+
5+
### Pagination
6+
* Fixed `list_*` infinite-looping for API call which are non paginated, so they are now fetched with a single request. The generic list helper also treats any response without `meta.pagination` as a single complete page, preventing the same loop on other non-paginated endpoints. [#181](https://github.com/hashicorp/python-tfe/issues/181)
7+
38

49
# Released
510
# v1.0.0

‎docs/ITERATORS.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,23 @@ def list(
5656
yield self._workspace_from(item)
5757
```
5858

59-
That's it. `self._list()` lives in `_base.py` and handles `page[number]` / `page[size]` and follow-through automatically. It is also robust to endpoints that **don't paginate** — if the response has no pagination metadata and the returned data is smaller than the requested page size, the helper just breaks after one round-trip. So you do not need a different code path for relationship reads like `GET /workspaces/{id}/tag-bindings` (single response) versus list endpoints like `GET /organizations/{org}/workspaces` (paginated). The same `for item in self._list(path): yield ...` works for both.
59+
That's it. `self._list()` lives in `_base.py` and handles `page[number]` / `page[size]` and follow-through automatically. When a response carries no `meta.pagination` block, `_list` treats it as a single complete page and stops after one round-trip — so the same `for item in self._list(path): yield ...` works for ordinary paginated endpoints (`GET /organizations/{org}/workspaces`) and for single-response relationship reads (`GET /workspaces/{id}/tag-bindings`) alike.
60+
61+
### Endpoints that ignore pagination entirely — pass `paginated=False`
62+
63+
A few HCP Terraform endpoints return the **whole** collection on every request and ignore `page[number]` / `page[size]`. The workspace **`/vars`** and **`/all-vars`** endpoints are the known ones. For these you must opt out of the page loop explicitly:
64+
65+
```python
66+
# variable.py — /vars is not paginated
67+
for item in self._list(path, params=params, paginated=False):
68+
yield self._variable_from(item)
69+
```
70+
71+
With `paginated=False`, `_list` issues exactly one request and yields every row.
72+
73+
Why this matters: skipping the flag on such an endpoint with **≥ `page_size` rows (default 100)** used to spin forever — the helper saw a "full" page, asked for page 2, got the *same* full set back (the endpoint ignored the page param), and re-yielded it, indefinitely. That was the root cause of [#181](https://github.com/hashicorp/python-tfe/issues/181). The generic "no `meta.pagination` ⇒ single page" rule now catches this as a safety net, but **still set `paginated=False`** on a known non-paginated endpoint: it documents intent and avoids sending meaningless page params.
74+
75+
> Rule of thumb: if the endpoint returns no `meta.pagination` and ignores `page[size]` (check go-tfe or the API docs — it returns the full collection in one shot), pass `paginated=False`.
6076
6177

6278
### Note on lazy validation
@@ -131,7 +147,7 @@ def list_versions(
131147

132148
`registry_module.list_versions` is the only method in the codebase that does this. Add a docstring note explaining the reason if you find yourself reaching for this pattern, so future readers don't mistake it for something to copy.
133149

134-
Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Use `self._list()` for those — that's the convention.
150+
Do **not** reach for `iter(list)` just because the endpoint is non-paginated. Use `self._list()` for those — that's the convention (with `paginated=False` if the endpoint ignores `page[size]` and returns the full set, as described above).
135151

136152
### Shape that does **not** match the convention (don't do this)
137153

@@ -186,6 +202,7 @@ Don't assert `isinstance(result, list)` against the raw return — that asserts
186202
- [ ] Every `list*` method returns `Iterator[X]`, not `list[X]` or `Iterable[X]`
187203
- [ ] `Iterator` is imported from `collections.abc`, not `typing`
188204
- [ ] The body uses the canonical `for item in self._list(path, params=params): yield ...` pattern — including for non-paginated single-shot endpoints
205+
- [ ] Endpoints that ignore `page[size]` and return the whole collection (e.g. workspace `/vars`, `/all-vars`) pass `paginated=False` to `self._list(...)` — otherwise they infinite-loop at ≥ 100 rows (see [#181](https://github.com/hashicorp/python-tfe/issues/181))
189206
- [ ] Hand-rolled `iter(materialized_list)` only appears if the method has a try/except fallback to a different endpoint (extremely rare — has a docstring note explaining why)
190207
- [ ] If a class defines `def list(...)`, later annotations in that class avoid bare `list[...]` so mypy does not resolve `list` to the method
191208
- [ ] Examples that call the method use `list(client.foo.list_bars(...))` (or stream with a `for` loop) — never assume list semantics on the bare return

‎docs/MODELS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class Run(BaseModel):
5959
Rules:
6060

6161
- **Every multi-word JSON:API attribute** gets an alias. Don't try to invent a snake_case-to-hyphen mapper — be explicit per field.
62-
- **Page params** use the JSON:API square-bracket form: `Field(None, alias="page[number]")`, `Field(None, alias="page[size]")`.
62+
- **Page params** use the JSON:API square-bracket form: `Field(None, alias="page[number]")`, `Field(None, alias="page[size]")`. Note that a few endpoints (workspace `/vars`, `/all-vars`) are not paginated and ignore these — their resource methods call `self._list(..., paginated=False)`, so a `page_size` field on those options models would be a no-op. See [ITERATORS.md](ITERATORS.md).
6363
- **Filter params** use the same convention: `Field(None, alias="filter[workspace][name]")`.
6464
- **`include`** is a comma-separated string on the wire but exposed as `list[SomeEnum] | None` in Python; the resource layer dumps options with `mode="json"` and joins the resulting values (`",".join(params["include"])`). See the `policy_set.read_with_options` pattern.
6565

‎docs/pagination.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,12 @@ for run in client.runs.list("ws-abc123", options):
8282
- `list` / `list_*` methods are lazy. If an invalid-id check is inside a
8383
generator method, the exception is raised when you iterate, not when you
8484
create the iterator.
85-
- Some relationship endpoints are not paginated by the server, but pyTFE still
86-
exposes them as iterators for a consistent public API.
85+
- Some endpoints are not paginated by the server, but pyTFE still exposes them
86+
as iterators for a consistent public API. A few — notably
87+
`variables.list()` / `variables.list_all()` (the workspace `/vars` and
88+
`/all-vars` endpoints) — return the entire collection in a single request and
89+
ignore `page_size`. You still iterate them the same way; the SDK just fetches
90+
everything in one round-trip.
8791
- A small number of older methods intentionally return concrete lists for
8892
backward compatibility. Prefer the iterator rule for new code, and check the
8993
method's return type if you are unsure.

‎src/pytfe/resources/_base.py‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any
88

99
from .._http import HTTPTransport
10+
from .._logging import logger
1011

1112

1213
def _to_int(value: Any) -> int | None:
@@ -26,9 +27,25 @@ def __init__(self, t: HTTPTransport) -> None:
2627
self.t = t
2728

2829
def _list(
29-
self, path: str, *, params: dict | None = None
30+
self, path: str, *, params: dict | None = None, paginated: bool = True
3031
) -> Iterator[dict[str, Any]]:
3132
base_params = dict(params or {})
33+
34+
# Some TFE endpoints are not paginated: they return the full collection
35+
# in a single response, ignore page[number]/page[size], and emit no
36+
# meta.pagination block (e.g. workspace /vars and /all-vars). Callers
37+
# opt those out so we issue exactly one request and never loop trying to
38+
# fetch a "next" page that re-returns the same set
39+
if not paginated:
40+
r = self.t.request("GET", path, params=base_params)
41+
json_response = r.json()
42+
if not isinstance(json_response, dict):
43+
json_response = {}
44+
data = json_response.get("data", [])
45+
if isinstance(data, list):
46+
yield from data
47+
return
48+
3249
page = int(base_params.get("page[number]", 1))
3350
while True:
3451
p = dict(base_params)
@@ -81,8 +98,17 @@ def _list(
8198
# Metadata present and indicates no next page.
8299
break
83100

84-
# Fallback for endpoints that do not return pagination metadata.
85-
page_size = int(p["page[size]"])
86-
if len(data) < page_size:
87-
break
88-
page += 1
101+
# No pagination metadata. Genuine TFE list endpoints always include
102+
# meta.pagination, so a response without it is a non-paginated
103+
# collection returned in full. Treat it as a single complete page
104+
# and stop. Re-requesting would loop forever when the endpoint
105+
# ignores page[number]/page[size] and re-returns the same full set
106+
if len(data) >= int(p["page[size]"]):
107+
logger.debug(
108+
"List endpoint %s returned a full page (%d rows) without "
109+
"pagination metadata; treating it as a single, "
110+
"non-paginated response.",
111+
path,
112+
len(data),
113+
)
114+
break

‎src/pytfe/resources/no_code_module.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,12 @@ def read_variables(
255255
if not valid_string(version):
256256
raise InvalidVersionError()
257257

258+
# module-variables is not paginated; fetch the full set in one request.
258259
path = (
259260
f"/api/v2/no-code-modules/{no_code_module_id}"
260261
f"/versions/{version}/module-variables"
261262
)
262-
for item in self._list(path):
263+
for item in self._list(path, paginated=False):
263264
attrs = item.get("attributes") or {}
264265
yield RegistryModuleVariable.model_validate(
265266
{

‎src/pytfe/resources/projects.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,9 @@ def list_effective_tag_bindings(
320320
if not valid_string_id(project_id):
321321
raise ValueError("Project ID is required and must be valid")
322322

323+
# effective-tag-bindings is not paginated.
323324
path = f"/api/v2/projects/{project_id}/effective-tag-bindings"
324-
for item in self._list(path):
325+
for item in self._list(path, paginated=False):
325326
attr = item.get("attributes", {}) or {}
326327
links = item.get("links", {}) or {}
327328
yield EffectiveTagBinding(

‎src/pytfe/resources/run_event.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ def list(
2626
params: dict[str, Any] = {}
2727
if options and options.include:
2828
params["include"] = ",".join(options.include)
29+
# The run-events endpoint is not paginated; fetch the full set in one request.
2930
path = f"/api/v2/runs/{run_id}/run-events"
30-
for item in self._list(path, params=params):
31+
for item in self._list(path, params=params, paginated=False):
3132
attrs = item.get("attributes", {})
3233
attrs["id"] = item.get("id")
3334
yield RunEvent.model_validate(attrs)

‎src/pytfe/resources/task_stage.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ def list(
7676
raise InvalidRunIDError()
7777

7878
path = f"/api/v2/runs/{run_id}/task-stages"
79-
kwargs = {"params": options.model_dump(by_alias=True)} if options else {}
79+
params = options.model_dump(by_alias=True) if options else None
8080

81-
for item in self._list(path, **kwargs):
81+
for item in self._list(path, params=params):
8282
yield self._parse_task_stage(item)
8383

8484
# Override

0 commit comments

Comments
 (0)