-
Notifications
You must be signed in to change notification settings - Fork 0
Make retry behavior configurable per step via host-owned ExecutionOptions #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
f489d42
Initial plan
Copilot 4e45818
Add retry profile configuration support
Copilot 917ff00
Remove direct unit tests for private functions
Copilot bf549df
Address code review feedback: refactor and use constants
Copilot 6ddd9f4
Add example workflow demonstrating retry profiles
Copilot 94e7651
Apply suggestions from code review
blindzero 419f375
Address code review feedback
Copilot 0a6c221
docs: updated cmdlet reference
blindzero File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| @{ | ||
| # Example workflow demonstrating configurable retry behavior | ||
| # | ||
| # This workflow shows how to use RetryProfile to configure | ||
| # different retry behavior for steps targeting different systems. | ||
|
|
||
| Name = 'Joiner - With Retry Profiles' | ||
| LifecycleEvent = 'Joiner' | ||
| Description = 'Example workflow with custom retry profiles for different target systems' | ||
|
|
||
| Steps = @( | ||
| @{ | ||
| Name = 'Resolve identity from HR system' | ||
| Type = 'IdLE.Step.EmitEvent' | ||
| Description = 'Lookup user in HR database' | ||
| # In a real deployment, this would be a system-specific "resolve from HR" step. | ||
| # Here we emit an event as a simple example, using the default retry profile. | ||
| With = @{ | ||
| Message = 'Resolve identity for HR record {{Request.Data.HrEmployeeId}}' | ||
| } | ||
| } | ||
|
|
||
| @{ | ||
| Name = 'Create Entra ID account' | ||
| Type = 'IdLE.Step.CreateIdentity' | ||
| Description = 'Create user in Entra ID (Microsoft Graph API)' | ||
| RetryProfile = 'GraphAPI' | ||
| # Microsoft Graph has specific throttling limits - use a profile | ||
| # optimized for Graph API retry behavior | ||
| } | ||
|
|
||
| @{ | ||
| Name = 'Create mailbox' | ||
| Type = 'IdLE.Step.EnsureEntitlement' | ||
| Description = 'Provision Exchange Online mailbox' | ||
| RetryProfile = 'ExchangeOnline' | ||
| With = @{ | ||
| Kind = 'Mailbox' | ||
| MailboxType = 'UserMailbox' | ||
| } | ||
| # Exchange Online has different throttling characteristics | ||
| # than Graph - use a dedicated profile | ||
| } | ||
|
|
||
| @{ | ||
| Name = 'Add to security group' | ||
| Type = 'IdLE.Step.EnsureEntitlement' | ||
| Description = 'Add user to Entra ID security group' | ||
| RetryProfile = 'GraphAPI' | ||
| With = @{ | ||
| Kind = 'Group' | ||
| Value = 'All_Users' | ||
| } | ||
| } | ||
|
|
||
| @{ | ||
| Name = 'Set manager attribute' | ||
| Type = 'IdLE.Step.EnsureAttribute' | ||
| Description = 'Set manager reference in Entra ID' | ||
| RetryProfile = 'GraphAPI' | ||
| With = @{ | ||
| AttributeName = 'manager' | ||
| Value = '{{Request.Data.ManagerId}}' | ||
| } | ||
| } | ||
| ) | ||
|
|
||
| OnFailureSteps = @( | ||
| @{ | ||
| Name = 'Emit failure notification' | ||
| Type = 'IdLE.Step.EmitEvent' | ||
| Description = 'Notify on workflow failure' | ||
| RetryProfile = 'Notifications' | ||
| # Notification systems may have their own rate limits | ||
| With = @{ | ||
| Message = 'Joiner workflow failed for user {{Request.Data.UserPrincipalName}}' | ||
| } | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| <# | ||
| Example ExecutionOptions configuration: | ||
|
|
||
| $executionOptions = @{ | ||
| RetryProfiles = @{ | ||
| Default = @{ | ||
| MaxAttempts = 3 | ||
| InitialDelayMilliseconds = 200 | ||
| BackoffFactor = 2.0 | ||
| MaxDelayMilliseconds = 5000 | ||
| JitterRatio = 0.2 | ||
| } | ||
| GraphAPI = @{ | ||
| # Microsoft Graph throttling can be aggressive | ||
| # Use more retries with longer delays | ||
| MaxAttempts = 5 | ||
| InitialDelayMilliseconds = 1000 | ||
| BackoffFactor = 2.0 | ||
| MaxDelayMilliseconds = 16000 | ||
| JitterRatio = 0.3 | ||
| } | ||
| ExchangeOnline = @{ | ||
| # Exchange Online often requires patience | ||
| MaxAttempts = 6 | ||
| InitialDelayMilliseconds = 500 | ||
| BackoffFactor = 2.5 | ||
| MaxDelayMilliseconds = 30000 | ||
| JitterRatio = 0.25 | ||
| } | ||
| Notifications = @{ | ||
| # Notifications should retry but not delay the workflow too much | ||
| MaxAttempts = 3 | ||
| InitialDelayMilliseconds = 100 | ||
| BackoffFactor = 1.5 | ||
| MaxDelayMilliseconds = 1000 | ||
| JitterRatio = 0.1 | ||
| } | ||
| } | ||
| DefaultRetryProfile = 'Default' | ||
| } | ||
|
|
||
| # Invoke the plan with retry configuration | ||
| $result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $executionOptions | ||
|
|
||
| # Each step will use its configured retry profile: | ||
| # - Steps without RetryProfile use 'Default' (from DefaultRetryProfile) | ||
| # - Steps with RetryProfile='GraphAPI' use the GraphAPI profile | ||
| # - Steps with RetryProfile='ExchangeOnline' use the ExchangeOnline profile | ||
| # - Steps with RetryProfile='Notifications' use the Notifications profile | ||
| #> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| # Asserts that ExecutionOptions is valid and rejects ScriptBlocks. | ||
| # Validates the structure and constraints for retry profiles. | ||
|
|
||
| # Retry parameter limits (hard constraints to prevent misconfiguration) | ||
| $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT = 10 | ||
| $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT = 60000 | ||
| $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT = 300000 | ||
|
|
||
| function Assert-IdleExecutionOptions { | ||
| [CmdletBinding()] | ||
| param( | ||
| [Parameter(Mandatory)] | ||
| [AllowNull()] | ||
| [object] $ExecutionOptions | ||
| ) | ||
|
|
||
| if ($null -eq $ExecutionOptions) { | ||
| return | ||
| } | ||
|
|
||
| # ExecutionOptions must be a hashtable or IDictionary | ||
| if ($ExecutionOptions -isnot [System.Collections.IDictionary]) { | ||
| throw [System.ArgumentException]::new( | ||
| 'ExecutionOptions must be a hashtable or IDictionary.', | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
|
||
| # Reject ScriptBlocks anywhere in ExecutionOptions | ||
| Assert-IdleNoScriptBlock -InputObject $ExecutionOptions -Path 'ExecutionOptions' | ||
|
|
||
| # Validate RetryProfiles if present | ||
| if ($ExecutionOptions.Contains('RetryProfiles')) { | ||
| $retryProfiles = $ExecutionOptions['RetryProfiles'] | ||
|
|
||
| if ($null -ne $retryProfiles -and $retryProfiles -isnot [System.Collections.IDictionary]) { | ||
| throw [System.ArgumentException]::new( | ||
| 'ExecutionOptions.RetryProfiles must be a hashtable or IDictionary.', | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
|
||
| if ($null -ne $retryProfiles) { | ||
| foreach ($profileKey in $retryProfiles.Keys) { | ||
| # Profile key must match pattern: ^[A-Za-z0-9_.-]{1,64}$ | ||
| if ([string]$profileKey -notmatch '^[A-Za-z0-9_.-]{1,64}$') { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile key '$profileKey' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
|
||
| $profile = $retryProfiles[$profileKey] | ||
|
|
||
| if ($null -eq $profile) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$profileKey' is null. Each profile must be a hashtable with retry parameters.", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
|
||
| if ($profile -isnot [System.Collections.IDictionary]) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$profileKey' must be a hashtable or IDictionary.", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
|
||
| # Validate individual retry parameters | ||
| Assert-IdleRetryProfile -Profile $profile -ProfileKey $profileKey | ||
| } | ||
| } | ||
| } | ||
|
|
||
| # Validate DefaultRetryProfile if present | ||
| if ($ExecutionOptions.Contains('DefaultRetryProfile')) { | ||
| $defaultProfile = $ExecutionOptions['DefaultRetryProfile'] | ||
|
|
||
| if ($null -ne $defaultProfile -and [string]::IsNullOrWhiteSpace([string]$defaultProfile)) { | ||
| throw [System.ArgumentException]::new( | ||
| 'ExecutionOptions.DefaultRetryProfile must not be an empty string.', | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
|
||
| # DefaultRetryProfile must reference a valid profile key | ||
| if ($null -ne $defaultProfile -and $ExecutionOptions.Contains('RetryProfiles')) { | ||
| $retryProfiles = $ExecutionOptions['RetryProfiles'] | ||
| if ($null -ne $retryProfiles -and -not $retryProfiles.Contains([string]$defaultProfile)) { | ||
| throw [System.ArgumentException]::new( | ||
| "DefaultRetryProfile '$defaultProfile' references a profile that does not exist in RetryProfiles.", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function Assert-IdleRetryProfile { | ||
| [CmdletBinding()] | ||
| param( | ||
| [Parameter(Mandatory)] | ||
| [ValidateNotNull()] | ||
| [System.Collections.IDictionary] $Profile, | ||
|
|
||
| [Parameter(Mandatory)] | ||
| [ValidateNotNullOrEmpty()] | ||
| [string] $ProfileKey | ||
| ) | ||
|
|
||
| # Validate MaxAttempts (0..10) | ||
| if ($Profile.Contains('MaxAttempts')) { | ||
| $maxAttempts = $Profile['MaxAttempts'] | ||
| if ($maxAttempts -isnot [int] -or $maxAttempts -lt 0 -or $maxAttempts -gt $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$ProfileKey': MaxAttempts must be an integer between 0 and $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT (inclusive).", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| # Validate InitialDelayMilliseconds (0..60000) | ||
| if ($Profile.Contains('InitialDelayMilliseconds')) { | ||
| $initialDelay = $Profile['InitialDelayMilliseconds'] | ||
| if ($initialDelay -isnot [int] -or $initialDelay -lt 0 -or $initialDelay -gt $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$ProfileKey': InitialDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT (inclusive).", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| # Validate BackoffFactor (>= 1.0) | ||
| if ($Profile.Contains('BackoffFactor')) { | ||
| $backoffFactor = $Profile['BackoffFactor'] | ||
| # Accept both int and double | ||
| if (($backoffFactor -isnot [double] -and $backoffFactor -isnot [int]) -or ([double]$backoffFactor -lt 1.0)) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$ProfileKey': BackoffFactor must be a number >= 1.0.", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| # Validate MaxDelayMilliseconds (0..300000 and >= InitialDelayMilliseconds) | ||
| if ($Profile.Contains('MaxDelayMilliseconds')) { | ||
| $maxDelay = $Profile['MaxDelayMilliseconds'] | ||
| if ($maxDelay -isnot [int] -or $maxDelay -lt 0 -or $maxDelay -gt $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$ProfileKey': MaxDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT (inclusive).", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
|
||
| # Check that MaxDelayMilliseconds >= InitialDelayMilliseconds | ||
| # Use the profile's InitialDelayMilliseconds if present, otherwise use engine default (250ms) | ||
| $initialDelay = if ($Profile.Contains('InitialDelayMilliseconds')) { | ||
| $Profile['InitialDelayMilliseconds'] | ||
| } else { | ||
| 250 # Engine default | ||
| } | ||
|
|
||
| if ($maxDelay -lt $initialDelay) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$ProfileKey': MaxDelayMilliseconds ($maxDelay) must be >= InitialDelayMilliseconds ($initialDelay).", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
|
blindzero marked this conversation as resolved.
|
||
| } | ||
|
|
||
| # Validate JitterRatio (0.0..1.0) | ||
| if ($Profile.Contains('JitterRatio')) { | ||
| $jitterRatio = $Profile['JitterRatio'] | ||
| # Accept both int and double | ||
| if (($jitterRatio -isnot [double] -and $jitterRatio -isnot [int]) -or ([double]$jitterRatio -lt 0.0) -or ([double]$jitterRatio -gt 1.0)) { | ||
| throw [System.ArgumentException]::new( | ||
| "RetryProfile '$ProfileKey': JitterRatio must be a number between 0.0 and 1.0 (inclusive).", | ||
| 'ExecutionOptions' | ||
| ) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.