Chapter 26Lesson 02~220 minutes

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.

Disposable labLink headersCursorsPython clientDry-run

Learning objectives

  • Create and inspect a disposable public repository without exposing CLI credentials.
  • Compare unauthenticated public REST metadata with authenticated gh api metadata.
  • 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.
Mandatory path: GitHub.com + GitHub Free + one disposable public personal repository. GitHub CLI uses its existing authentication; no PAT, App, organization, or paid plan is required.

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))
Mutation boundary: The client does not blindly retry POST. It first enumerates all issues and searches for a durable body marker. A production client must also handle ambiguous network timeouts/5xx by re-reading state before deciding whether a create should be repeated.

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?

What proves REST inventory completeness?

What makes the marker mutation idempotent at the application level?

Why inspect GraphQL errors on every page?

A 403 response includes Retry-After. What should the client infer?

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.

Next lesson

REST API, GraphQL API, gh api, Pagination, Rate Limits, and Automation Clients: Configuration, Design Choices, and Tradeoffs

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.