TB

MoppleIT Tech Blog

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

Reusable HttpClient for Reliable Web Calls in PowerShell: Timeouts, Cancellation, and Logging

Creating a single, reusable HttpClient in PowerShell dramatically improves reliability and performance for API automation. By instantiating one client per run, setting clear defaults (timeouts, headers, base URI), and using per-call cancellation tokens, you avoid socket exhaustion, cut connection latency, and gain predictable error handling. In this guide, youll learn a practical, production-ready pattern you can drop into scripts and CI jobs right away.

Why Reuse HttpClient in PowerShell

1) Fewer sockets, faster calls

HttpClient manages a connection pool behind the scenes. When you reuse a single instance, keep-alive connections can be reused across calls. That means fewer TCP and TLS handshakes, lower latency, and reduced chance of running out of ephemeral ports.

2) Predictable timeouts and deadlines

Setting a global client timeout creates a safety net for any call that forgets to specify one. Layering per-call cancellation tokens lets you set tighter SLAs for specific requests (e.g., low-latency health checks) without affecting others.

3) Clear diagnostics

Logging status codes and response times consistently from the same client makes triage faster. With a shared client, you set standard headers once (Accept, User-Agent, Authorization) for consistent server behavior and cleaner logs.

Build a Reusable HttpClient in PowerShell

Heres a minimal, robust pattern using a single client for many calls. It sets timeouts, base address, and headers once, then performs multiple requests with per-call cancellation and predictable JSON parsing.

# One client, many calls
$client = [System.Net.Http.HttpClient]::new()
try {
  # Global safety net timeout
  $client.Timeout = [TimeSpan]::FromSeconds(10)

  # Optional: prefer HTTP/2 where available (PowerShell 7+)
  if ([Version]::Parse($PSVersionTable.PSVersion.ToString()) -ge [Version]::new(7,0)) {
    $client.DefaultRequestVersion = [Version]::new(2,0)
    $client.DefaultVersionPolicy = [System.Net.Http.HttpVersionPolicy]::RequestVersionOrHigher
  }

  # Set a base URI and default headers once
  $client.BaseAddress = [Uri]::new('https://api.example.com/v1/')
  $client.DefaultRequestHeaders.Accept.Clear()
  $client.DefaultRequestHeaders.Accept.Add(
    [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json')
  )
  $client.DefaultRequestHeaders.UserAgent.ParseAdd('PSEngine/1.0')

  # Example: share the client across multiple calls
  $paths = @('status','version')
  foreach ($p in $paths) {
    # Per-call deadline (5s) using a cancellation token
    $cts = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(5))
    $res = $null
    try {
      $res = $client.GetAsync($p, $cts.Token).GetAwaiter().GetResult()
      $body = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult()

      if ($res.IsSuccessStatusCode) {
        $obj = $body | ConvertFrom-Json -Depth 10
        [pscustomobject]@{ Path = $p; Code = [int]$res.StatusCode; Name = $obj.name }
      } else {
        Write-Warning ("{0}: HTTP {1} - {2}" -f $p, [int]$res.StatusCode, $res.ReasonPhrase)
      }
    } catch {
      Write-Error ("{0}: Exception - {1}" -f $p, $_.Exception.Message)
    } finally {
      if ($res) { $res.Dispose() }
      $cts.Dispose()
    }
  }
} finally {
  $client.Dispose()
}

This simple structure already yields big wins: one client reused across calls, consistent defaults, per-call deadlines, and predictable JSON parsing.

Advanced: tune the handler for long-lived processes (PowerShell 7+)

If your script or service runs for hours or days, consider constructing HttpClient with SocketsHttpHandler. This enables decompression, DNS refresh, and connection rotation, which help avoid stale DNS entries and optimize bandwidth.

# Advanced client with SocketsHttpHandler (PowerShell 7+)
$handler = [System.Net.Http.SocketsHttpHandler]::new()
$handler.AutomaticDecompression = \
  [System.Net.DecompressionMethods]::GZip -bor \
  [System.Net.DecompressionMethods]::Deflate -bor \
  [System.Net.DecompressionMethods]::Brotli
$handler.PooledConnectionIdleTimeout = [TimeSpan]::FromMinutes(2)
$handler.PooledConnectionLifetime    = [TimeSpan]::FromMinutes(5)   # rotate to pick up DNS changes
$handler.MaxConnectionsPerServer     = 20                           # avoid overload

$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromSeconds(15)
$client.BaseAddress = [Uri]::new('https://api.example.com/v1/')
$client.DefaultRequestVersion = [Version]::new(2,0)
$client.DefaultVersionPolicy  = [System.Net.Http.HttpVersionPolicy]::RequestVersionOrHigher
$client.DefaultRequestHeaders.Accept.Add(
  [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json')
)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('PSEngine/1.0')

Tip: On Windows PowerShell 5.1 (.NET Framework), enable TLS 1.2 if needed:

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

Prefer SendAsync with HttpRequestMessage for per-call overrides

When you need to override headers or methods for a single call (e.g., a POST with JSON or a unique header), build an HttpRequestMessage and send it with the shared client.

$req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post, 'items')
$payload = @{ name = 'demo'; enabled = $true } | ConvertTo-Json -Depth 5
$req.Content = [System.Net.Http.StringContent]::new($payload, [Text.Encoding]::UTF8, 'application/json')
$req.Headers.Add('X-Correlation-Id', [guid]::NewGuid().ToString())

$cts = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(5))
$res = $null
try {
  # ResponseHeadersRead improves latency for large bodies and enables streaming
  $res = $client.SendAsync($req, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $cts.Token).GetAwaiter().GetResult()
  if ($res.IsSuccessStatusCode) {
    $text = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult()
    $obj  = $text | ConvertFrom-Json -Depth 10
    Write-Host ("Created item id={0}" -f $obj.id)
  } else {
    Write-Warning ("POST items: HTTP {0} - {1}" -f [int]$res.StatusCode, $res.ReasonPhrase)
  }
} finally {
  if ($res) { $res.Dispose() }
  $req.Dispose()
  $cts.Dispose()
}

Logging, Retries, and Deadlines

Log status codes and latency for fast triage

Consistent logs make failures obvious. Track the path, status code, and duration per call. Emit structured objects so your CI, logs, or dashboards can parse them.

function Invoke-JsonGet {
  param(
    [Parameter(Mandatory)] [System.Net.Http.HttpClient] $Client,
    [Parameter(Mandatory)] [string] $Path,
    [int] $DeadlineMs = 5000
  )

  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  $cts = [System.Threading.CancellationTokenSource]::new($DeadlineMs)
  $res = $null
  try {
    $res  = $Client.GetAsync($Path, $cts.Token).GetAwaiter().GetResult()
    $text = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult()

    [pscustomobject]@{
      Path     = $Path
      Code     = [int]$res.StatusCode
      Reason   = $res.ReasonPhrase
      Ms       = $sw.ElapsedMilliseconds
      Success  = $res.IsSuccessStatusCode
      Payload  = if ($res.IsSuccessStatusCode) { $text | ConvertFrom-Json -Depth 10 } else { $null }
    }
  } catch {
    [pscustomobject]@{
      Path    = $Path
      Code    = $null
      Reason  = $_.Exception.GetType().Name
      Ms      = $sw.ElapsedMilliseconds
      Success = $false
      Error   = $_.Exception.Message
    }
  } finally {
    $sw.Stop()
    if ($res) { $res.Dispose() }
    $cts.Dispose()
  }
}

Retry transient failures with backoff (but keep deadlines)

Short, bounded retries help with flakiness (429, 503, timeouts). Keep the total call time under a limit by combining backoff with a global deadline and per-attempt cancellation.

function Invoke-WithRetry {
  param(
    [Parameter(Mandatory)] [System.Net.Http.HttpClient] $Client,
    [Parameter(Mandatory)] [string] $Path,
    [int] $MaxAttempts = 3,
    [int] $AttemptDeadlineMs = 3000
  )

  for ($i = 1; $i -le $MaxAttempts; $i++) {
    $result = Invoke-JsonGet -Client $Client -Path $Path -DeadlineMs $AttemptDeadlineMs
    if ($result.Success) { return $result }

    if ($result.Code -in 429, 500, 502, 503, 504) {
      $delay = [Math]::Min(2000, [int][Math]::Pow(2, $i - 1) * 200) # capped backoff
      Start-Sleep -Milliseconds $delay
      continue
    } else {
      return $result
    }
  }

  return $result
}

Security and correctness tips

  • Set Authorization once on DefaultRequestHeaders for a shared token, or set per request via HttpRequestMessage. Avoid logging secrets.
  • Use ConvertFrom-Json -Depth to avoid truncated nested objects. For very large payloads, stream to disk or process incrementally.
  • Prefer ResponseHeadersRead when bodies are large; it reduces memory pressure and improves first-byte latency.
  • Bound concurrency if you fan out many requests. Use a queue or limit to keep under service rate limits and your MaxConnectionsPerServer.
  • Dispose HttpResponseMessage and CancellationTokenSource promptly to release resources.

Practical Checklist

  1. Create exactly one HttpClient per run or service instance. Dispose it on shutdown.
  2. Set a sane client-wide Timeout (e.g., 1015s) and enforce per-call CancellationToken deadlines.
  3. Establish defaults once: BaseAddress, Accept: application/json, User-Agent, optional Authorization.
  4. Log path, status code, reason, and latency for every call; keep logs structured.
  5. Retry only transient failures with short, capped backoff. Respect deadlines.
  6. For long-lived processes, use SocketsHttpHandler with AutomaticDecompression and PooledConnectionLifetime to pick up DNS changes.
  7. On Windows PowerShell 5.1, ensure TLS 1.2 and test proxies if your environment requires them.

By reusing a single HttpClient, setting explicit timeouts, applying per-call cancellation, and logging consistently, you get lower latency, fewer socket errors, and much clearer diagnostics. These patterns make your PowerShell automation fast, reliable, and production-ready.

← All Posts Home →