TB

MoppleIT Tech Blog

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

Reusable HttpClient in PowerShell: Faster, Safer, More Predictable Web Calls

When your scripts call HTTP APIs repeatedly, building a new connection for each request wastes time, increases timeouts, and clutters logs with noise. By reusing a single HttpClient per run and configuring SocketsHttpHandler for pooling and automatic decompression, you get lower latency, fewer failures, and predictable behavior. Add well-chosen timeouts, a cancellation token, and safe JSON parsing, and you have a robust foundation for dependable API automation in PowerShell.

Build a Fast, Reusable HttpClient

At the heart of reliable web calls is a single, long-lived HttpClient instance created once per execution. Under the hood, SocketsHttpHandler manages connection pooling and TLS, giving you performance and control. Configure it explicitly and reuse it.

# Create and reuse a single HttpClient per script/module run
# Pool connections, decompress responses, and set a sane lifetime.
$handler = [System.Net.Http.SocketsHttpHandler]::new()
$handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$handler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(5)  # rotate connections to avoid stale DNS
$handler.MaxConnectionsPerServer = 100                          # tune for parallelism if needed

$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromSeconds(10)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('ps-http/1.0')
$client.DefaultRequestHeaders.Accept.Add([System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json'))

Why SocketsHttpHandler

  • Connection pooling: Reuses TCP/TLS connections across requests for lower latency.
  • Automatic decompression: Saves bandwidth and speeds up transfers with gzip/deflate.
  • Configurable lifetimes: PooledConnectionLifetime rotates connections to avoid stale DNS or dead load balancer flows.
  • Fewer resources: No socket exhaustion from per-request clients.

Scope your client

Initialize your handler/client once and reuse them. In scripts or modules, keep them in $Script: scope and expose a helper function:

function Get-HttpClient {
  if (-not $Script:HttpHandler) {
    $Script:HttpHandler = [System.Net.Http.SocketsHttpHandler]::new()
    $Script:HttpHandler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
    $Script:HttpHandler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(5)
    $Script:HttpHandler.MaxConnectionsPerServer = 100
  }
  if (-not $Script:HttpClient) {
    $Script:HttpClient = [System.Net.Http.HttpClient]::new($Script:HttpHandler)
    $Script:HttpClient.Timeout = [TimeSpan]::FromSeconds(10)
    $Script:HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd('ps-http/1.0')
    $Script:HttpClient.DefaultRequestHeaders.Accept.Add([System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json'))
  }
  return $Script:HttpClient
}

Bound Your Waits and Make Failures Predictable

Time-bounded operations fail fast and consistently. Use both HttpClient.Timeout for a top-level cap and a per-call CancellationTokenSource to bound individual requests.

$client = Get-HttpClient
$uri = 'https://api.example.com/v1/items'
$cts  = [System.Threading.CancellationTokenSource]::new(12000)  # 12s per-request budget

try {
  $res = $client.GetAsync($uri, $cts.Token).GetAwaiter().GetResult()
  $res.EnsureSuccessStatusCode()
  $json = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult()
  $obj  = $json | ConvertFrom-Json -Depth 10
  $obj | Select-Object -First 3 Name, Id
}
catch [System.OperationCanceledException] {
  Write-Warning ('HTTP canceled or timed out: {0}' -f $_.Exception.Message)
}
catch [System.Net.Http.HttpRequestException] {
  Write-Warning ('HTTP request failed: {0}' -f $_.Exception.Message)
}
finally {
  $cts.Dispose()
}

Tip: OperationCanceledException can represent either your cancellation token firing or an internal timeout. Check $cts.IsCancellationRequested to tell them apart.

Log only what matters

Prefer small, structured logs: method, path, status, and duration. Don’t log bodies or secrets. Redact Authorization headers.

function Write-HttpLog {
  param(
    [string]$Method, [string]$Uri, [int]$Status, [int]$Ms
  )
  $u = [System.Uri]$Uri
  $path = if ($u.Query) { $u.AbsolutePath } else { $u.AbsolutePath }
  Write-Host ('[{0}] {1} {2}{3} - {4} in {5} ms' -f (Get-Date).ToString('s'), $Method.ToUpper(), $u.Host, $path, $Status, $Ms)
}

Safe JSON Parsing and Small Helpers

APIs aren’t always perfect. Parse JSON defensively and fall back gracefully if the payload isn’t valid JSON.

function Try-ParseJson {
  param([string]$Text)
  try {
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    return $Text | ConvertFrom-Json -Depth 20 -AsHashtable
  } catch { return $null }
}

When you need to send JSON, serialize PowerShell objects carefully and set the content type:

$payload = @{ limit = 10; filter = @{ status = 'active' } }
$json    = $payload | ConvertTo-Json -Depth 10
$content = [System.Net.Http.StringContent]::new($json, [System.Text.Encoding]::UTF8, 'application/json')

A Production-Friendly Wrapper with Retries

Bundle client reuse, timeouts, logging, and safe parsing into a single function. Add limited retries for transient failures like 429/502/503/504 with jittered backoff.

function Invoke-ReliableRequest {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [ValidateSet('Get','Post','Put','Delete','Patch','Head','Options')] [string]$Method,
    [Parameter(Mandatory)] [string]$Uri,
    [hashtable]$Headers,
    $Body,
    [int]$TimeoutMs = 12000,
    [int]$MaxRetries = 2
  )

  $client = Get-HttpClient
  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  $attempt = 0

  while ($true) {
    $attempt++
    $cts = [System.Threading.CancellationTokenSource]::new($TimeoutMs)
    $req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$Method, $Uri)

    try {
      if ($Headers) {
        foreach ($k in $Headers.Keys) {
          if ($k -ieq 'Authorization') {
            # set but remember to NEVER log the raw token
            $req.Headers.TryAddWithoutValidation($k, [string]$Headers[$k]) | Out-Null
          } else {
            $req.Headers.TryAddWithoutValidation($k, [string]$Headers[$k]) | Out-Null
          }
        }
      }

      if ($PSBoundParameters.ContainsKey('Body') -and $Method -notin @('Get','Head')) {
        $json = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 }
        $req.Content = [System.Net.Http.StringContent]::new($json, [System.Text.Encoding]::UTF8, 'application/json')
      }

      $resp = $client.SendAsync($req, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $cts.Token).GetAwaiter().GetResult()
      $status = [int]$resp.StatusCode
      $text = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()

      if (-not $resp.IsSuccessStatusCode) {
        # Transient?
        if ($status -in 429,502,503,504 -and $attempt -le $MaxRetries + 1) {
          $delay = [Math]::Min(2000 * [Math]::Pow(2, $attempt - 1), 10000) + (Get-Random -Minimum 50 -Maximum 250)
          Start-Sleep -Milliseconds [int]$delay
          continue
        }
        throw [System.Net.Http.HttpRequestException]::new("HTTP $status")
      }

      $obj = Try-ParseJson -Text $text
      $sw.Stop()
      Write-HttpLog -Method $Method -Uri $Uri -Status $status -Ms $sw.ElapsedMilliseconds
      return [pscustomobject]@{
        StatusCode = $status
        DurationMs = [int]$sw.ElapsedMilliseconds
        Headers    = $resp.Headers
        BodyRaw    = $text
        Body       = $obj
      }
    }
    catch [System.OperationCanceledException] {
      if ($attempt -le $MaxRetries + 1 -and -not $cts.IsCancellationRequested) {
        # transient cancellation from networking stack
        Start-Sleep -Milliseconds (150 + (Get-Random -Minimum 0 -Maximum 200))
        continue
      }
      throw
    }
    catch {
      if ($attempt -le $MaxRetries + 1) {
        Start-Sleep -Milliseconds (200 + (Get-Random -Minimum 0 -Maximum 300))
        continue
      }
      throw
    }
    finally {
      $req.Dispose()
      $cts.Dispose()
    }
  }
}

Use it like this:

$r = Invoke-ReliableRequest -Method Get -Uri 'https://api.example.com/v1/items?limit=3'
if ($r.Body) { $r.Body | Select-Object Name, Id } else { $r.BodyRaw }

Real-World Tips

  • Kubernetes and load balancers: Set PooledConnectionLifetime lower than the LB/NAT idle timeout (e.g., 5 minutes) to avoid stale flows.
  • Parallelism: Increase MaxConnectionsPerServer if you fire many concurrent requests, but watch API rate limits.
  • Streaming large responses: Use ResponseHeadersRead and stream to disk if payloads are huge to avoid buffering all content in memory.
  • Security: Never log Authorization headers or tokens. Don’t disable certificate validation. Prefer TLS 1.2+ (current Windows/OpenSSL defaults handle this).
  • CI/CD and automation: Parameterize TimeoutMs and endpoints via environment variables. Reuse the same client per job run to speed up pipelines.
  • Content negotiation: Set Accept: application/json and use automatic decompression for faster responses.
  • Predictability: Always call EnsureSuccessStatusCode() or explicitly handle non-2xx responses so failures are obvious and traceable.

Complete Basic Example

This simple pattern reuses a client, bounds waits with timeouts and a cancellation token, and parses JSON safely while logging only what matters.

$handler = [System.Net.Http.SocketsHttpHandler]::new()
$handler.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate
$handler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(5)

$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromSeconds(10)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('ps-http/1.0')
$client.DefaultRequestHeaders.Accept.Add([System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json'))

$uri = 'https://api.example.com/v1/items'
$cts = [System.Threading.CancellationTokenSource]::new(12000)
try {
  $res = $client.GetAsync($uri, $cts.Token).GetAwaiter().GetResult()
  $res.EnsureSuccessStatusCode()
  $json = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult()
  $obj = Try-ParseJson -Text $json
  if ($obj) {
    $obj | Select-Object -First 3 Name, Id
  } else {
    Write-Host 'Response was not valid JSON:'
    Write-Host ($json.Substring(0, [Math]::Min(400, $json.Length)))
  }
}
catch {
  Write-Warning ('HTTP failed: {0}' -f $_.Exception.Message)
}
finally {
  $cts.Dispose(); $client.Dispose()
}

With a reusable HttpClient, SocketsHttpHandler pooling and decompression, explicit timeouts, disciplined logging, and safe JSON handling, your PowerShell web calls become fast, resilient, and easy to operate.

Further reading: .NET HttpClient guidance, PowerShell ConvertFrom-Json docs, and patterns for resilient networking. Want more recipes? Explore the PowerShell Advanced Cookbook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

← All Posts Home →