Functions + Pub/Sub integration
This integration turns a message queue into a worker pipeline. Pub/Sub transports the event and the Function processes it. The platform handles delivery and retries, and dead-letters a message that keeps failing — once you have configured a dead-letter topic. Without one, a message that exhausts its delivery attempts is dropped.
Use it when
- work arrives asynchronously,
- each message is independent,
- you want retries and a dead-letter topic without writing your own scheduler.
The pieces
- Pub/Sub topic — the durable channel for events.
- Push subscription — calls your function with each message.
- Function — the worker.
- Dead-letter topic — where repeated failures go. It has to exist before the subscription names it, and it must not be the subscription's own topic.
Step 1: Deploy the worker
Write a function that accepts the event payload and returns quickly. Keep it deterministic and idempotent when you can.
def handle(event):
order_id = event.get("order_id", "unknown")
total = event.get("total", 0)
print(f"processing order {order_id} for {total}")
return {"ok": True}
Deploy it like any other function.
A published JSON object arrives as the event itself: the shim unwraps the CloudEvent envelope — including the base64 data_base64 field every Pub/Sub delivery uses — before calling handle(). A message that is not JSON arrives as event["data"].
Two consequences worth knowing before the first failure:
- The return value is discarded. A push delivery is a CloudEvent, so the shim ACKs with an empty
204and throws the result away. Do the work insidehandle(). - Any non-2xx is a failed attempt. A handler exception becomes a
400, which the push worker counts as an attempt and retries with exponential backoff (1 s, 2 s, 4 s, capped at 60 s) until the message is dead-lettered or dropped.
Step 2: Create the topics
Create a work topic and a dead-letter topic.
- platformctl
- curl
- Console
platformctl pubsub topics create orders --max-bytes 16Mi --discard old
platformctl pubsub topics create orders-dead --max-bytes 16Mi --discard old
curl -sX POST "$CAI_PUBSUB_API/v1/projects/$CAI_PROJECT/topics" \
-H "Authorization: Bearer ***" -H 'Content-Type: application/json' \
-d '{"name":"orders","max_bytes":"16Mi","discard":"old"}'
curl -sX POST "$CAI_PUBSUB_API/v1/projects/$CAI_PROJECT/topics" \
-H "Authorization: Bearer ***" -H 'Content-Type: application/json' \
-d '{"name":"orders-dead","max_bytes":"16Mi","discard":"old"}'
- Open Messaging → Pub/Sub.
- Create
ordersandorders-dead.
Step 3: Wire the subscription
Create a push subscription that targets the function and dead-letters repeated failures.
$FN_URL is the function's internal address, not its public endpoint. Push delivery is made from inside the platform and is validated against your own project, so the target must be the private http:// address the platform assigns the function. Paste the public HTTPS endpoint instead and it is refused twice over — first for the scheme, with a message saying https has no trust story for internal delivery yet, and then for the host:
deliver.push.url is outside this project. A push target must be a workload in this project, and the address to use is the one the platform already gives you: the "url" field on that workload, which 'platformctl status <workload>' prints.
Neither refusal points at the real mistake, which is using the public endpoint. Copy that address rather than assembling it by hand, then append the port and path your handler listens on if they are not the defaults:
export FN_URL=$(platformctl status order-worker -o json | jq -r .url)
# an internal http:// address, reachable only from workloads in this project
If you do not need to own the subscription's settings, a Pub/Sub trigger does this wiring for you: it resolves the function's address itself and creates a shared push subscription that starts from new messages. Build the subscription by hand, as below, when you want to choose the type, the start point, or the ack deadline.
- platformctl
- curl
- Console
platformctl pubsub subscriptions create to-worker \
--topic orders \
--type shared \
--push-url "$FN_URL" \
--max-deliver 3 \
--dead-letter-topic orders-dead \
--dead-letter-after-attempts 3
curl -sX POST "$CAI_PUBSUB_API/v1/projects/$CAI_PROJECT/topics/orders/subscriptions" \
-H "Authorization: Bearer ***" -H 'Content-Type: application/json' \
-d '{"name":"to-worker","type":"shared","max_deliver":3,
"deliver":{"mode":"push","push":{"url":"'"$FN_URL"'"}},
"dead_letter":{"topic":"orders-dead","after_attempts":3}}'
Delivery and dead-lettering are nested objects, not flat keys. The API rejects a body with a field it does not know, so a flat push_url does not quietly create a pull subscription — it answers:
400 request body is not valid JSON: json: unknown field "push_url"
A 201 therefore means every field you sent was understood.
- Open the topic
orders. - Add a subscription named
to-workerand set Delivery to push. - Put the function's internal address in Push URL. The field's own placeholder shows the shape —
http://my-worker.<this project's internal name>:8080/events— not the public HTTPS endpoint. The form checks the shape before it submits, and explains what is wrong in the form rather than in a 400. - Set Max deliver, then pick
orders-deadunder Dead-letter topic and set Dead-letter after.
Step 4: Publish a message
Publish one test message and watch the function log the delivery.
Successful delivery looks like a normal function invocation. Repeated failure lands in orders-dead after three attempts, carrying the original payload plus forensic attributes — cai-dead-letter-reason (target responded 400, say), cai-dead-letter-attempts, cai-dead-letter-subscription and the source topic. That is the point of the dead-letter topic: a poison message stops costing attempts and becomes something you can read.
Operational notes
- The defaults, when you leave a field out:
max_deliver5,ack_deadline_seconds30,max_ack_pending1000, and — only if you name a dead-letter topic —after_attempts5. There is no dead-letter topic unless you configure one, and without one a message that exhaustsmax_deliveris dropped, not parked. ack_deadline_secondsis also the push request timeout. At the default, a handler that takes longer than 30 seconds is cut off and the message is redelivered — and that budget has to cover a scale-to-zero cold start, not just the work.- A function should be idempotent because redelivery is possible: a slow handler is retried while the first attempt may still be running.
- Fail fast if the payload is malformed; do not turn a bad payload into a long retry loop. A permanent failure is worth dead-lettering on the first or second attempt.
- Keep the payload small enough that retries stay cheap. The function shim refuses a body over 8 MiB with
413 body exceeds 8388608 bytes.
Next steps
- Pub/Sub triggers — the same pipeline with the subscription managed for you.
- Event-driven functions
- Functions overview
- Pub/Sub overview