Modules/businessdev.ALbuild.Marketplace/Private/Invoke-BcIngestionRestMethod.ps1

function Invoke-BcIngestionRestMethod {
    <#
    .SYNOPSIS
        Sends a body-bearing write (PUT/POST) to the Partner Center Product Ingestion API the way the
        proven BcContainerHelper implementation does, and surfaces the real error on failure.
    .DESCRIPTION
        The submission flow originally called Invoke-RestMethod inline with a raw JSON *string* body and
        Content-Type 'application/json'. Microsoft's proven AppSource ingestion client
        (BcContainerHelper's Invoke-IngestionApiRestMethod) instead sends the body as **UTF-8 bytes** with
        Content-Type 'application/json; charset=utf-8', always carries the resource ETag in the If-Match
        header, and retries transient 5xx/429 responses. A raw-string body of a real product resource -
        whose properties include localised / free-text fields - can be mis-encoded by the host into
        malformed JSON that the ingestion API rejects with a bare "400 Bad Request". This helper aligns the
        mechanics to the proven client and, on failure, appends the Partner Center response body (via
        Get-BcMarketplaceErrorDetail) so the actual reason is logged instead of just the status code.
    .PARAMETER Uri
        The full ingestion API URI.
    .PARAMETER Method
        The HTTP method (Put/Post/Get/Delete).
    .PARAMETER Headers
        Base request headers (Authorization etc.). Cloned; Content-Type/If-Match are set here.
    .PARAMETER Body
        The request body: a hashtable/PSObject (serialised to JSON here) or a pre-serialised JSON string.
        Omit for methods without a body.
    .PARAMETER ETag
        The resource's '@odata.etag'; sent as the If-Match header when present.
    .PARAMETER Operation
        A short human description used in the thrown error and retry logs (e.g. 'update the product version').
    .OUTPUTS
        The deserialised API response (whatever Invoke-RestMethod returns).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Uri,
        [Parameter(Mandatory)] [ValidateSet('Get', 'Post', 'Put', 'Delete')] [string] $Method,
        [Parameter(Mandatory)] [hashtable] $Headers,
        [object] $Body,
        [string] $ETag,
        [string] $Operation = 'the ingestion request'
    )

    $h = $Headers.Clone()
    $params = @{ Uri = $Uri; Method = $Method; Headers = $h; ErrorAction = 'Stop' }
    if ($null -ne $Body) {
        # UTF-8 bytes + explicit charset: exactly what the proven BcContainerHelper client sends. Passing a
        # raw string here can be mis-encoded and produce JSON the ingestion API rejects with 400.
        $h['Content-Type'] = 'application/json; charset=utf-8'
        $json = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 }
        $params['Body'] = [System.Text.Encoding]::UTF8.GetBytes($json)
    }
    if ($ETag) { $h['If-Match'] = $ETag }

    $attempt = 0
    while ($true) {
        $attempt++
        try { return Invoke-RestMethod @params }
        catch {
            # StrictMode-safe: reference `.Response` via PSObject so an exception type WITHOUT that member
            # (anything other than WebException/HttpResponseException) reads as $null instead of throwing
            # PropertyNotFoundException. Fall back to parsing the status out of the message ("(400)").
            $status = 0
            $respProp = $_.Exception.PSObject.Properties['Response']
            if ($respProp -and $respProp.Value) { try { $status = [int] $respProp.Value.StatusCode } catch { $status = 0 } }
            if (-not $status -and "$($_.Exception.Message)" -match '\((\d{3})\)') { $status = [int] $Matches[1] }

            # Transient server-side failures: retry a few times with backoff (mirrors the proven client).
            if ($status -in @(429, 500, 502, 503, 504) -and $attempt -le 5) {
                Write-ALbuildLog -Level Warning "HTTP $status while trying to $Operation; retry $attempt/5 in $($attempt * 5)s..."
                Start-Sleep -Seconds ($attempt * 5)
                continue
            }

            # Surface the Partner Center response body - Invoke-RestMethod only exposes the status line.
            $detail = Get-BcMarketplaceErrorDetail -ErrorRecord $_
            throw "Failed to $Operation ($($_.Exception.Message))$(if ($detail) { " | Partner Center response: $detail" })."
        }
    }
}