aws-lambda-dotnet icon indicating copy to clipboard operation
aws-lambda-dotnet copied to clipboard

Support PowerShell ErrorActionPreference Continue

Open nickadam opened this issue 3 years ago • 5 comments

Describe the Feature

ErrorActionPreference Continue results in the last object being returned to the caller.

ErrorActionPreference should work as you would expect if you ran your script outside of lambda. If ErrorActionPreference is set to Continue, the script should continue to execute and return the last object, not the error.

Is your Feature Request related to a problem?

Module functions that write errors have to be suppressed using ErrorAction SilentlyContinue to prevent lambda from "giving up" on returning the last object.

Proposed Solution

Describe alternatives you've considered

Additional Context

The default ErrorActionPreference in lambda is Continue however lambda is weird and it will hold on to an error and give it to the caller rather than return the last object.

It does not "throw" or "return" the error (i.e. stop execution) as you would expect in reading the documentation. The script will continue to run regardless of the error. If a script has side effects that occur after the error (like Write-S3Object) they will run.

This can mislead developers into thinking their code stopped (since they got some error early on in the script), but in reality the script continued to execute after the error.

Environment

  • [ ] :wave: I may be able to implement this feature request
  • [X] :warning: This feature might incur a breaking change

This is a :rocket: Feature Request

nickadam avatar Feb 25 '22 21:02 nickadam

Hi @nickadam,

Good afternoon.

Thanks for submitting the feature request. Could you please share a code example for the use case to analyze this further? Is this related to PowerShell Lambda?

Thanks, Ashish

ashishdhingra avatar Feb 25 '22 22:02 ashishdhingra

I believe I know what @nickadam is trying to convey.

Given the following PowerShell Lambda code:

#Requires -Modules @{ModuleName='AWS.Tools.Common';ModuleVersion='4.1.32'}
#Requires -Modules @{ModuleName='AWS.Tools.S3';ModuleVersion='4.1.32'}

$ErrorActionPreference = 'Continue' #<-- note this explicitly set to continue
$returnObject = [PSCustomObject]@{
    Status         = $null
    BucketCreation = $null
}
$bucketName = 'thisbucketdoesnotexistandthiswillfailforsure'
Write-Host ('Bucket Name: {0}' -f $bucketName)
$bucketInfo = Get-S3Bucket -BucketName $bucketName
Write-Host 'Bucket query complete'

if ($bucketInfo) {
    $returnObject.Status = $true
    $returnObject.BucketCreation = $bucketInfo.CreationDate
}
else {
    $returnObject.Status = $false
}

return $returnObject

The expectation from a typical PowerShell developer would be that the $returnObject should be returned, regardless of the success of Get-S3Bucket.

If the bucket does not exists, the code should encounter an exception. However, because $ErrorActionPreference = 'Continue' is set - this means the error should be non-terminating.

This can be proven by running the code in a normal PowerShell session (not in Lambda). This results in the following return:

Status BucketCreation
------ --------------
 False 

Which is what is expected. The error is set to non-terminating, and our object with status false is returned despite the exception.

The behavior is very different when the same code is run in Lambda though:

Publish-AWSPowerShellLambda -Name pwshError -ScriptPath .\lambada_errors\pwshError.ps1

$response = Invoke-LMFunction -FunctionName pwshError -Payload '{"does":"notmatter"}' -LogType 'Tail'
$log = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($response.logresult))
foreach ($line in $log) {
    Write-Verbose -Message $line -Verbose
}
VERBOSE: 07:19:54.661Z  c2c64be4-b478-467c-9e5e-fbb42db03e86    info    [Error] - Access Denied
2022-02-26T07:19:54.662Z        c2c64be4-b478-467c-9e5e-fbb42db03e86    info    [Information] - Bucket query complete
2022-02-26T07:19:54.663Z        c2c64be4-b478-467c-9e5e-fbb42db03e86    fail    System.InvalidOperationException: Access Denied
---> Amazon.S3.AmazonS3Exception: Access Denied
---> Amazon.Runtime.Internal.HttpErrorResponseException: Exception of type 'Amazon.Runtime.Internal.HttpErrorResponseException' was thrown.
at Amazon.Runtime.HttpWebRequestMessage.GetResponseAsync(CancellationToken cancellationToken)
at Amazon.Runtime.Internal.HttpHandler`1.InvokeAsync[T](IExecutionContext executionContext)
at Amazon.Runtime.Internal.RedirectHandler.InvokeAsync[T](IExecutionContext executionContext)
at Amazon.Runtime.Internal.Unmarshaller.InvokeAsync[T](IExecutionContext executionContext)
at Amazon.S3.Internal.AmazonS3ResponseHandler.InvokeAsync[T](IExecutionContext executionContext)
at Amazon.Runtime.Internal.ErrorHandler.InvokeAsync[T](IExecutionContext executionContext)
--- End of inner exception stack trace ---
$StreamReader = [System.IO.StreamReader]::new($response.Payload)
$StreamReader.ReadToEnd()
{
  "errorType": "InvalidOperationException",
  "errorMessage": "Access Denied",
  "stackTrace": [
    "at Amazon.Lambda.PowerShellHost.PowerShellFunctionHost.ExecuteFunction(Stream inputStream, ILambdaContext context)",
    "at lambda_method70(Closure , Stream , ILambdaContext , Stream )",
    "at Amazon.Lambda.RuntimeSupport.Bootstrap.UserCodeLoader.Invoke(Stream lambdaData, ILambdaContext lambdaContext, Stream outStream) in /src/Repo/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/UserCodeLoader.cs:line 145",
    "at Amazon.Lambda.RuntimeSupport.HandlerWrapper.<>c__DisplayClass8_0.<GetHandlerWrapper>b__0(InvocationRequest invocation) in /src/Repo/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/HandlerWrapper.cs:line 56",
    "at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.InvokeOnceAsync(CancellationToken cancellationToken) in /src/Repo/Libraries/src/Amazon.Lambda.RuntimeSupport/Bootstrap/LambdaBootstrap.cs:line 176"
  ],
  "cause":   {
    "errorType": "AmazonS3Exception",
    "errorMessage": "Access Denied",
    "stackTrace": [
      "at Amazon.Runtime.Internal.HttpErrorResponseExceptionHandler.HandleExceptionStream(IRequestContext requestContext, IWebResponseData httpErrorResponse, HttpErrorResponseException exception, Stream responseStream)",
      "at Amazon.Runtime.Internal.HttpErrorResponseExceptionHandler.HandleExceptionAsync(IExecutionContext executionContext, HttpErrorResponseException exception)",
      "at Amazon.Runtime.Internal.ExceptionHandler`1.HandleAsync(IExecutionContext executionContext, Exception exception)",
      "at Amazon.Runtime.Internal.ErrorHandler.ProcessExceptionAsync(IExecutionContext executionContext, Exception exception)",
      "at Amazon.Runtime.Internal.ErrorHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.CallbackHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.EndpointDiscoveryHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.EndpointDiscoveryHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.CredentialsRetriever.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.RetryHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.RetryHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.CallbackHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.CallbackHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.S3.Internal.AmazonS3ExceptionHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.ErrorCallbackHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.Runtime.Internal.MetricsHandler.InvokeAsync[T](IExecutionContext executionContext)",
      "at Amazon.PowerShell.Cmdlets.S3.GetS3BucketCmdlet.CallAWSServiceOperation(IAmazonS3 client, ListBucketsRequest request)",
      "at Amazon.PowerShell.Cmdlets.S3.GetS3BucketCmdlet.Execute(ExecutorContext context)"
    ],
    "cause":     {
      "errorType": "HttpErrorResponseException",
      "errorMessage": "Exception of type 'Amazon.Runtime.Internal.HttpErrorResponseException' was thrown.",
      "stackTrace": [
        "at Amazon.Runtime.HttpWebRequestMessage.GetResponseAsync(CancellationToken cancellationToken)",
        "at Amazon.Runtime.Internal.HttpHandler`1.InvokeAsync[T](IExecutionContext executionContext)",
        "at Amazon.Runtime.Internal.RedirectHandler.InvokeAsync[T](IExecutionContext executionContext)",
        "at Amazon.Runtime.Internal.Unmarshaller.InvokeAsync[T](IExecutionContext executionContext)",
        "at Amazon.S3.Internal.AmazonS3ResponseHandler.InvokeAsync[T](IExecutionContext executionContext)",
        "at Amazon.Runtime.Internal.ErrorHandler.InvokeAsync[T](IExecutionContext executionContext)"
      ]
    }
  }
}

As we see in the Lambda invoke - the error object is returned and the non-terminating behavior is not honored. The $returnObject is not returned. Instead we get the exception back which is not what we would get when executed locally.

Despite this difference in behavior - I'm not sure a change is warranted. I believe this behavior was a design choice. @austoonz or other members of the AWS team may have additional thoughts.

This is very easily corrected by simply changing:

$ErrorActionPreference = 'Continue' to $ErrorActionPreference = 'SilentlyContinue'

This results in the expected non-terminating behavior inside Lambda:

Publish-AWSPowerShellLambda -Name pwshError -ScriptPath .\lambada_errors\pwshError.ps1

$response = Invoke-LMFunction -FunctionName pwshError -Payload '{"does":"notmatter"}' -LogType 'Tail'
$log = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($response.logresult))
foreach ($line in $log) {
    Write-Verbose -Message $line -Verbose
}
VERBOSE: START RequestId: b5c07415-b265-4598-bd4a-05f5d93e4fb3 Version: $LATEST
Importing module ./Modules/AWS.Tools.Common/4.1.32/AWS.Tools.Common.psd1
Importing module ./Modules/AWS.Tools.S3/4.1.32/AWS.Tools.S3.psd1
2022-02-26T07:42:57.769Z        b5c07415-b265-4598-bd4a-05f5d93e4fb3    info    [Information] - Bucket Name: thisbucketdoesnotexistandthiswillfailforsure
2022-02-26T07:42:59.206Z        b5c07415-b265-4598-bd4a-05f5d93e4fb3    info    [Information] - Bucket query complete
END RequestId: b5c07415-b265-4598-bd4a-05f5d93e4fb3
REPORT RequestId: b5c07415-b265-4598-bd4a-05f5d93e4fb3  Duration: 2132.21 ms    Billed Duration: 2133 ms        Memory Size: 512 MB     Max Memory Used: 171 MB Init Duration: 2137.76 ms
$StreamReader = [System.IO.StreamReader]::new($response.Payload)
$StreamReader.ReadToEnd()
{
  "Status": false,
  "BucketCreation": null
}

techthoughts2 avatar Feb 26 '22 07:02 techthoughts2

Thanks @techthoughts2! You are exactly right. I'll add one more point. If the script had a side-effect after the error, the caller is unaware of the result. Here's a rough addition to your example where we create the s3 bucket if it's not found. Since the ErrorActionPreference is Continue, New-S3Bucket will still run. Assuming New-S3Bucket succeeded, the caller will not get the status object, just an error.

#Requires -Modules @{ModuleName='AWS.Tools.Common';ModuleVersion='4.1.32'}
#Requires -Modules @{ModuleName='AWS.Tools.S3';ModuleVersion='4.1.32'}

$ErrorActionPreference = 'Continue' #<-- note this explicitly set to continue
$returnObject = [PSCustomObject]@{
    Status         = $null
    BucketCreation = $null
}
$bucketName = 'thisbucketdoesnotexistandthiswillfailforsure'
Write-Host ('Bucket Name: {0}' -f $bucketName)
$bucketInfo = Get-S3Bucket -BucketName $bucketName
Write-Host 'Bucket query complete'

# create bucket if not found
if (-not $bucketInfo) {
    New-S3Bucket -BucketName $bucketName
    $bucketInfo = Get-S3Bucket -BucketName $bucketName
}

if ($bucketInfo) {
    $returnObject.Status = $true
    $returnObject.BucketCreation = $bucketInfo.CreationDate
}
else {
    $returnObject.Status = $false
}

return $returnObject

nickadam avatar Feb 26 '22 13:02 nickadam

From ErrorAction:

  • -ErrorAction:Continue displays the error message and continues executing the command. Continue is the default.
  • -ErrorAction:SilentlyContinue suppresses the error message and continues executing the command.

I'm still not convinced a change is warranted.

In the example code using Continue - the PowerShell developer has made a conscious decision not to suppress the error. Because an exception did occur, and it wasn't suppressed - this has implications.

For example - if this example Lambda were part of a State Machine workflow... would the State Machine evaluate the Lambda as: ✔️ Lambda succeeded ❌ Lambda failed

Because an exception with the bucket occurred, and wasn't suppressed - the State Machine is going evaluate the Lambda as failed because the error is returned to the State Machine workflow.

This affects other things as well, for example SQS processing. If set to Continue and an exception occurs - was the queue message successfully processed, or not? Because an exception with the bucket occurred, and wasn't suppressed - SQS processing will evaluate the Lambda as not successfully processing the message.

^ I think these behaviors are expected. The Lambda did not succeed. It encountered an exception and is advising these external service integrations appropriately.

My .02 - it falls to the PowerShell developer to recognize these pattern constraints and write their Lambda logic with the desired end state in mind.

techthoughts2 avatar Feb 26 '22 18:02 techthoughts2

My .02 - it falls to the PowerShell developer to recognize these pattern constraints and write their Lambda logic with the desired end state in mind. @techthoughts2

I agree. And I would suggest that developers that want to return an error rather than the last object would set ErrorAction or ErrorActionPreference to Stop. As it stands now there is no way to return the last object and write an error to the log.

The Lambda did not succeed. @techthoughts2

I disagree. "Success" is up to the developer to determine. I'm currently working on a function that will add a user to a group in Office 365. One error that can be returned by Add-DistributionGroupMember is "the user is already a member of the group". To me, that is an acceptable error and I would rather call Add-DistributionGroupMember and handle this error rather than enumerate all the user's groups or all the group's members to check if I should call Add-DistributionGroupMember. Doing so would add unnecessary overhead.

I can work around it, but it's odd that I have to. For example:

What would normally be

Add-DistributionGroupMember -Identity $GroupName -Member $Upn

Becomes

$Errors = @()
Add-DistributionGroupMember -Identity $GroupName -Member $Upn -ErrorAction SilentlyContinue -ErrorVariable Errors
$Errors | Write-Host

The developer guide doesn't provide any hints about the current "errors above all" behavior.

After the PowerShell script is finished, the last object in the PowerShell pipeline is the return data for the Lambda function. https://docs.aws.amazon.com/lambda/latest/dg/powershell-handler.html

This would be a more accurate statement:

After the PowerShell script is finished, the last object in the PowerShell pipeline is the return data for the Lambda function so long as no errors were displayed.

It would be nice if Write-Error could be leveraged, right now all errors have to suppress with SilentlyContinue. Other lambda languages have access to this log levels but not PowerShell 😢

edit: Last paragraph. I looked at the source and I see that Warning, Information, Verbose logging is available but Error has special treatment.

nickadam avatar Feb 26 '22 20:02 nickadam