AWS and Google Cloud events
EventBridge, SNS and Pub/Sub can each call an HTTPS address when something happens. Give them a trigger URL, and every event starts a run.
What you'll build
One workflow, cloud-events, with a trigger for each source: an EventBridge rule through an API destination, an SNS topic through an HTTPS subscription, and a Pub/Sub topic through a push subscription. The workflow reads each source's format, confirms the SNS subscription itself, and handles each message once, since these services can deliver a message more than once.
- Deploy the workflow
It works out the source from the body.
- Create a trigger for each source
One per source, so you can rotate one URL without touching the others.
- Point each source at its trigger
An API destination, an HTTPS subscription or a push subscription.
- Send a test message
Then find its run.
shiftlabs doesn't read queues itself. A queue you pull from, such as SQS or Kafka, can reach a trigger the same way through an EventBridge pipe with an API destination as its target.
Write the workflow
A trigger passes the body as payload: parsed JSON when it's sent as application/json, as EventBridge and Pub/Sub send it, and the raw text otherwise. SNS sends text/plain, so its payload is a string of JSON.
import { workflow } from "shiftlabs";
type Message = { source: string; id: string; body: any };
// One delivery from EventBridge, SNS or Pub/Sub, as the trigger passed it.
function read(payload: any): Message {
if (typeof payload === "string") {
const sns = JSON.parse(payload);
return { source: "sns", id: sns.MessageId, body: sns };
}
if (payload.message?.messageId) {
const bytes = Uint8Array.from(atob(payload.message.data ?? ""), (c) =>
c.charCodeAt(0),
);
const text = new TextDecoder().decode(bytes);
return { source: "pubsub", id: payload.message.messageId, body: text };
}
return { source: "eventbridge", id: payload.id, body: payload.detail };
}
// A UTC date, offset days ago.
const day = (offset: number) =>
new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
export default workflow(async ({ payload, step, state, runId, log }) => {
const { source, id, body } = read(payload);
if (source === "sns" && body.Type === "SubscriptionConfirmation") {
const host = new URL(body.SubscribeURL).hostname;
if (!host.startsWith("sns.") || !host.endsWith(".amazonaws.com")) {
throw new Error(`not an SNS address: ${host}`);
}
await step.run("confirm subscription", async () => {
const r = await fetch(body.SubscribeURL);
if (!r.ok) throw new Error(`SNS answered ${r.status}`);
});
return "subscribed";
}
const seen = `${source}:${id}`;
const fresh = await step.run("claim", async () => {
// Forget ids from before yesterday, so the keys don't pile up.
for (const key of await state.list({ prefix: "seen:", limit: 20 })) {
if (key < `seen:${day(1)}`) await state.delete(key);
}
for (const d of [day(1), day(0)]) {
const claimed = await state.get<{ runId: string }>(`seen:${d}:${seen}`);
if (claimed) return claimed.runId === runId;
}
await state.set(`seen:${day(0)}:${seen}`, { runId });
return true;
});
if (!fresh) return "duplicate";
await step.run("handle", async () => {
log.info("message", { source, id });
});
return id;
});
import base64
import json
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse
from workers import fetch
from shiftlabs import workflow
def read(payload):
"""One delivery from EventBridge, SNS or Pub/Sub: (source, id, body)."""
if isinstance(payload, str):
sns = json.loads(payload)
return "sns", sns["MessageId"], sns
if "message" in payload:
message = payload["message"]
text = base64.b64decode(message.get("data", "")).decode()
return "pubsub", message["messageId"], text
return "eventbridge", payload["id"], payload["detail"]
def day(offset):
"""A UTC date, offset days ago."""
then = datetime.now(timezone.utc) - timedelta(days=offset)
return then.strftime("%Y-%m-%d")
@workflow
async def cloud_events(ctx):
source, message_id, body = read(ctx.payload)
if source == "sns" and body["Type"] == "SubscriptionConfirmation":
url = body["SubscribeURL"]
host = urlparse(url).hostname or ""
if not (host.startswith("sns.") and host.endswith(".amazonaws.com")):
raise ValueError(f"not an SNS address: {host}")
async def confirm():
r = await fetch(url)
if not r.ok:
raise Exception(f"SNS answered {r.status}")
await ctx.step.run("confirm subscription", confirm)
return "subscribed"
seen = f"{source}:{message_id}"
async def claim():
# Forget ids from before yesterday, so the keys don't pile up.
for key in await ctx.state.list(prefix="seen:", limit=20):
if key < f"seen:{day(1)}":
await ctx.state.delete(key)
for d in (day(1), day(0)):
claimed = await ctx.state.get(f"seen:{d}:{seen}")
if claimed:
return claimed["runId"] == ctx.run_id
await ctx.state.set(f"seen:{day(0)}:{seen}", {"runId": ctx.run_id})
return True
if not await ctx.step.run("claim", claim):
return "duplicate"
async def handle():
ctx.log.info("message", {"source": source, "id": message_id})
await ctx.step.run("handle", handle)
return message_id
- The claim remembers each message id for at least a day. An unsigned trigger drops a repeat by itself only when the sender sets an
Idempotency-Key, which these services don't. - The claim stores the run's id, so if an attempt stops after
state.set, the step's retry finds its own claim and carries on. See State. - Each id is one state key, kept for up to two days. Free allows 1,000 keys per workflow and Pro 10,000, so past about 500 messages a day on Free a claim's
state.setfails. See Limits. - Anyone with the URL can post to an unsigned trigger, so the workflow fetches only an SNS address for a confirmation. Treat the rest of the body as untrusted, as any webhook's.
curl -X PUT https://api.shiftlabs.org/v1/workflows/cloud-events \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
--data-binary @cloud-events.ts
# Run this once per source. Answers 201 {"triggerId", "url"}.
curl -X POST https://api.shiftlabs.org/v1/triggers \
-H "authorization: Bearer $SHIFTLABS_API_KEY" \
-H "content-type: application/json" \
-d '{"workflow": "cloud-events"}'
EventBridge
An API destination sends each event a rule matches to an HTTPS address, as the whole event in JSON: id, source, detail-type, time and detail.
# A connection needs an authorization method. The trigger doesn't check it,
# so any API key name and value will do.
aws events create-connection --name shiftlabs \
--authorization-type API_KEY \
--auth-parameters '{"ApiKeyAuthParameters":
{"ApiKeyName": "x-shiftlabs-unused", "ApiKeyValue": "unused"}}'
# A trigger takes 100 deliveries per 10 seconds: send at most 10 a second.
aws events create-api-destination --name shiftlabs-cloud-events \
--connection-arn <connection ARN> \
--invocation-endpoint https://hooks.shiftlabs.org/t/<triggerId> \
--http-method POST \
--invocation-rate-limit-per-second 10
# The role needs events:InvokeApiDestination on the API destination.
aws events put-targets --rule <rule name> \
--targets 'Id=shiftlabs,Arn=<API destination ARN>,RoleArn=<role ARN>'
EventBridge waits 5 seconds for an answer, and retries a 429 or a 5xx for up to 24 hours, waiting at least the trigger's Retry-After. It doesn't retry other 4xx answers. Give the rule a dead-letter queue to keep events it couldn't deliver. See API destinations.
SNS
An HTTPS subscription posts each message published to the topic, wrapped in SNS's JSON with Type, MessageId and Message. The first post is a SubscriptionConfirmation, and the workflow above visits its SubscribeURL to confirm it.
aws sns subscribe --topic-arn <topic ARN> --protocol https \
--notification-endpoint https://hooks.shiftlabs.org/t/<triggerId>
# Confirmed once the subscription has an ARN instead of PendingConfirmation.
aws sns list-subscriptions-by-topic --topic-arn <topic ARN>
The message you published is the string in body.Message. SNS's wrapper adds to its size, and a trigger refuses a body over 256 KiB with 413. See HTTP/HTTPS headers and delivery retries.
Pub/Sub
A push subscription posts each message as JSON: message.data holds what you published, in base64, and message.messageId its id. A 202 or 200 from the trigger acknowledges it, and any other answer makes Pub/Sub send it again.
gcloud pubsub subscriptions create shiftlabs-cloud-events \
--topic=<topic> \
--push-endpoint=https://hooks.shiftlabs.org/t/<triggerId>
Pub/Sub resends a message until it's acknowledged or expires, so a message a trigger refuses for good, such as one over 256 KiB, comes back until then. Base64 makes a message about a third larger. Give the subscription a dead-letter topic to set such messages aside. See Push subscriptions.
Check it worked
aws sns publish --topic-arn <topic ARN> --message '{"order": "ord_123"}'
gcloud pubsub topics publish <topic> --message='{"order": "ord_123"}'
# Each source's run, newest first, with origin "trigger".
curl "https://api.shiftlabs.org/v1/runs?workflow=cloud-events&limit=5" \
-H "authorization: Bearer $SHIFTLABS_API_KEY"
- The first SNS run
Returns
subscribed, and the subscription shows an ARN. - Every other run
Returns the message's id, and its logs show
messagewith the source. A repeat of a message returnsduplicate. - A refused delivery
A source whose deliveries get
429is sending faster than the trigger takes them; see Request rates. A404means the trigger was revoked or rotated: point the source at the new URL.
Reference
- Deploy and triggers
Every answer a trigger gives, and rotating its URL.
- State
The values the claim keeps, and their limits.
- Rate limits and retries
What a
429or503from a trigger means, and how senders retry. - Limits
Deliveries per trigger and per workspace, and state keys on each plan.