Private/Get-SPCGraphSiteUsageInternal.ps1

function Get-SPCGraphSiteUsageInternal {
    <#
    .SYNOPSIS
        Retrieves SharePoint site usage metrics via Microsoft Graph REST API.
    .DESCRIPTION
        Queries /reports/getSharePointSiteUsageDetail with automatic HTTP 429 exponential backoff and jitter.
    .OUTPUTS
        [System.Collections.Generic.List[PSCustomObject]]
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateSet('D7', 'D30', 'D90', 'D180')]
        [string]$Period = 'D180'
    )

    $endpoint = "https://graph.microsoft.com/v1.0/reports/getSharePointSiteUsageDetail(period='$Period')"
    $token = $script:SPCContext.GraphAccessToken
    $headers = @{
        'Authorization'    = "Bearer $token"
        'ConsistencyLevel' = 'eventual'
    }

    $maxRetries = 5
    $retryCount = 0
    $backoffSec = 2

    while ($retryCount -lt $maxRetries) {
        try {
            Write-Verbose "Querying Graph Site Usage Report: $endpoint"
            $csvResponse = Invoke-RestMethod -Uri $endpoint -Headers $headers -Method Get -ErrorAction Stop
            
            # Graph reports endpoint returns CSV content directly
            $rows = $csvResponse | ConvertFrom-Csv
            $result = [System.Collections.Generic.List[PSCustomObject]]::new()

            if ($null -ne $rows) {
                foreach ($r in $rows) {
                    $storageBytes = [int64]0
                    if ($null -ne $r.'Storage Used (Byte)') {
                        [int64]::TryParse($r.'Storage Used (Byte)', [ref]$storageBytes) | Out-Null
                    }
                    $storageMB = [Math]::Round(($storageBytes / 1MB), 2)

                    $fileCount = [int64]0
                    if ($null -ne $r.'File Count') {
                        [int64]::TryParse($r.'File Count', [ref]$fileCount) | Out-Null
                    }

                    $lastActivity = $null
                    if (-not [string]::IsNullOrWhiteSpace($r.'Last Activity Date')) {
                        $parsedDate = [datetime]::MinValue
                        if ([datetime]::TryParse($r.'Last Activity Date', [ref]$parsedDate)) {
                            $lastActivity = $parsedDate
                        }
                    }

                    $ownerUPN = if (-not [string]::IsNullOrWhiteSpace($r.'Site Owner Principal Name')) {
                        $r.'Site Owner Principal Name'
                    } elseif (-not [string]::IsNullOrWhiteSpace($r.'Owner Display Name')) {
                        $r.'Owner Display Name'
                    } else {
                        'Unknown'
                    }

                    $result.Add([PSCustomObject]@{
                        SiteUrl          = $r.'Site URL'
                        SiteTitle        = $r.'Site Title'
                        GroupId          = $r.'Group ID'
                        Template         = $r.'Root Web Template'
                        StorageUsedMB    = $storageMB
                        FileCount        = $fileCount
                        LastActivityDate = $lastActivity
                        LockState        = $r.'Lock State'
                        OwnerUPN         = $ownerUPN
                        IsTeamSite       = (-not [string]::IsNullOrWhiteSpace($r.'Group ID'))
                    })
                }
            }
            return $result
        }
        catch {
            $statusCode = $null
            if ($_.Exception -and $_.Exception.Response) {
                if ($_.Exception.Response.StatusCode) {
                    if ($_.Exception.Response.StatusCode.value__) {
                        $statusCode = $_.Exception.Response.StatusCode.value__
                    } else {
                        $statusCode = [int]$_.Exception.Response.StatusCode
                    }
                }
            }

            if ($statusCode -in @(429, 503)) {
                $retryCount++
                $retryAfter = $null
                if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) {
                    $retryAfter = $_.Exception.Response.Headers['Retry-After']
                }
                $sleepDuration = if ($retryAfter) { [int]$retryAfter } else { $backoffSec }
                
                # Add jitter
                $jitter = (Get-Random -Minimum 100 -Maximum 1000) / 1000.0
                $totalSleep = $sleepDuration + $jitter

                Write-Warning "ERR-STO-104: Microsoft Graph API Throttled (HTTP $statusCode). Retrying in $totalSleep seconds (Attempt $retryCount of $maxRetries)..."
                Start-Sleep -Seconds $totalSleep
                $backoffSec *= 2
            } else {
                Write-Verbose "Get-SPCGraphSiteUsageInternal: Graph report request failed ($($_.Exception.Message)). Falling back to PnP Tenant Sites query."
                try {
                    $tenantSites = if (Get-Command -Name 'Get-PnPTenantSite' -Module PnP.PowerShell -ErrorAction SilentlyContinue) {
                        Get-PnPTenantSite -Connection $script:SPCContext.PnPContext -ErrorAction Stop
                    } else { @() }

                    $fallbackResult = [System.Collections.Generic.List[PSCustomObject]]::new()
                    foreach ($ts in $tenantSites) {
                        $mb = [Math]::Round(($ts.StorageUsageCurrent), 2)
                        $fallbackResult.Add([PSCustomObject]@{
                            SiteUrl          = $ts.Url
                            SiteTitle        = $ts.Title
                            GroupId          = $ts.GroupId
                            Template         = $ts.Template
                            StorageUsedMB    = $mb
                            FileCount        = 0
                            LastActivityDate = $ts.LastContentModifiedDate
                            LockState        = $ts.LockState
                            OwnerUPN         = $ts.Owner
                            IsTeamSite       = (-not [string]::IsNullOrWhiteSpace($ts.GroupId) -and $ts.GroupId -ne [Guid]::Empty.ToString())
                        })
                    }
                    return $fallbackResult
                }
                catch {
                    throw "ERR-STO-102: Failed to fetch Graph Site Usage Report and PnP fallback failed: $($_.Exception.Message)"
                }
            }
        }
    }
    throw "ERR-STO-104: Exceeded maximum retry attempts for Microsoft Graph Site Usage Report."
}