TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Reusable HttpClient for Faster PowerShell APIs: Timeouts, Cancellations, and Clear Failures

Creating a new HTTP connection for every API call is a hidden tax on your scripts: extra TCP handshakes, TLS negotiations, and needless memory churn. By reusing a single HttpClient instance in PowerShell, you get faster calls, lower overhead, and more predictable error handling. In this post, you’ll learn how to set up a reusable client with a sensible base configuration, apply per-call deadlines with cancellation tokens, and handle responses safely with clear logs.

Why a reusable HttpClient beats New-Object per call

Fewer handshakes, lower latency

HttpClient internally pools and reuses connections. Instead of paying for a TCP handshake and TLS negotiation every call, keep-alive connections (HTTP/1.1) and multiplexing (HTTP/2) are reused across requests. The result is consistently faster calls, especially for short-lived endpoints like /health and /items.

Avoid socket exhaustion and GC churn

Creating many HttpClient instances in quick succession can lead to socket exhaustion and high GC pressure. A single, long-lived client avoids these pitfalls. It’s thread-safe for concurrent requests, so you can share it across functions and modules (set once, use everywhere).

Predictable behavior from one place

Configuring BaseAddress, Accept headers, decompression, and a sensible default Timeout in one place means every call behaves predictably. Add per-call CancellationTokenSource for hard deadlines, and you gain fine-grained control without sprinkling boilerplate across your scripts.

A production-ready HttpClient setup in PowerShell

The following reusable pattern sets a base URI, configures headers, and enforces a default timeout. Each request uses its own CancellationTokenSource for a hard deadline. Only parse JSON after a successful status; otherwise, log clear warnings.

if (-not $script:HttpClient) {
  $handler = [System.Net.Http.HttpClientHandler]::new()
  $script:HttpClient = [System.Net.Http.HttpClient]::new($handler)
  $script:HttpClient.BaseAddress = [Uri]'https://api.example.com/'
  $script:HttpClient.Timeout = [TimeSpan]::FromSeconds(10)
  $script:HttpClient.DefaultRequestHeaders.Accept.Clear()
  $script:HttpClient.DefaultRequestHeaders.Accept.Add([System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json'))
}

$cts = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(5))
try {
  $resp = $script:HttpClient.GetAsync('items', $cts.Token).GetAwaiter().GetResult()
  $resp.EnsureSuccessStatusCode() | Out-Null
  $json = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
  $obj = $json | ConvertFrom-Json -Depth 10
  $obj | Select-Object -First 3 Name, Id
} catch {
  Write-Warning ('HTTP failed: {0}' -f $_.Exception.Message)
} finally {
  $cts.Dispose()
}

What this gives you

  • Speed: connection pooling reuses TCP/TLS sessions across calls.
  • Safety: EnsureSuccessStatusCode() stops you from parsing failed payloads.
  • Clarity: warnings surface the exact error; successful responses are parsed only after success.
  • Predictable timeouts: a 10s client-wide cap plus a per-call 5s hard deadline.

Advanced patterns, tuning, and pitfalls

Per-request headers and POSTing JSON

Configure shared defaults once, then add request-specific headers and content per call. Don’t mutate DefaultRequestHeaders mid-flight; instead, use HttpRequestMessage or per-call content objects.

# POST JSON with a per-call deadline
$payload = @{ name = 'Widget'; price = 9.95 } | ConvertTo-Json -Depth 5
$content = [System.Net.Http.StringContent]::new($payload, [System.Text.Encoding]::UTF8, 'application/json')
$reqCts = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(8))
try {
  $resp = $script:HttpClient.PostAsync('items', $content, $reqCts.Token).GetAwaiter().GetResult()
  if (-not $resp.IsSuccessStatusCode) {
    Write-Warning ("POST failed: {0} {1}" -f [int]$resp.StatusCode, $resp.ReasonPhrase)
    return
  }
  $obj = ($resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()) | ConvertFrom-Json -Depth 10
  $obj.Id
} finally {
  $content.Dispose()
  $reqCts.Dispose()
}

Timeouts vs. cancellation: use both

  • HttpClient.Timeout: a ceiling for the overall operation (DNS + connect + send + receive). Applies if no per-call token cancels first.
  • Per-call CancellationTokenSource: set hard deadlines for latency SLOs. If the token cancels first, you get a TaskCanceledException or OperationCanceledException you can log and handle.

Use a shorter per-call deadline than the global timeout to avoid “slow leak” operations that tie up connections.

Retry transient failures with backoff

Don’t blindly retry everything. Only retry transient classes like 429/5xx, and cap attempts with exponential backoff.

$maxAttempts = 3
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
  try {
    $cts = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(5))
    $resp = $script:HttpClient.GetAsync('health', $cts.Token).GetAwaiter().GetResult()

    if ($resp.IsSuccessStatusCode) { break }

    if ($resp.StatusCode -in 429, 500, 502, 503, 504) {
      $delay = [math]::Min(30, [math]::Pow(2, $attempt))
      Start-Sleep -Seconds $delay
      continue
    }

    throw "HTTP $([int]$resp.StatusCode) $($resp.ReasonPhrase)"
  } catch {
    if ($attempt -eq $maxAttempts) { throw }
    Start-Sleep -Seconds ([math]::Min(30, [math]::Pow(2, $attempt)))
  } finally {
    if ($cts) { $cts.Dispose() }
  }
}

DNS changes and long-lived clients

Long-lived clients can “pin” DNS to the original IP. In PowerShell 7+ (.NET), prefer SocketsHttpHandler with a PooledConnectionLifetime so connections refresh periodically and pick up DNS changes. In Windows PowerShell 5.1, consider occasional client recycling during maintenance windows.

# PowerShell 7+ example with SocketsHttpHandler
if (-not $script:HttpClient) {
  if ($PSVersionTable.PSEdition -eq 'Core') {
    $handler = [System.Net.Http.SocketsHttpHandler]::new()
    $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
    $handler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(5)  # refresh connections for DNS updates
    $handler.MaxConnectionsPerServer = 100
    $script:HttpClient = [System.Net.Http.HttpClient]::new($handler)
  } else {
    $handler = [System.Net.Http.HttpClientHandler]::new()
    $handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
    $script:HttpClient = [System.Net.Http.HttpClient]::new($handler)
  }
  $script:HttpClient.BaseAddress = [Uri]'https://api.example.com/'
  $script:HttpClient.Timeout = [TimeSpan]::FromSeconds(10)
}

Performance tips

  • Automatic decompression: enable gzip/deflate on the handler to reduce payload size without manual header wrangling.
  • Stream large content: for big responses, prefer ReadAsStream() to avoid loading everything in memory at once. Parse line-by-line or with a streaming JSON parser if applicable.
  • JSON depth: set -Depth reasonably when using ConvertFrom-Json to avoid truncation while keeping performance predictable.
  • Warm-up: make an initial call during startup to establish connections before latency-sensitive operations.

Security best practices

  • Always prefer HTTPS. Avoid disabling certificate validation. If you must customize validation, scope it narrowly and audit it.
  • Use least-privilege API tokens; send them as Authorization headers per request, not in the URL.
  • Sanitize logs: log status codes and correlation IDs, not secrets or full payloads.
  • Set a default timeout and per-call deadlines to prevent resource starvation from hung connections.

Operational guidance

  • Logging: log target path, method, status code, elapsed time, and a correlation ID header (e.g., X-Correlation-ID) for traceability.
  • Limits: tune MaxConnectionsPerServer for high concurrency. Start with 50–100 and measure.
  • Resilience: combine retries with jitter, deadlines, and circuit breakers (a simple “pause on repeated failures” strategy) for stability under partial outages.

Measure the win

Benchmark before and after. Use Measure-Command or Stopwatch to time a batch of calls and compute the average and p95 latency. You should see fewer long tails, faster medians, and clearer failure modes once you consolidate on a single, tuned client.

Bottom line: reuse one HttpClient, set BaseAddress, sane defaults, and per-call cancellations. Parse JSON only after success, and log concise warnings. You’ll get faster calls, lower overhead, clearer errors, and predictable timeouts—exactly what dependable API scripts need.

Explore more patterns in the PowerShell ecosystem: PowerShell Advanced Cookbook.

← All Posts Home →