Hedronite · Ops Lesson · 01-Earth-DevOps / Python · Mon 2026-08-17 · Trio #92

Python CloudFormation Custom Resources — the signal that must still fire

The stack does not watch your exception. It watches the URL.

Lesson Class: Ops (DevOps + Python + CloudFormation custom resources)
Sprint: Python track · day 26 · trio #92 · ninth Python visit
Cloud Referent: AWS CloudFormation — ResponseURL PUT, RequestType Create/Update/Delete
Paired Dev: weakref.finalize, atexit, the language-level signal
Paired Cert: DOP-C02 Domain 2 — custom resources, change sets, the wait
Grounding: Gift SQS Lambda handler · Bootcamp ConfigInfrastructure Custom Resources
The post
PUT JSON to ResponseURL even when apply_seat raises. A Lambda error is not the signal.
Physical id
Mint once. A new string on Update is a replacement.
Delete
SUCCESS if the external thing is already gone. Idempotent teardown.
The exception you raised was local. The stack is still waiting.

<!-- hal:authoritative:yaml -->

The stack does not watch your exception. It watches the URL.

§I — Frame

Friday's lesson named a hop that dropped the story. A parent held the run_id and the SIGTERM handler. A child inherited the environment and almost nothing else. Coin of that day: the child that never saw the log context.

Today the hop is the other way around. CloudFormation is the parent. Your Lambda is the child. The parent already has a story: a stack, a RequestId, a logical name, a pre-signed URL. The child is allowed to do almost anything with ResourceProperties. The child is not allowed to vanish.

The event arrives with RequestType (Create, Update, or Delete), ResponseURL, StackId, RequestId, LogicalResourceId, a PhysicalResourceId on Update and Delete, and ResourceProperties. The handler does the work, then puts a JSON body on that URL: Status is SUCCESS or FAILED, plus Reason, PhysicalResourceId, StackId, RequestId, LogicalResourceId, and Data. The verb on the wire is PUT. Operators still call it the post. The stack cares that the body arrived.

If the body never arrives, CloudFormation does not see your traceback. It sees CREATE_IN_PROGRESS until the wait expires, about an hour. The exception was local. The stack is still waiting.

Name the duty. Coin it: the signal that must still fire.

The cloud referent is AWS because today's Cert seat is DOP-C02 Domain 2. 08-14 already stood on AWS STS and EC2. Same cloud two Python days in a row is correct when the Cert overlay is DOP. The topic is the wait contract, not Parameter Store and not another process hop.

§II — Foundations: four facts about the wait

Fact one. Three parties, one wait.

The Bootcamp DOP note names the triangle without decoration (ConfigInfrastructure.md, Custom Resources). The template developer writes a resource whose type is Custom::Something and whose ServiceToken is a Lambda ARN or an SNS topic ARN. The custom resource provider owns that token and decides what Create, Update, and Delete mean. CloudFormation, during the stack operation, sends a request to the token and then waits for a response before it proceeds.

The token must live in the same region as the stack. That is a placement rule, not a preference. A Lambda in us-west-2 cannot be the ServiceToken for a stack in us-east-1. The wait has a geography.

SNS is legal. This lesson writes the Lambda path because the Python handler is the thing that forgets to post. The protocol is the same either way: write SUCCESS or FAILED to the pre-signed URL.

Fact two. The event is a lease on a URL.

The fields you actually read are few.

RequestType is the verb. Create mints. Update mutates or replaces. Delete removes. Your code branches on this string and on nothing else that looks like a verb.

ResponseURL is the lease. It is a pre-signed S3 URL with a short life. When the handler returns, the lease is still the only channel CloudFormation is listening on. Logging to CloudWatch is for you. The URL is for the stack.

StackId, RequestId, and LogicalResourceId are echo fields. You copy them into the response. If you invent a RequestId, the post is a stranger and the wait continues.

PhysicalResourceId is the name the stack will keep. On Create you mint it. On Update you return the same value unless you intend a replacement. On Delete you echo the value you were given. A new physical id on Update is how a "small" property change deletes the old external thing and creates a new one.

ResourceProperties is the input. OldResourceProperties arrives on Update so you can diff.

Fact three. A Lambda handler must finish. This one must finish by posting.

Python for DevOps walks a Lambda that reads Amazon SQS events (Gift, Behrman, Deza, Gheorghiu, "Reading Amazon SQS Events from AWS Lambda"). The posture of that section is the finish: the handler must run the batch to completion, or the queue retries work already half-applied.

A custom-resource handler has the same posture pointed at a different witness. SQS watches the invocation result. CloudFormation watches the URL. Returning without a post is a successful Lambda invocation and a failed stack. Gift's subprocess pages (ch. 3, pp. 117-118) already taught the 08-14 hop as prior-arc: a parent that does not ask the child has lied. Today the parent is CloudFormation. The ask is the URL.

Fact four. Delete is a SUCCESS when the thing is already gone.

Create and Update may post FAILED. Delete that fails because the external object is missing leaves the stack unable to finish teardown: gone in the world, present in the stack. Idempotent delete is the rule. If the seat is already absent, post SUCCESS, echo the PhysicalResourceId, and do not punish a double delete.

§III — Worked example: a wrapper that always posts

The resource is a license seat in a third-party pool. CloudFormation has no AWS::Vendor::Seat. The template developer writes Custom::LicenseSeat. The wait lives in the handler, not in the YAML.

Resources:
  SeatFn:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.12
      Handler: handler.lambda_handler
      Role: !GetAtt SeatFnRole.Arn
      Timeout: 60
      Code:
        ZipFile: placeholder
  LicenseSeat:
    Type: Custom::LicenseSeat
    Properties:
      ServiceToken: !GetAtt SeatFn.Arn
      SeatName: !Ref SeatName
      Pool: production

Timeout: 60 is the Lambda's clock. It is not the stack's clock. The stack will wait far longer than sixty seconds if the function dies before the post. A short function timeout without a finally is how you buy an hour of CREATE_IN_PROGRESS.

The handler below keeps the post in one function and the work in another. The work is allowed to raise. The post is not allowed to be skipped.

import json
import urllib.error
import urllib.request
from typing import Any


def post_signal(event: dict[str, Any], context: Any, status: str,
                reason: str, data: dict[str, Any] | None = None,
                physical_id: str | None = None) -> None:
    body = {
        "Status": status,
        "Reason": (reason or f"log {getattr(context, 'log_stream_name', '?')}")[:1024],
        "PhysicalResourceId": (
            physical_id
            or event.get("PhysicalResourceId")
            or event["LogicalResourceId"]
        ),
        "StackId": event["StackId"],
        "RequestId": event["RequestId"],
        "LogicalResourceId": event["LogicalResourceId"],
        "Data": data or {},
    }
    payload = json.dumps(body).encode("utf-8")
    req = urllib.request.Request(
        event["ResponseURL"],
        data=payload,
        method="PUT",
        headers={
            "content-type": "",
            "content-length": str(len(payload)),
        },
    )
    with urllib.request.urlopen(req, timeout=25) as resp:
        resp.read()


def apply_seat(event: dict[str, Any]) -> dict[str, Any]:
    props = event["ResourceProperties"]
    request_type = event["RequestType"]
    name = props["SeatName"]
    pool = props["Pool"]
    physical = event.get("PhysicalResourceId") or f"{pool}:{name}"
    if request_type == "Create":
        seat = vendor.create_seat(pool, name)
        return {"PhysicalResourceId": physical, "Data": {"SeatId": seat.id}}
    if request_type == "Update":
        vendor.ensure_seat(pool, name)
        return {"PhysicalResourceId": physical, "Data": {"SeatId": name}}
    if request_type == "Delete":
        vendor.delete_seat_if_present(pool, name)
        return {"PhysicalResourceId": physical, "Data": {}}
    raise ValueError(f"unknown RequestType {request_type}")


def lambda_handler(event: dict[str, Any], context: Any) -> None:
    status = "FAILED"
    reason = "handler returned without a result"
    result: dict[str, Any] = {}
    try:
        result = apply_seat(event)
        status = "SUCCESS"
        reason = f"{event['RequestType']} completed"
    except Exception as exc:
        reason = f"{type(exc).__name__}: {exc}"
        raise
    finally:
        try:
            post_signal(
                event,
                context,
                status,
                reason,
                data=result.get("Data"),
                physical_id=result.get("PhysicalResourceId"),
            )
        except urllib.error.URLError as post_exc:
            print(f"signal failed: {post_exc}")

Read the finally before you read the vendor calls. The work can raise. The except records the reason and re-raises so Lambda's error metric still ticks. The finally still runs. That is the signal that must still fire.

PhysicalResourceId is minted once as {pool}:{name} and reused. A new string on Update is a replacement: revoke plus grant, not a tag change. delete_seat_if_present is the idempotent delete; a 404 that becomes FAILED stops teardown on a resource the world already forgot. method="PUT" is the wire. The pre-signed URL is an S3 object. POST will not write it. The lesson title says post because that is the operator word for the body entering CloudFormation's wait. The empty content-type is part of the same contract; a signed URL minted with an empty type rejects a typed body.

§IV — Failure mode: the signal that does not fire

The common bug is not a wrong JSON key. The common bug is an exception that ends the invocation before the post.

def lambda_handler(event, context):
    props = event["ResourceProperties"]
    seat = vendor.create_seat(props["Pool"], props["SeatName"])
    post_signal(event, context, "SUCCESS", "created",
                data={"SeatId": seat.id},
                physical_id=f"{props['Pool']}:{props['SeatName']}")

vendor.create_seat raises. post_signal never runs. Lambda writes a traceback to CloudWatch and returns an error. CloudFormation is not subscribed to that traceback. The stack stays CREATE_IN_PROGRESS. The operator tails the function log, sees the exception, fixes the vendor token, and waits for a retry that will not come. Custom resource invocations are not SQS batches. CloudFormation sent one event. It will wait for one URL write. It will not re-invoke because your function failed.

That is the sibling of 08-14. Friday, the child never saw the log context: the hop dropped the story the parent was telling. Today, the Lambda never posted: the hop dropped the story the parent was waiting to hear. Same class of hole. Different direction.

A second failure is a post that fires with a new physical id on Update.

physical_id = f"{props['Pool']}:{props['SeatName']}:{context.aws_request_id}"

Create accepts that. Update treats it as "this is a different object." CloudFormation deletes the old physical id (a second Delete event) and keeps the new one. The vendor now has two seats or one revoked seat, depending on how Delete is written. The stack looks healthy. The pool does not.

A third failure is a Delete that posts FAILED because the vendor returned 404. The stack cannot finish. The resource remains in DELETE_FAILED. The operator then has to retain, skip, or recreate a thing that does not exist so that a signal can say yes. Post SUCCESS. Echo the id. Leave.

A fourth failure is a SUCCESS post after a partial Create: the vendor minted the seat, a later line raised, and finally posted yes because status was set too early. Order the assignment after the work. A FAILED post after a partial Create is recoverable (rollback sends Delete). A lying SUCCESS is a seat the stack believes in.

The hour-long wait is the tell. If stack events show the custom resource in progress and the Lambda finished minutes ago, the signal did not fire. Do not add a WaitCondition. Open the handler and find the path that returned without a PUT.

§V — Pairing

Today's Dev lesson is the same duty at object lifetime. del deletes a reference. __del__ is not a clock you can set. A cycle can skip it. weakref.finalize is the callback that still runs when the owner is collected. atexit.register is process-lifetime, the wrong clock for a seat you already dropped. The Dev coin is the same phrase. The Ops URL is a stack-lifetime finalize.

Today's Cert lesson is DOP-C02 Domain 2. The Bootcamp stem: a FAILED response or no response fails the operation. Change sets, drift, nested stacks, deletion policies, and stack policies sit around that wait. AWS Config is the witness outside the stack. This lesson is the Python that makes the signal come.

08-11 is a ceiling, not a topic: one invocation, not four hundred coroutines that hang before the post. The 08-08 retry budget belongs on the vendor call inside apply_seat, not around post_signal. Retrying the PUT is legal. Retrying Create after a SUCCESS post mints a second seat the stack will never name.

§VI — Drills

Question 1
A custom-resource Lambda raises vendor.AuthError in create_seat and has no finally. The function error rate is 100 percent. What does the stack show for the next hour, and why is the CloudWatch traceback not the signal?
tap to reveal
CREATE_IN_PROGRESS (then CREATE_FAILED on timeout). CloudFormation waits on the ResponseURL PUT, not on the Lambda error. The traceback is for the operator. The URL is for the stack.
Question 2
An Update changes only Pool from staging to production. The handler returns a new PhysicalResourceId. What does CloudFormation do next, and what must Delete tolerate?
tap to reveal
It treats the resource as replaced: Delete arrives for the old id, Create semantics keep the new one. Delete must SUCCESS if the old seat is already gone.
Question 3
Delete runs. The vendor returns 404. The handler posts FAILED with reason "missing." The operator wants the stack gone. What should the handler have posted, and which field must be echoed?
tap to reveal
SUCCESS, with the PhysicalResourceId from the event. Idempotent delete is the teardown contract.

Related

🫡 ⚖️ 📜 Leo.Syri — Praetor Consulate, Imperium Luminaura Filed 2026-08-17 · Fajr · sprint track Python day 26 · ninth Python visit · trio #92

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-17 at Fajr · Trio #92 · sprint day 26 · Python track
Ops · Dev · Cert trio shipped MD + HTML in-cycle