Modern APIs increasingly expose GraphQL endpoints instead of REST, but shelling out to curl for a quick query is brittle: you have to hand-craft JSON bodie
Modern APIs increasingly expose GraphQL endpoints instead of REST, but shelling out to curl for a quick query is brittle: you have to hand-craft JSON bodies, remember to inject Authorization headers, and manually retry when the API rate-limits you or the network hiccups. In CI pipelines, cron jobs, and admin scripts, this leads to fragile one-liners that silently fail or spam the console.
This script provides a small, dependable CLI client that reads a .gql query file (optionally with variables), sends it to a GraphQL endpoint, and returns clean JSON on stdout. It handles bearer-token authentication, exponential backoff with jitter for transient failures, and works with both raw requests and the gql library so you can reuse it as a module.
The script is organized into four layers:
argparse) collects the endpoint URL, query file, variables file, auth token, and retry knobs.requests.Session with a pre-configured Authorization header and a Retry adapter from urllib3 for connection-level retries (DNS, connection resets).429, 5xx) and GraphQL errors for transient conditions like throttling, applying exponential backoff with jitter.data payload as pretty JSON.Two layers of retry are deliberate: urllib3 handles low-level socket failures, while the application loop handles semantic failures (rate limits, partial service degradation) that urllib3 can't reason about.
#!/usr/bin/env python3
"""
GraphQL Client with Retry and Auth
Send a GraphQL query from a file to an endpoint, with bearer-token auth,
exponential backoff retries, and JSON output on stdout.
Usage:
python3 graphql_client.py --url https://api.example.com/graphql \
--query query.gql [--variables vars.json] [--token $TOKEN]
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import random
import sys
import time
from pathlib import Path
from typing import Any
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# ---------------------------------------------------------------------------
# Configuration (env vars with sensible defaults)
# ---------------------------------------------------------------------------
DEFAULT_TIMEOUT = float(os.environ.get("GQL_TIMEOUT", "30"))
DEFAULT_MAX_ATTEMPTS = int(os.environ.get("GQL_MAX_ATTEMPTS", "4"))
DEFAULT_BACKOFF_BASE = float(os.environ.get("GQL_BACKOFF_BASE", "1.0"))
DEFAULT_BACKOFF_CAP = float(os.environ.get("GQL_BACKOFF_CAP", "30.0"))
TOKEN_ENV_VAR = os.environ.get("GQL_TOKEN_ENV", "GQL_TOKEN")
log = logging.getLogger("graphql_client")
class GraphQLError(RuntimeError):
"""Raised when the GraphQL endpoint returns errors in the response body."""
class GraphQLClient:
"""Minimal GraphQL client with auth, retries, and backoff."""
def __init__(
self,
url: str,
token: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
backoff_base: float = DEFAULT_BACKOFF_BASE,
backoff_cap: float = DEFAULT_BACKOFF_CAP,
extra_headers: dict[str, str] | None = None,
) -> None:
self.url = url
self.token = token
self.timeout = timeout
self.max_attempts = max_attempts
self.backoff_base = backoff_base
self.backoff_cap = backoff_cap
# Session with connection-level retries (DNS, resets, 502/503/504).
self.session = requests.Session()
retry = Retry(
total=2,
backoff_factor=0.5,
status_forcelist=(502, 503, 504),
allowed_methods=frozenset(["POST"]),
raise_on_status=False,
)
self.session.mount("https://", HTTPAdapter(max_retries=retry))
self.session.mount("http://", HTTPAdapter(max_retries=retry))
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "graphql-client/1.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
if extra_headers:
headers.update(extra_headers)
self.session.headers.update(headers)
def _sleep_backoff(self, attempt: int, retry_after: str | None = None) -> None:
"""Exponential backoff with jitter; honor Retry-After when present."""
if retry_after and retry_after.isdigit():
delay = float(retry_after)
else:
delay = min(self.backoff_cap, self.backoff_base * (2 ** attempt))
delay += random.uniform(0, delay * 0.25) # jitter
log.warning("Retrying in %.2fs (attempt %d/%d)", delay, attempt + 1, self.max_attempts)
time.sleep(delay)
def execute(
self,
query: str,
variables: dict[str, Any] | None = None,
operation_name: str | None = None,
) -> dict[str, Any]:
"""Execute a GraphQL operation and return the parsed JSON response."""
payload: dict[str, Any] = {"query": query}
if variables:
payload["variables"] = variables
if operation_name:
payload["operationName"] = operation_name
last_exc: Exception | None = None
for attempt in range(self.max_attempts):
try:
resp = self.session.post(self.url, json=payload, timeout=self.timeout)
# Application-level retry for rate limiting / server errors.
if resp.status_code in (429, 500, 502, 503, 504):
if attempt < self.max_attempts - 1:
self._sleep_backoff(attempt, resp.headers.get("Retry-After"))
continue
resp.raise_for_status()
# Non-retryable HTTP error (401, 403, 404, 400...).
if resp.status_code >= 400:
raise requests.HTTPError(
f"HTTP {resp.status_code}: {resp.text[:500]}", response=resp
)
try:
body = resp.json()
except ValueError as exc:
raise GraphQLError(f"Non-JSON response: {resp.text[:500]}") from exc
# GraphQL errors may be partial (data + errors) or fatal.
if "errors" in body:
messages = "; ".join(
e.get("message", "unknown") for e in body["errors"]
)
# Throttling is often surfaced as a GraphQL error, not HTTP 429.
if "throttl" in messages.lower() and attempt < self.max_attempts - 1:
self._sleep_backoff(attempt)
continue
raise GraphQLError(messages)
return body
except (requests.ConnectionError, requests.Timeout) as exc:
last_exc = exc
if attempt < self.max_attempts - 1:
self._sleep_backoff(attempt)
continue
raise
# Should be unreachable, but keeps type checkers happy.
raise RuntimeError(f"Exhausted retries: {last_exc}")
def load_text(path: Path) -> str:
"""Read a text file, raising a friendly error if missing."""
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")
return path.read_text(encoding="utf-8")
def load_json(path: Path) -> dict[str, Any]:
"""Read a JSON file and validate that it's an object."""
data = json.loads(load_text(path))
if not isinstance(data, dict):
raise ValueError(f"Variables file must contain a JSON object: {path}")
return data
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="GraphQL client with retry and bearer-token auth.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--url", required=True, help="GraphQL endpoint URL")
p.add_argument("--query", required=True, type=Path, help="Path to .gql query file")
p.add_argument("--variables", type=Path, help="Optional JSON file with variables")
p.add_argument(
"--token",
default=os.environ.get(TOKEN_ENV_VAR) or os.environ.get("GQL_TOKEN"),
help=f"Bearer token (defaults to ${TOKEN_ENV_VAR})",
)
p.add_argument("--operation-name", help="GraphQL operationName if query has multiple")
p.add_argument("--header", action="append", default=[], metavar="K:V",
help="Extra header, repeatable (e.g. --header X-Tenant:acme)")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
p.add_argument("--max-attempts", type=int, default=DEFAULT_MAX_ATTEMPTS)
p.add_argument("--backoff-base", type=float, default=DEFAULT_BACKOFF_BASE)
p.add_argument("--backoff-cap", type=float, default=DEFAULT_BACKOFF_CAP)
p.add_argument("--raw", action="store_true", help="Print full response envelope, not just data")
p.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
return p
def parse_headers(pairs: list[str]) -> dict[str, str]:
headers: dict[str, str] = {}
for item in pairs:
if ":" not in item:
raise ValueError(f"Header must be K:V, got {item!r}")
k, v = item.split(":", 1)
headers[k.strip()] = v.strip()
return headers
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
try:
query = load_text(args.query)
variables = load_json(args.variables) if args.variables else None
extra_headers = parse_headers(args.header)
except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc:
log.error("Input error: %s", exc)
return 2
client = GraphQLClient(
url=args.url,
token=args.token,
timeout=args.timeout,
max_attempts=args.max_attempts,
backoff_base=args.backoff_base,
backoff_cap=args.backoff_cap,
extra_headers=extra_headers,
)
try:
result = client.execute(query, variables, args.operation_name)
except GraphQLError as exc:
log.error("GraphQL error: %s", exc)
return 3
except requests.HTTPError as exc:
log.error("HTTP error: %s", exc)
return 4
except requests.RequestException as exc:
log.error("Network error after retries: %s", exc)
return 5
output = result if args.raw else result.get("data", result)
json.dump(output, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())
| Variable / Flag | Default | Purpose |
|---|---|---|
GQL_TOKEN |
(unset) | Bearer token sent as Authorization: Bearer <token>. The --token flag overrides this. |
GQL_TOKEN_ENV |
GQL_TOKEN |
Name of an alternative env var to read the token from (useful for multi-tenant setups). |
GQL_TIMEOUT / --timeout |
30 |
Per-request timeout in seconds. |
GQL_MAX_ATTEMPTS / --max-attempts |
4 |
Total attempts including the first one. |
GQL_BACKOFF_BASE / --backoff-base |
1.0 |
Base seconds for exponential backoff (base * 2^attempt). |
GQL_BACKOFF_CAP / --backoff-cap |
30.0 |
Maximum delay per retry, in seconds. |
--url |
(required) | GraphQL endpoint. |
--query |
(required) | Path to a .gql file containing the query text. |
--variables |
(none) | Optional JSON file with query variables. |
--header K:V |
(none) | Extra header; repeatable. |
--raw |
off | Print the full response envelope (including errors) instead of just data. |
Keep tokens out of shell history by exporting GQL_TOKEN in your environment or sourcing a .env file before running.
1. Simple query with a bearer token from the environment:
export GQL_TOKEN="ghp_xxxxxxxxxxxx"
python3 graphql_client.py --url https://api.github.com/graphql --query viewer.gql
Expected output:
{
"viewer": {
"login": "octocat",
"name": "The Octocat"
}
}
2. Query with variables passed via JSON file:
cat > vars.json <<'EOF'
{ "owner": "python", "name": "cpython" }
EOF
python3 graphql_client.py \
--url https://api.github.com/graphql \
--query repo.gql \
--variables vars.json \
--header "X-GitHub-Api-Version:2022-11-28"
Expected output (truncated):
{
"repository": {
"stargazerCount": 61000,
"defaultBranchRef": { "name": "main" }
}
}
3. Debug mode with a shorter retry budget against a flaky endpoint:
GQL_MAX_ATTEMPTS=6 GQL_BACKOFF_BASE=0.5 \
python3 graphql_client.py -v \
--url https://staging.example.com/graphql \
--query slow_query.gql \
--timeout 10
Expected output includes retry logs on stderr, then JSON on stdout:
2024-05-01 12:00:01 WARNING graphql_client: Retrying in 0.61s (attempt 2/6)
2024-05-01 12:00:04 WARNING graphql_client: Retrying in 1.22s (attempt 3/6)
{ "data": { ... } }
2.requests.HTTPError, exit code 4. The response body is included in the log for debugging.max_attempts with exponential backoff; Retry-After header is honored when present.errors in the response → raised as GraphQLError, exit code 3. If the message contains "throttl", it is treated as retryable.5.GraphQLError with a truncated body snippet.The exit codes are stable so you can branch on them in shell scripts: 0 success, 2 input error, 3 GraphQL error, 4 HTTP error, 5 network error.
requests_oauthlib session that refreshes the access token on 401 before retrying.--output -/--output file.json and pipe results into downstream tooling (jq, Kafka, S3)..gql file with a directory of named operations and a --operation flag, mirroring Apollo's persisted-query workflow.