REST API, GraphQL API, gh api, Pagination, Rate Limits, and Automation Clients: Guided Hands-On Workflow and Core Operations
Build a disposable API lab that compares unauthenticated and authenticated REST reads, traverses complete REST and GraphQL result sets, inspects response headers, and implements a safe dry-run-first automation client.
Learning objectives
- Create and inspect a disposable public repository without exposing CLI credentials.
-
Compare unauthenticated public REST metadata with authenticated
gh apimetadata. - Prove complete REST and GraphQL pagination using a deliberately small page size.
- Build a small client with structured errors, complete traversal, and dry-run mutation planning.
- Prove application-level duplicate protection through repeated execution.
1. Preflight: prove the actor and tools
gh --version
git --version
python --version
curl --version
gh auth status
OWNER="$(gh api -H 'X-GitHub-Api-Version: 2026-03-10' user --jq .login)"
echo "owner=$OWNER"
This exposes only the login, not the credential. PowerShell users
can assign the same command output to $OWNER; the
hosted resource model is unchanged.
2. Create deterministic disposable data
gh repo create "$OWNER/atlas-c26-api-lab" --public --clone --description "Disposable Chapter 26 API lab"
cd atlas-c26-api-lab
printf '# Chapter 26 API Lab
' > README.md
git add README.md
git commit -m "Initialize API lab"
git push -u origin HEAD
REPO="$OWNER/atlas-c26-api-lab"
for n in 1 2 3 4 5; do
gh issue create --title "inventory-$n" --body "chapter26-fixture-$n"
done
git rev-parse HEAD
gh repo view "$REPO" --json id,nameWithOwner,url,visibility,defaultBranchRef
Repository creation and issue creation are mutations, so they are isolated here. Five known issues make a page size of two visibly incomplete.
3. Authenticated versus intentionally unauthenticated REST reads
gh api is an authenticated client. Use curl without
Authorization for the public unauthenticated comparison:
curl --silent --show-error --include -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2026-03-10" "https://api.github.com/repos/$REPO" -o unauth-response.txt
sed -n '1,25p' unauth-response.txt
gh api -i -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO" --jq '{id,full_name,visibility,default_branch}'
Compare status/rate headers, but remember the two requests count against different identities/buckets. Authentication also affects visibility and permissions, not only request budget.
4. Make first-page loss visible, then repair it
# Intentionally incomplete first page
gh api -i -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/issues?state=all&per_page=2" --jq '.[] | {number,title}'
# Correct complete traversal
gh api --paginate -H "X-GitHub-Api-Version: 2026-03-10" "repos/$REPO/issues?state=all&per_page=2" --jq '.[] | {number,title,state}'
The first command returns two issues. The paginator returns five.
Note that the REST Issues endpoint can include pull requests; an
“issues only” client should deliberately exclude objects with a
pull_request field.
5. GraphQL variables and cursor pagination
gh api graphql --paginate --slurp -F owner="$OWNER" -F name="atlas-c26-api-lab" -f query='query($owner:String!, $name:String!, $endCursor:String) {
repository(owner:$owner, name:$name) {
issues(first:2, after:$endCursor, orderBy:{field:CREATED_AT,direction:ASC}) {
nodes { number title state }
pageInfo { hasNextPage endCursor }
}
}
rateLimit { cost remaining resetAt }
}' > graphql-pages.json
python - <<'PY'
import json
pages=json.load(open('graphql-pages.json', encoding='utf-8'))
items=[]
for page in pages:
if page.get('errors'):
raise SystemExit(page['errors'])
items.extend(page['data']['repository']['issues']['nodes'])
print('pages=', len(pages), 'issues=', len(items))
for item in items: print(item)
PY
The query selects only the fields needed and explicitly rejects any page that carries GraphQL errors.
6. Write a dry-run-first client
Create api_client.py. This sample deliberately uses
gh api as a subprocess so authentication stays in the
GitHub CLI credential store. The token is never printed or copied
into a source file.
#!/usr/bin/env python3
import argparse, json, subprocess
VERSION="2026-03-10"
MARKER="chapter26-idempotence-marker-v1"
def run(args, input_text=None):
p=subprocess.run(args, input=input_text, text=True, capture_output=True)
if p.returncode:
raise RuntimeError(f"rc={p.returncode}: {p.stderr[:500]}")
return p.stdout
def all_issues(repo):
raw=run(["gh","api","--paginate","--slurp",
"-H",f"X-GitHub-Api-Version: {VERSION}",
f"repos/{repo}/issues?state=all&per_page=2"])
pages=json.loads(raw)
items=[x for page in pages for x in page if "pull_request" not in x]
return pages, items
def rate_metadata():
raw=run(["gh","api","-H",f"X-GitHub-Api-Version: {VERSION}","rate_limit"])
core=json.loads(raw)["resources"]["core"]
return {k:core[k] for k in ("limit","remaining","used","reset")}
def ensure_marker(repo, items, apply):
existing=next((i for i in items if MARKER in (i.get("body") or "")), None)
if existing:
return {"action":"none","reason":"marker-exists","number":existing["number"]}
if not apply:
return {"dry_run":True,"action":"create-issue","marker":MARKER}
body=json.dumps({"title":"Chapter 26 mutation marker","body":MARKER})
created=json.loads(run(["gh","api","-X","POST",
"-H",f"X-GitHub-Api-Version: {VERSION}",
f"repos/{repo}/issues","--input","-"], body))
return {"dry_run":False,"action":"created","number":created["number"],"id":created["id"]}
p=argparse.ArgumentParser(); p.add_argument("repo"); p.add_argument("--apply",action="store_true")
a=p.parse_args()
pages,items=all_issues(a.repo)
print(json.dumps({"pages":len(pages),"issue_count":len(items),
"rate":rate_metadata(),
"mutation":ensure_marker(a.repo,items,a.apply)},indent=2))
7. Dry-run, apply once, apply again
python api_client.py "$REPO"
gh issue list --repo "$REPO" --state all --limit 100 --json number,title,state
python api_client.py "$REPO" --apply
python api_client.py "$REPO" --apply
Expected sequence: the dry-run reports a create plan and changes
nothing; the first apply creates one marker issue; the second apply
reports marker-exists and creates nothing. The desired
state is “one marker issue exists,” not “send POST once.”
8. Challenge: select the right GitHub surface
You must inventory 2,000 repositories using only name, visibility,
and default branch, then react to future repository creation. Decide
whether the best architecture is first-class gh, REST,
paginated GraphQL, polling, webhooks, or a combination. A strong
answer often uses a paginated bootstrap/reconciliation plus
event-driven change signals—not one giant query and not continuous
polling.
9. Verification and cleanup
- First REST page contained only two of five fixtures.
- REST pagination returned all five.
- GraphQL cursor traversal returned the same five and no errors.
- Dry-run changed no resource.
- Two applied runs produced exactly one marker issue.
- No credential appeared in files, Git history, or output.
gh issue list --repo "$REPO" --state open --limit 100 --json number --jq '.[].number' | while read -r n; do
gh issue close "$n" --repo "$REPO" --reason completed
done
gh repo archive "$REPO" --yes
Archiving is a safer cleanup default than deletion because it preserves lab evidence. Deletion is destructive and optional.
Knowledge check
Why does the lab use curl for the unauthenticated comparison?
Because gh api is designed to make authenticated
API requests with the configured gh identity. Curl can
intentionally omit Authorization without damaging the credential
store.
What proves REST inventory completeness?
Every server-provided rel="next" page is followed
until no next relation remains.
What makes the marker mutation idempotent at the application level?
The client reads hosted state and creates only if the durable marker is absent.
Why inspect GraphQL errors on every page?
HTTP success does not guarantee a complete GraphQL result. Partial data must not silently become policy input.
A 403 response includes Retry-After. What should the client infer?
That throttling/rate protection is likely relevant; honor Retry-After, reduce request pressure, and bound retries before assuming it is a normal authorization failure.
Summary
You built a complete safe automation loop: inspect identity, compare auth boundaries, traverse every REST/GraphQL page, normalize evidence, dry-run a desired-state change, apply once, re-run to prove convergence, and clean up.
Official references
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.