Build a Production-Style API Client
Build a small production-style PowerShell API client with encoded URIs, optional authentication injection, explicit timeout/status policy, bounded retries, pagination, redacted diagnostics, and stable output objects.
Learning objectives
- Separate pure URI/header/mapping helpers from the HTTP side-effect boundary.
- Build encoded API URIs and secret-safe request headers.
- Centralize timeout, serialization, HTTP status, and bounded retry behavior.
- Classify transient versus permanent failures explicitly.
- Paginate using Link headers with a maximum-page bound.
- Emit stable PowerShell objects and an optional JSON report from a complete mini-project.
1. A production client centralizes policy instead of copying web calls everywhere
If every script contains its own Invoke-RestMethod call, timeout, headers, authentication, retries, error handling, pagination, and logging drift apart. A small wrapper creates one stable contract for transport policy while keeping resource-specific logic separate.
This mini-project reads public GitHub repository issues. Authentication is optional; no token is required for the small anonymous lab. If a token is injected, it is never printed.
2. Separate pure helpers from side effects
| Boundary | Responsibility | Why testable |
|---|---|---|
| New-ApiUri | Combine base URI, path, and encoded query values | Pure input → output; no network. |
| New-ApiHeaders | Build non-secret/default headers and optional auth injection | Can test key presence/redaction separately. |
| Invoke-AcademyApiRequest | Own HTTP transport, timeout, retry, status policy | One side-effect boundary to mock later with Pester. |
| Convert-GitHubIssue | Map remote schema to your stable output contract | Pure transformation shields callers from API shape. |
| Get-GitHubIssueSnapshot | Own pagination and collection limits | Can be tested with a mocked transport later. |
3. Build URIs without hand-concatenating query values
function New-ApiUri {
[CmdletBinding()]
param(
[Parameter(Mandatory)][uri]$BaseUri,
[Parameter(Mandatory)][string]$Path,
[hashtable]$Query = @{}
)
$relativePath = $Path.TrimStart('/')
$builder = [System.UriBuilder]::new([uri]::new($BaseUri, $relativePath))
if ($Query.Count -gt 0) {
$pairs = foreach ($entry in $Query.GetEnumerator() | Sort-Object Name) {
$key = [uri]::EscapeDataString([string]$entry.Key)
$value = [uri]::EscapeDataString([string]$entry.Value)
"$key=$value"
}
$builder.Query = $pairs -join '&'
}
$builder.Uri
}
New-ApiUri -BaseUri 'https://api.github.com/' `
-Path 'repos/PowerShell/PowerShell/issues' `
-Query @{ state='open'; per_page=5 }4. Inject authentication without making it part of logging
function Get-RedactedHeaders {
param([Parameter(Mandatory)][hashtable]$Headers)
$copy = @{}
foreach ($key in $Headers.Keys) {
$copy[$key] = if ($key -match '^(Authorization|X-Api-Key|Cookie)$') { '<redacted>' } else { $Headers[$key] }
}
$copy
}
function New-ApiHeaders {
[CmdletBinding()]
param([string]$BearerToken)
$headers = @{
Accept = 'application/vnd.github+json'
'User-Agent' = 'DevOpsAcademy-PowerShell-Lab'
'X-Correlation-Id' = [guid]::NewGuid().Guid
}
if (-not [string]::IsNullOrWhiteSpace($BearerToken)) {
$headers.Authorization = "Bearer $BearerToken"
}
$headers
}
$headers = New-ApiHeaders -BearerToken $env:GITHUB_TOKEN
Get-RedactedHeaders -Headers $headersThe environment-variable token is only an injection example. For long-lived production credentials, use a secret manager and least-privilege token scopes.
5. Centralize timeout, serialization, HTTP status, and retries
The wrapper below uses a status-first design. HTTP error status codes are inspected explicitly. DNS/TLS/connection failures still enter catch. Only a narrow transient set is retried, and retries are bounded.
function Get-BackoffSeconds {
param([ValidateRange(1,10)][int]$Attempt, [ValidateRange(1,60)][int]$Maximum = 16)
$base = [math]::Min([math]::Pow(2, $Attempt - 1), $Maximum)
$jitter = (Get-Random -Minimum 0 -Maximum 1000) / 1000
[math]::Round([math]::Min($base + $jitter, $Maximum), 3)
}
function Get-RetryAfterSeconds {
param([string]$RetryAfter, [ValidateRange(0,60)][int]$FallbackSeconds)
if ($RetryAfter -match '^\d+$') { return [math]::Min([int]$RetryAfter, 60) }
$when = [datetimeoffset]::MinValue
if ([datetimeoffset]::TryParse($RetryAfter, [ref]$when)) {
$seconds = [math]::Ceiling(($when - [datetimeoffset]::UtcNow).TotalSeconds)
return [math]::Min([math]::Max($seconds, 0), 60)
}
$FallbackSeconds
}
function Invoke-AcademyApiRequest {
[CmdletBinding()]
param(
[Parameter(Mandatory)][uri]$Uri,
[ValidateSet('Get','Post','Put','Patch','Delete')][string]$Method = 'Get',
[hashtable]$Headers = @{},
[object]$Body,
[ValidateRange(0,5)][int]$MaxRetries = 2
)
$attempt = 0
while ($true) {
$attempt++
$params = @{
Uri = $Uri
Method = $Method
Headers = $Headers
ConnectionTimeoutSeconds = 5
OperationTimeoutSeconds = 20
SkipHttpErrorCheck = $true
StatusCodeVariable = 'statusCode'
ResponseHeadersVariable= 'responseHeaders'
ErrorAction = 'Stop'
}
if ($PSBoundParameters.ContainsKey('Body')) {
$params.Body = $Body | ConvertTo-Json -Depth 8
$params.ContentType = 'application/json'
}
Write-Verbose ("{0} {1} attempt={2} headers={3}" -f `
$Method,$Uri,$attempt,((Get-RedactedHeaders $Headers | ConvertTo-Json -Compress)))
try {
$data = Invoke-RestMethod @params
}
catch {
# DNS, TLS, proxy, and connection exceptions are not all safely retryable.
# Preserve the original error; extend this policy only for failures your API documents as transient.
throw
}
if ($statusCode -ge 200 -and $statusCode -lt 300) {
return [pscustomobject]@{
Data = $data
StatusCode = [int]$statusCode
Headers = $responseHeaders
Attempts = $attempt
}
}
$transient = [int]$statusCode -in 408,429,500,502,503,504
if (-not $transient -or $attempt -gt $MaxRetries) {
throw "HTTP $statusCode from $Method $Uri after $attempt attempt(s)."
}
$retryAfter = ($responseHeaders['Retry-After'] -join ',')
$fallback = Get-BackoffSeconds -Attempt $attempt -Maximum 8
$delay = Get-RetryAfterSeconds `
-RetryAfter $retryAfter `
-FallbackSeconds ([math]::Ceiling($fallback))
Start-Sleep -Seconds $delay
}
}6. Define retry count precisely
In this wrapper, MaxRetries = 2 means one initial attempt plus at most two retries: three attempts total. Naming this explicitly prevents off-by-one misunderstandings in incident logs and tests.
7. Parse only the protocol text that is actually text
GitHub’s REST API can expose pagination through the HTTP Link header. Regex is appropriate here because the header itself is a textual protocol field; this is different from using regex to edit JSON or YAML.
function Get-NextLink {
param([string]$LinkHeader)
if ([string]::IsNullOrWhiteSpace($LinkHeader)) { return $null }
$match = [regex]::Match($LinkHeader, '<([^>]+)>;\s*rel="next"')
if ($match.Success) { return [uri]$match.Groups[1].Value }
$null
}8. Map remote objects into a stable local output contract
A wrapper should not force callers to know every remote property. The mapping function selects the fields your automation promises to emit. It also distinguishes GitHub issues from pull requests because GitHub’s issues endpoint can include pull requests.
function Convert-GitHubIssue {
param([Parameter(Mandatory)]$InputObject)
if ($InputObject.PSObject.Properties['pull_request']) { return }
[pscustomobject]@{
Number = [int]$InputObject.number
Title = [string]$InputObject.title
State = [string]$InputObject.state
Author = [string]$InputObject.user.login
CreatedUtc = [datetime]$InputObject.created_at
Url = [string]$InputObject.html_url
}
}9. Compose transport, mapping, and pagination
function Get-GitHubIssueSnapshot {
[CmdletBinding()]
param(
[string]$Owner = 'PowerShell',
[string]$Repository = 'PowerShell',
[ValidateRange(1,5)][int]$MaxPages = 2,
[string]$BearerToken = $env:GITHUB_TOKEN
)
$base = [uri]'https://api.github.com/'
$headers = New-ApiHeaders -BearerToken $BearerToken
$next = New-ApiUri -BaseUri $base `
-Path "repos/$Owner/$Repository/issues" `
-Query @{ state='open'; per_page=10 }
for ($page = 1; $page -le $MaxPages -and $null -ne $next; $page++) {
$response = Invoke-AcademyApiRequest -Uri $next -Headers $headers
foreach ($item in @($response.Data)) {
Convert-GitHubIssue -InputObject $item
}
$next = Get-NextLink -LinkHeader ($response.Headers['Link'] -join ',')
}
}10. Verbose diagnostics explain behavior without leaking secrets
Good diagnostics include method, URI, attempt number, elapsed time, correlation ID, status, and retry decision. They do not include Authorization, cookies, request bodies containing secrets, or full credential objects. The wrapper’s Write-Verbose line redacts known secret-bearing headers before serialization.
11. The design already has unit-testable seams
Before learning Pester in Chapter 18, you can already identify deterministic tests:
New-ApiUrishould encode query values correctly.Get-RedactedHeadersshould never return a raw Authorization value.Get-BackoffSecondsshould stay within the configured bound.Get-NextLinkshould return null when no next relation exists.Convert-GitHubIssueshould emit exactly the stable properties promised by your function.
The actual HTTP call is a side-effect boundary that Pester can mock later.
12. Mini-project: collect clean API objects and optionally write JSON
$issues = @(Get-GitHubIssueSnapshot -MaxPages 1)
$issues | Select-Object Number,Title,State,Author,CreatedUtc
$lab = Join-Path ([System.IO.Path]::GetTempPath()) 'ps-ch13-api-client'
New-Item -ItemType Directory -Path $lab -Force | Out-Null
$reportPath = Join-Path $lab 'issues.json'
$issues | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $reportPath -Encoding utf8
[pscustomobject]@{
Items = $issues.Count
ReportPath = $reportPath
SHA256 = (Get-FileHash -LiteralPath $reportPath -Algorithm SHA256).Hash
}
# Inspect the report, then clean up when finished.
# Remove-Item -LiteralPath $lab -Recurse -Force13. Transient versus permanent failure is a policy decision
| Failure | Default policy in this client |
|---|---|
| DNS/TLS/proxy/connection exception | Preserve and throw by default; add retries only for documented transient exception classes. |
| 408/429/500/502/503/504 | Treat as transient and retry with Retry-After/backoff. |
| Other 4xx | Treat as permanent request/auth/permission problem; fail immediately. |
| Successful 2xx | Return data, status, headers, and attempt count. |
| API-specific rate limit using another status | Extend the policy only from that API’s documented contract. |
14. Verification checklist
- You can explain why one wrapper owns transport policy.
- You construct and encode URIs instead of concatenating untrusted query strings.
- You inject optional authentication and redact it from verbose logs.
- Your HTTP-status retries are bounded and limited to an explicit transient policy.
- You respect Retry-After delta-seconds or HTTP-date values with a safety cap.
- You paginate with a maximum-page bound and map remote objects into a stable output contract.
- The mini-project emits clean objects and an optional JSON report.
15. Common mistakes
- Copying raw
Invoke-RestMethodcalls into every script. - Logging the full headers/body to troubleshoot authentication.
- Calling every failure transient and retrying it.
- Using unbounded loops for retries or pagination.
- Returning the entire remote API object as your permanent function contract.
- Mixing formatting/UI output into the data stream.
- Making tests require live internet for helpers that could be pure functions.
16. Knowledge check
Question 1. Why create a wrapper around Invoke-RestMethod?
Question 2. What does MaxRetries=2 mean in this lesson’s wrapper?
Question 3. Why map GitHub response objects into a smaller PSCustomObject?
Question 4. What should verbose output contain about authentication?
Question 5. Which parts are easiest to unit test without network access?
17. Chapter summary and next bridge
Chapter 13 moved from packet-path reasoning to a resilient API client. You can now distinguish DNS, TCP, TLS, and HTTP failures; inspect web response objects; serialize REST payloads from PowerShell objects; inject authentication without leaking it; paginate safely; respect rate limits; and implement bounded retry policy. The durable engineering pattern is to keep protocol fundamentals visible while wrapping repetitive transport concerns behind a stable function contract.
Chapter 14 takes the same remote-boundary discipline to PowerShell Remoting, sessions, SSH, serialization, and fleet automation.
18. Authoritative 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.