From 4057c276f730fecabaf381a337c9356c0e7e3f04 Mon Sep 17 00:00:00 2001 From: Rebecca Cremona Date: Thu, 1 Aug 2024 16:55:09 -0400 Subject: [PATCH 1/4] Stream lambda payloads, if that API is available. --- web/main/utils.py | 107 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 32 deletions(-) diff --git a/web/main/utils.py b/web/main/utils.py index 290980020..827149417 100644 --- a/web/main/utils.py +++ b/web/main/utils.py @@ -665,7 +665,13 @@ def export_via_aws_lambda(obj, html, file_type): "docx_sections": True, }, } + if export_settings.get("function_arn"): + + # + # Communicate with AWS via API, using the boto3 library + # + lambda_client = boto3.client( "lambda", export_settings["function_region"], @@ -673,57 +679,94 @@ def export_via_aws_lambda(obj, html, file_type): aws_secret_access_key=export_settings["secret_key"], config=Config(read_timeout=settings.AWS_LAMBDA_EXPORT_TIMEOUT), ) - raw_response = lambda_client.invoke( + response = lambda_client.invoke_with_response_stream( FunctionName=export_settings["function_name"], LogType="Tail", Payload=bytes(json.dumps(lambda_event_config), "utf-8"), ) - response = { - "status_code": raw_response["ResponseMetadata"]["HTTPStatusCode"], - "headers": raw_response["ResponseMetadata"]["HTTPHeaders"], - "content": raw_response["Payload"], - "get_text": lambda: raw_response["Payload"].read(), - } - lambda_log_str = ( - str(base64.b64decode(raw_response["LogResult"]), "utf-8") - .strip() - .replace("\n", "; ") - .replace("\t", ", ") - ) - logger.info(f'{log_line_prefix}: Lambda logs "{lambda_log_str}"') + + payload = b"" + error_code = None + error_details = None + log_result = None + log_str = "" + status_code = response["StatusCode"] + + # Consume the event stream to retrieve the payload and any logs or error messages + for event in response["EventStream"]: + if event.get("PayloadChunk"): + payload += event["PayloadChunk"]["Payload"] + elif event.get("InvokeComplete"): + error_code = event["InvokeComplete"].get("ErrorCode") + error_details = event["InvokeComplete"].get("ErrorDetails") + log_result = event["InvokeComplete"].get("LogResult") + else: + logger.error(f"Unexpected event from AWs Lambda: {event}.") + + # Format the logs, if present + if log_result: + log_str = ( + str(base64.b64decode(log_result), "utf-8") + .strip() + .replace("\n", "; ") + .replace("\t", ", ") + ) + if log_str: + logger.info(f'{log_line_prefix}: Lambda logs "{log_str}"') + else: + logger.info(f"{log_line_prefix}: No Lambda logs") + + # Check for apparent success + assert ( + status_code == 200 + ), f"Status: {status_code}. Details: {error_code or 'no code'}; {error_details or 'no details'}; {log_str or 'no logs'}." + assert ( + not error_code and not error_details + ), f"Error: {error_code or 'no code'}; {error_details or 'no details'}; {log_str or 'no logs'}." + assert payload, f"No payload: {log_str or 'no logs'}." + + # Looks like we have something to return! + content = payload + else: + + # + # Communicate with AWS (or an emulator) using a lambda function URL + # + raw_response = requests.post( export_settings["function_url"], timeout=settings.AWS_LAMBDA_EXPORT_TIMEOUT, json=lambda_event_config, ) + + # format the response response = { "status_code": raw_response.status_code, "headers": {k.lower(): v for k, v in raw_response.headers.items()}, - "log": None, "content": raw_response.content, "get_text": lambda: raw_response.text, } - assert ( - response["status_code"] == 200 - ), f"Status: {response['status_code']}. Content: {response['get_text']()}" - if response["headers"].get("content-type", "") == "text/plain; charset=utf-8": - parsed_content = json.loads(response.get("content")) - error_type = parsed_content.get("errorType", "Unknown") - if error_type == "Function.ResponseSizeTooLarge": - raise LambdaExportTooLarge( - f"An HTML export of {len(html)} chars resulted in a {parsed_content.get('errorMessage')}" - ) - assert not response["headers"].get("x-amz-function-error") and response["headers"].get( - "content-type", "" - ) in [ - "application/zip", - "application/octet-stream", - ], f"x-amz-function-error: {response['headers'].get('x-amz-function-error')}, content-type:{response['headers'].get('content-type','unknown')}, {response['get_text']()}" + + # check for apparent success + assert ( + response["status_code"] == 200 + ), f"Status: {response['status_code']}. Content: {response['get_text']()}" + assert not response["headers"].get("x-amz-function-error") and response[ + "headers" + ].get("content-type", "") in [ + "application/zip", + "application/octet-stream", + ], f"x-amz-function-error: {response['headers'].get('x-amz-function-error')}, content-type:{response['headers'].get('content-type','unknown')}, {response['get_text']()}" + + # Looks like we have something to return! + content = response["content"] + except (BotoCoreError, BotoClientError, requests.RequestException, AssertionError) as e: if export_type == "Casebook": obj.inc_export_fails() raise Exception(f"AWS Lambda export failed: {str(e)}") + finally: # remove the source html from s3 storage.delete(filename) @@ -731,7 +774,7 @@ def export_via_aws_lambda(obj, html, file_type): # return the docx to the user if export_type == "Casebook" and obj.export_fails > 0: obj.reset_export_fails() - return response["content"] + return content class BadFiletypeError(Exception): From 7493746af151b34d1b336a889943fbe311462998 Mon Sep 17 00:00:00 2001 From: Rebecca Cremona Date: Thu, 1 Aug 2024 17:08:43 -0400 Subject: [PATCH 2/4] Tweak error handling. --- web/main/templates/export_too_large.html | 13 ------------- web/main/utils.py | 4 ++-- web/main/views.py | 9 +++++---- 3 files changed, 7 insertions(+), 19 deletions(-) delete mode 100644 web/main/templates/export_too_large.html diff --git a/web/main/templates/export_too_large.html b/web/main/templates/export_too_large.html deleted file mode 100644 index b9407141b..000000000 --- a/web/main/templates/export_too_large.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends 'base.html' %} - -{% block page_title %}Export error{% endblock %} - -{% block mainContent %} -
-
-

Export too large

-

This book is too large to export at this time.

-

For now, you can return to the casebook and export a relevant section.

-
-
-{% endblock %} diff --git a/web/main/utils.py b/web/main/utils.py index 827149417..a49f0985c 100644 --- a/web/main/utils.py +++ b/web/main/utils.py @@ -625,7 +625,7 @@ def get_link_title(url: str) -> str: return title[0].text -class LambdaExportTooLarge(RuntimeError): +class LambdaException(RuntimeError): pass @@ -765,7 +765,7 @@ def export_via_aws_lambda(obj, html, file_type): except (BotoCoreError, BotoClientError, requests.RequestException, AssertionError) as e: if export_type == "Casebook": obj.inc_export_fails() - raise Exception(f"AWS Lambda export failed: {str(e)}") + raise LambdaException(f"AWS Lambda export failed: {str(e)}") finally: # remove the source html from s3 diff --git a/web/main/views.py b/web/main/views.py index 3aad1a976..2039b1824 100644 --- a/web/main/views.py +++ b/web/main/views.py @@ -104,7 +104,7 @@ ) from .utils import ( BadFiletypeError, - LambdaExportTooLarge, + LambdaException, StringFileResponse, fix_after_rails, get_link_title, @@ -2771,11 +2771,12 @@ def export(request: HttpRequest, node: Union[ContentNode, Casebook], file_type=" file_type, export_options=export_options, ) - except LambdaExportTooLarge as too_large: - logger.warning(f"Export node({node.id}): " + too_large.args[0]) - return render(request, "export_too_large.html", {"casebook": node}) + except LambdaException as e: + logger.exception(f"Export of node {node.id} failed.") + return render(request, "export_error.html", {"casebook": node}) if response_data is None: return render(request, "export_error.html", {"casebook": node}) + # return html if file_type == "html": return HttpResponse(response_data) From 084ef9222bffedccefce6148159dc504abd5befc Mon Sep 17 00:00:00 2001 From: Rebecca Cremona Date: Thu, 1 Aug 2024 17:12:53 -0400 Subject: [PATCH 3/4] Tweak comment. --- web/main/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/main/utils.py b/web/main/utils.py index a49f0985c..33f551404 100644 --- a/web/main/utils.py +++ b/web/main/utils.py @@ -669,7 +669,7 @@ def export_via_aws_lambda(obj, html, file_type): if export_settings.get("function_arn"): # - # Communicate with AWS via API, using the boto3 library + # Communicate with AWS via their API, using the boto3 library # lambda_client = boto3.client( From 7605974d1164a2ff12e2fa19ef8503c4e335221c Mon Sep 17 00:00:00 2001 From: Rebecca Cremona Date: Thu, 1 Aug 2024 17:22:25 -0400 Subject: [PATCH 4/4] Lint. --- web/main/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/main/views.py b/web/main/views.py index 2039b1824..a331d59d2 100644 --- a/web/main/views.py +++ b/web/main/views.py @@ -2771,7 +2771,7 @@ def export(request: HttpRequest, node: Union[ContentNode, Casebook], file_type=" file_type, export_options=export_options, ) - except LambdaException as e: + except LambdaException: logger.exception(f"Export of node {node.id} failed.") return render(request, "export_error.html", {"casebook": node}) if response_data is None: