Writing August 14, 2026 4 min read

X-Files: The Single-Page Azure Reservations API

I often do ad-hoc reporting from Azure, and this time was no different: list every active Reserved VM Instance in a couple of tenants, page through the results, write CSV rows. No scheduling, no UI, one file. I didn’t know that it would take me days of debugging and a few tickets with Azure support: even though we have thousands of reservations, the SDK always returned the same 50.

I’ve tried a bunch of stuff, but still the outcome is the same, a CSV that is quietly, confidently incomplete. And not one of them ever returned an error. Every one arrived as HTTP 200, exit code 0, no warnings.

Back to page 1

$skiptoken and take on Microsoft.Capacity/reservations are declared "type": "number", "format": "float" in the published Swagger spec. They are offsets. Offsets are integers. As of today that’s still what the spec says.

So the generated SDK dutifully serialises skiptoken=50 as "50.0", and the service dutifully parses "50.0" as 0. Over raw REST, $skiptoken=50 returns a page with zero overlap with offset 0; $skiptoken=50.0 returns offset 0 again. Every iteration of the loop refetches page one, forever, at HTTP 200.

The fix was a raw query-parameter override that the serialiser never touches.

# Looks correct. Sends skiptoken=50.0, which the service reads as 0.
client.reservation.list_all(selected_state=SELECTED_STATE, take=PAGE, skiptoken=offset)

# Actually correct. Note what is absent: no take=, no skiptoken=. The request builder
# only writes those keys when the kwarg isn't None, so omitting them lets the
# override through. Pass either one as well and the serialised float wins again.
client.reservation.list_all(
    selected_state=SELECTED_STATE,
    params={"$skiptoken": str(int(offset)), "take": str(int(PAGE))},
)

So the workaround for the spec bug is load-bearing (← As you can see, the first draft of this post was by Opus 5. Thanks for outing me bro.) on an implementation detail of the generator that reads the spec.

The Cap

An earlier commit of mine Claude set PAGE = 50, with a comment stating in total confidence that the service caps a page at 50 rows regardless of a larger take — then reasoning onward: a PAGE above the cap makes every page look short, fires the end-of-stream probe every iteration, doubles the call count. Airtight. Keep it at or below 50.

That comment was wrong. take=100 returns 100 rows. take=200 returns 200 rows. take=100.0 returns 50 though.

There is no cap at 50. What I Claude had measured was the float defect.

Nobody documents anything anymore

There is a cap, though. It just isn’t counted in rows, and you can’t dodge it by not paging: omit take and the service returns the entire collection in one response, which azure-cli#25638 is still open asking it to stop doing.

take=1000 came back with 931 items in about 1511 KiB, and no nextLink, presented as a complete result. At ~1.66 KiB per row for my column set, truncation lands near 900 rows, so the tool uses PAGE = 500 — about 830 KiB — and a full run at that size saw zero truncations.

Two consequences. Page size is a function of your column set, not of the API — add columns and re-measure. And worse: the server also omits nextLink on the last real page, so truncation and end-of-stream are indistinguishable at the moment they arrive. The only signal I trust now is a confirmed-empty page — zero rows is a hypothesis, so I send the same request again to check it.

The Actual Point

The point is that all of this returned HTTP 200. Why?

A 500 error stops you, it dates itself, it usually names the thing that broke. A wrong 200 sends me on a wild goose chase.