Chapter 13Lesson 05~190 minutes

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.

BeginnerAPI ClientResilienceDevOps

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

BoundaryResponsibilityWhy testable
New-ApiUriCombine base URI, path, and encoded query valuesPure input → output; no network.
New-ApiHeadersBuild non-secret/default headers and optional auth injectionCan test key presence/redaction separately.
Invoke-AcademyApiRequestOwn HTTP transport, timeout, retry, status policyOne side-effect boundary to mock later with Pester.
Convert-GitHubIssueMap remote schema to your stable output contractPure transformation shields callers from API shape.
Get-GitHubIssueSnapshotOwn pagination and collection limitsCan 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 $headers

The 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.

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-ApiUri should encode query values correctly.
  • Get-RedactedHeaders should never return a raw Authorization value.
  • Get-BackoffSeconds should stay within the configured bound.
  • Get-NextLink should return null when no next relation exists.
  • Convert-GitHubIssue should 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 -Force

13. Transient versus permanent failure is a policy decision

FailureDefault policy in this client
DNS/TLS/proxy/connection exceptionPreserve and throw by default; add retries only for documented transient exception classes.
408/429/500/502/503/504Treat as transient and retry with Retry-After/backoff.
Other 4xxTreat as permanent request/auth/permission problem; fail immediately.
Successful 2xxReturn data, status, headers, and attempt count.
API-specific rate limit using another statusExtend 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-RestMethod calls 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.

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