Private/Throttle/Initialize-ThrottleState.ps1

<#
    Copyright (c) 2025-2026 Robert Faddes
    SPDX-License-Identifier: MPL-2.0
#>

<#
.SYNOPSIS
    Discovers API rate limits and initialises multi-bucket throttle state.
.DESCRIPTION
    Calls GET /v2/system/request_rate to discover the account's rate limits and populates
    the session throttle state with discovered values. This includes the read limit, write
    limit, and per-operation write buckets (dynamically built from the API response).
 
    The Datto RMM API tracks reads and writes as independent quotas:
    - accountCount / accountRateLimit → read (GET) operations only
    - accountWriteCount / accountWriteRateLimit → write (PUT/POST/DELETE) operations only
 
    Falls back to safe defaults (600/min per bucket, empty operation buckets) if the
    rate-status call fails, logging a warning. Profile settings are not modified — only
    discovered limits and tracking state are initialised.
 
    Called once by Connect-DattoRMM immediately after successful authentication.
#>

function Initialize-ThrottleState {
    [CmdletBinding()]
    param ()

    try {

        Write-Debug "Throttle: Discovering API rate limits from Datto RMM."
        $RateInfo = Get-RMMRequestRate

        # Read bucket (GET requests)
        $Script:RMMThrottle.ReadLimit = $RateInfo.accountRateLimit
        $Script:RMMThrottle.AccountCutOffRatio = $RateInfo.accountCutOffRatio
        $Script:RMMThrottle.WindowSizeSeconds = $RateInfo.slidingTimeWindowSizeSeconds

        # Write bucket (PUT/POST/DELETE requests)
        $Script:RMMThrottle.WriteLimit = $RateInfo.accountWriteRateLimit

        # Per-operation write buckets — dynamically built from API response
        $Script:RMMThrottle.OperationBuckets = @{}

        if ($null -ne $RateInfo.operationWriteStatus) {

            $RateInfo.operationWriteStatus.PSObject.Properties | ForEach-Object {

                $Script:RMMThrottle.OperationBuckets[$_.Name] = @{
                    Limit = $_.Value.limit
                    LocalTimestamps = [System.Collections.Generic.List[datetime]]::new()
                }
            }
        }

        # Seed local tracking state from current API counters so local utilisation is aligned on connect
        $Script:RMMThrottle.ReadLocalTimestamps = [System.Collections.Generic.List[datetime]]::new()
        $Script:RMMThrottle.WriteLocalTimestamps = [System.Collections.Generic.List[datetime]]::new()

        $Now = [datetime]::UtcNow
        $WindowStart = $Now.AddSeconds(-$Script:RMMThrottle.WindowSizeSeconds)

        $ReadSeedCount = [math]::Max(0, [int]$RateInfo.accountCount)
        $WriteSeedCount = [math]::Max(0, [int]$RateInfo.accountWriteCount)
        $ReadSeedStep = [timespan]::FromSeconds($Script:RMMThrottle.WindowSizeSeconds / [math]::Max($ReadSeedCount, 1))
        $WriteSeedStep = [timespan]::FromSeconds($Script:RMMThrottle.WindowSizeSeconds / [math]::Max($WriteSeedCount, 1))

        for ($i = 0; $i -lt $ReadSeedCount; $i++) {

            $Script:RMMThrottle.ReadLocalTimestamps.Add($WindowStart.AddTicks($ReadSeedStep.Ticks * $i))

        }

        for ($i = 0; $i -lt $WriteSeedCount; $i++) {

            $Script:RMMThrottle.WriteLocalTimestamps.Add($WindowStart.AddTicks($WriteSeedStep.Ticks * $i))

        }

        # Seed per-operation buckets from current API counters when available
        if ($null -ne $RateInfo.operationWriteStatus) {

            $RateInfo.operationWriteStatus.PSObject.Properties | ForEach-Object {

                $OpName = $_.Name
                $OpCount = [math]::Max(0, [int]$_.Value.count)

                if ($Script:RMMThrottle.OperationBuckets.ContainsKey($OpName)) {

                    $OpSeedStep = [timespan]::FromSeconds($Script:RMMThrottle.WindowSizeSeconds / [math]::Max($OpCount, 1))

                    for ($i = 0; $i -lt $OpCount; $i++) {

                        $Script:RMMThrottle.OperationBuckets[$OpName].LocalTimestamps.Add($WindowStart.AddTicks($OpSeedStep.Ticks * $i))

                    }

                    # Seed API-reported utilisation so the per-op floor is available before the first Update-Throttle fires
                    $Script:RMMThrottle.OperationBuckets[$OpName].ApiUtilisation = $OpCount / [math]::Max([int]$_.Value.limit, 1)

                }
            }
        }

        Write-Debug "Throttle: Seeded local window from API baseline — Read=$ReadSeedCount, Write=$WriteSeedCount"

        $Now = [datetime]::UtcNow
        $Script:RMMThrottle.ReadLastCalibrationUtc = $Now
        $Script:RMMThrottle.WriteLastCalibrationUtc = $Now
        $Script:RMMThrottle.ReadSamplesAtLastCalibration = $Script:RMMThrottle.ReadLocalTimestamps.Count
        $Script:RMMThrottle.WriteSamplesAtLastCalibration = $Script:RMMThrottle.WriteLocalTimestamps.Count
        $Script:RMMThrottle.ReadUtilisation = $Script:RMMThrottle.ReadLocalTimestamps.Count / [math]::Max($Script:RMMThrottle.ReadLimit, 1)
        $Script:RMMThrottle.WriteUtilisation = $Script:RMMThrottle.WriteLocalTimestamps.Count / [math]::Max($Script:RMMThrottle.WriteLimit, 1)
        $Script:RMMThrottle.ReadDelayMS = 0
        $Script:RMMThrottle.WriteDelayMS = 0
        $Script:RMMThrottle.Pause = $false
        $Script:RMMThrottle.Throttle = $false

        Write-Verbose "Rate limits discovered: Read=$($Script:RMMThrottle.ReadLimit)/min, Write=$($Script:RMMThrottle.WriteLimit)/min, Operations=$($Script:RMMThrottle.OperationBuckets.Count) buckets."

    } catch {

        Write-Warning "Failed to discover API rate limits: $($_.Exception.Message). Using safe defaults (600/min per bucket)."

        $Script:RMMThrottle.ReadLimit = 600
        $Script:RMMThrottle.AccountCutOffRatio = 0.9
        $Script:RMMThrottle.WindowSizeSeconds = 60
        $Script:RMMThrottle.WriteLimit = 600
        $Script:RMMThrottle.OperationBuckets = @{}

        # Reset local tracking even on failure
        $Script:RMMThrottle.ReadLocalTimestamps = [System.Collections.Generic.List[datetime]]::new()
        $Script:RMMThrottle.WriteLocalTimestamps = [System.Collections.Generic.List[datetime]]::new()

        $Now = [datetime]::UtcNow
        $Script:RMMThrottle.ReadLastCalibrationUtc = $Now
        $Script:RMMThrottle.WriteLastCalibrationUtc = $Now
        $Script:RMMThrottle.ReadSamplesAtLastCalibration = 0
        $Script:RMMThrottle.WriteSamplesAtLastCalibration = 0

    }
}

# SIG # Begin signature block
# MIIF+wYJKoZIhvcNAQcCoIIF7DCCBegCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBQmFKOd4TIT1Xt
# S07RgyzrHoksdrVdvrBmcXemFllt4qCCA04wggNKMIICMqADAgECAhB464iXHfI6
# gksEkDDTyrNsMA0GCSqGSIb3DQEBCwUAMD0xFjAUBgNVBAoMDVJvYmVydCBGYWRk
# ZXMxIzAhBgNVBAMMGkRhdHRvUk1NLkNvcmUgQ29kZSBTaWduaW5nMB4XDTI2MDMz
# MTAwMTMzMFoXDTI4MDMzMTAwMjMzMFowPTEWMBQGA1UECgwNUm9iZXJ0IEZhZGRl
# czEjMCEGA1UEAwwaRGF0dG9STU0uQ29yZSBDb2RlIFNpZ25pbmcwggEiMA0GCSqG
# SIb3DQEBAQUAA4IBDwAwggEKAoIBAQChn1EpMYQgl1RgWzQj2+wp2mvdfb3UsaBS
# nxEVGoQ0gj96tJ2MHAF7zsITdUjwaflKS1vE6wAlOg5EI1V79tJCMxzM0bFpOdR1
# L5F2HE/ovIAKNkHxFUF5qWU8vVeAsOViFQ4yhHpzLen0WLF6vhmc9eH23dLQy5fy
# tELZQEc2WbQFa4HMAitP/P9kHAu6CUx5s4woLIOyyR06jkr3l9vk0sxcbCxx7+dF
# RrsSLyPYPH+bUAB8+a0hs+6qCeteBuUfLvGzpMhpzKAsY82WZ3Rd9X38i32dYj+y
# dYx+nx+UEMDLjDJrZgnVa8as4RojqVLcEns5yb/XTjLxDc58VatdAgMBAAGjRjBE
# MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQU
# H+B0vf97dYXqdUX1YMcWhFsY6fcwDQYJKoZIhvcNAQELBQADggEBAJmD4EEGNmcD
# 1JtFoRGxuLJaTHxDwBsjqcRQRE1VPZNGaiwIm8oSQdHVjQg0oIyK7SEb02cs6n6Y
# NZbwf7B7WZJ4aKYbcoLug1k1x9SoqwBmfElECeJTKXf6dkRRNmrAodpGCixR4wMH
# KXqwqP5F+5j7bdnQPiIVXuMesxc4tktz362ysph1bqKjDQSCBpwi0glEIH7bv5Ms
# Ey9Gl3fe+vYC5W06d2LYVebEfm9+7766hsOgpdDVgdtnN+e6uwIJjG/6PTG6TMDP
# y+pr5K6LyUVYJYcWWUTZRBqqwBHiLGekPbxrjEVfxUY32Pq4QfLzUH5hhUCAk4HN
# XpF9pOzFLMUxggIDMIIB/wIBATBRMD0xFjAUBgNVBAoMDVJvYmVydCBGYWRkZXMx
# IzAhBgNVBAMMGkRhdHRvUk1NLkNvcmUgQ29kZSBTaWduaW5nAhB464iXHfI6gksE
# kDDTyrNsMA0GCWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKEC
# gAAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwG
# CisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIOm0oeNX1Uy6nGGKqzlC1SX4bUIP
# L49kuaNPPtK6wBKcMA0GCSqGSIb3DQEBAQUABIIBADr1TMpAKcZF8P5LigwNHg+a
# IF6fWPR+LZZv34EhSNR8GATx4KuNWJiQFFCE7YR40jzeF3NeCD8pAqjLJ2WuqsGF
# /g8J5OwqbYjIkYiu5yrBkwr1XS2wjdtTlxR/vUwKX0VI5RjVur+NyFhLRNGo0a/9
# SK7VdWYKePLXx21wZ0eOV87B+RCTCpukSMHLYjODX0vQ4FxxTVOCut8+X/66sF+k
# qkr42FtrNseYPrUuvkOWE41AMKGTKYLcZXgkTOFig1FXzTkE291Vrng5aaGWkRYa
# JfqO97maM2koGtV51I2iTyvhLkU+Gf7L2Iwx6e2ZEjt02esfRYptsHpnf2OSLEw=
# SIG # End signature block