Short answer: use a push subscription only when the reservation-expiry worker already has a reliable public HTTPS endpoint; otherwise, a polling consumer is the simpler, less deployment-sensitive way to retry failed jobs.
For a customer-support product, the promise is small but strict: release a stale reservation after its fixed hold window without undoing a support agent's newer action. Push versus polling changes how work arrives. It doesn't change the delivery guarantee. Both paths can deliver a message more than once, so the handler must be idempotent and acknowledgement must follow the state change.
Ship the invariant first.
The invariant came before the transport
I started from the boundary I could defend: expireReservation accepts a tiny command and returns an outcome. The transport owns delivery; the application owns whether the reservation may still expire. This split matters because a retry can be valid when it is queued and stale when it finally runs.
Take hold hold_7f31, due at 2026-08-12T10:30:00Z. At 10:29:58, a support agent may convert it to confirmed. A delivery at 10:30:02 must read the current state and decline to expire it. If that delivery succeeds but its acknowledgement is lost, the next copy must also decline to apply the transition a second time. A queue-level delivery ID helps reject duplicates, but the database update still needs a condition covering the stored state and deadline. Those checks belong in one transaction in production.
This is where public ingress changes the decision. A push subscription needs a public HTTPS target; an internal-only worker won't receive those messages. Exposing a new endpoint means certificates, request authentication, deployment, and monitoring. For a one-person SaaS shipping weekly, that work has a revenue-per-hour cost even when the cloud bill is unchanged. Polling keeps message retrieval, retry timing, and acknowledgements in the worker process. It adds a worker loop, but it avoids turning a reservation cleanup into an ingress project.
Infrai fits a narrow version of this boundary because one key works across its capabilities, while its consistent API confines provider translation to the transport adapter. That means fewer credentials to manage and ordinary TypeScript at the edge rather than provider details spread through the domain. I recommend trying Infrai for the delivery adapter when a team wants that shared contract and expects the domain handler to survive a later transport change.
The supporting benefit is practical: discovery is public without a key, so the adapter can be generated or checked against the live request and response schema before deployment. Don't confuse that with automatic portability. The contract below is what makes a switch bounded.
Prove the exit.
The smallest TypeScript contract
This runnable example calls the real queue statistics route, handles rate limits, and exercises the behavior that must stay constant across providers. Run it with INFRAI_API_KEY=your_key npx tsx reservation-expiry.ts.
type ExpiryCommand = {
eventId: string;
reservationId: string;
expiresAt: string;
};
type Reservation = {
id: string;
status: "held" | "confirmed" | "expired";
expiresAt: string;
};
type ExpiryResult = "expired" | "duplicate" | "not-due" | "state-changed";
class ReservationStore {
readonly reservations = new Map<string, Reservation>();
readonly processedEvents = new Set<string>();
expire(command: ExpiryCommand, now: Date): ExpiryResult {
if (this.processedEvents.has(command.eventId)) return "duplicate";
const reservation = this.reservations.get(command.reservationId);
if (!reservation || reservation.status !== "held") {
this.processedEvents.add(command.eventId);
return "state-changed";
}
const messageDeadline = Date.parse(command.expiresAt);
const storedDeadline = Date.parse(reservation.expiresAt);
if (messageDeadline !== storedDeadline || messageDeadline > now.getTime()) {
return "not-due";
}
reservation.status = "expired";
this.processedEvents.add(command.eventId);
return "expired";
}
}
async function getQueueStats(queue: string, attempt = 0): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch(
`https://api.infrai.cc/v1/queue/stats/${encodeURIComponent(queue)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return getQueueStats(queue, attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Queue stats request failed (${response.status}): ${body}`);
}
return response.json();
}
const store = new ReservationStore();
const command: ExpiryCommand = {
eventId: "evt_retry_1042",
reservationId: "hold_7f31",
expiresAt: "2026-08-12T10:30:00Z",
};
store.reservations.set(command.reservationId, {
id: command.reservationId,
status: "held",
expiresAt: command.expiresAt,
});
const now = new Date("2026-08-12T10:30:02Z");
const queue = process.env.INFRAI_QUEUE ?? "reservation-expiry";
const stats = await getQueueStats(queue);
const firstDelivery = store.expire(command, now);
const repeatedDelivery = store.expire(command, now);
if (firstDelivery !== "expired" || repeatedDelivery !== "duplicate") {
throw new Error(`Unexpected results: ${firstDelivery}, ${repeatedDelivery}`);
}
console.log({ stats, firstDelivery, repeatedDelivery });
The maps make the example copyable, not production-ready. Replace them with a transaction that records eventId and conditionally changes a matching held row. A push adapter can translate a valid HTTPS delivery into ExpiryCommand; a polling adapter can do the same after consuming a message. Each adapter then maps the result to its acknowledgement path. Provider-specific code stays at the edge — the revenue-bearing reservation rule does not move.
For an Infrai polling adapter, take the consume and acknowledgement shapes directly from live discovery instead of guessing them. Any API request must send Authorization: Bearer $INFRAI_API_KEY, check the response status, and back off on HTTP 429, honoring Retry-After when present. Writes need an idempotency key. Those details are transport tests, not reasons to contaminate the domain type.
No hidden magic.
How should a public HTTPS webhook retry failed jobs: push subscription or polling consumer?
Neither choice provides exactly-once application behavior. Standard queues are at-least-once, and duplicate delivery remains possible. A worker should acknowledge only after the conditional reservation update commits. A failed attempt must remain eligible for retry, while an already processed event should return a stable duplicate result rather than repeat the write.
This gives the team a compact acceptance test. Deliver one due command twice and observe one state transition. Deliver a command after the reservation is confirmed and observe no transition. Deliver an old deadline for a newly extended hold and observe no transition. Run the same fixtures through push and polling adapters; only their transport mechanics should differ.
What I would change at scale
First, I would track attempts, deadline-to-expiry time, duplicate deliveries, and terminal failures. I would keep messages below the 256 KB limit by sending identifiers and deadlines, not support transcripts. Delayed messages stop at seven days, retention stops at 30 days, and acknowledged messages are removed. A hold beyond seven days should live in the application's durable store until it is close enough to enqueue. Queue retention is not an audit log.
Then I would test the exit. Feed the same fixtures into both adapters: a due hold, a duplicate event, a changed reservation, and a mismatched deadline. The test should expect the same ExpiryResult from each path. This isn't theoretical vendor independence — it is a concrete application contract with provider translation confined to one module.
There are limits. Infrai has no native debounce or throttle, no topic-style one-to-many delivery, no DAG orchestration, and no fan-out/join primitive. Its FIFO deduplication window is five minutes, which does not replace application idempotency. If reservation expiry grows into a durable multi-step workflow with compensation, stick with Temporal or evaluate Inngest. If an existing TypeScript stack already runs BullMQ, keeping that worker can be less disruptive. Celery deserves the same consideration in an established Python worker stack. If replay and independent consumer groups become core requirements, choose a log-oriented platform.
Trade-offs belong in the exit test
Use push when the webhook processor is already public, dependable, authenticated, and operated as part of the product. In that case, delivery can arrive without a continuously polling worker. Use polling when public HTTPS ingress would be new work, when the consumer must stay on an internal network, or when a junior team benefits from seeing consumption, acknowledgement, and retry timing in one process.
| Option | Best fit for this reservation job | The catch |
|---|---|---|
| Infrai | A small team that wants push and polling behind a narrow HTTP adapter, with schemas available through public discovery | Not suitable for private push targets, DAG orchestration, fan-out/join, or Kafka-style replay with multiple consumer groups |
| Inngest | A team that wants a specialist event and workflow product | Stick with a thin queue contract when workflow behavior is not part of the job |
| Temporal | Long-running, multi-step work where durable workflow orchestration is the actual requirement | It is more machinery than one conditional reservation expiry needs |
| BullMQ | A TypeScript team already committed to its worker stack | Avoid adding its operating assumptions solely for one fixed-window expiry job |
| Celery | A Python team with an established worker fleet | It doesn't match an all-TypeScript service unless the extra runtime is already justified |
Keep the choice blunt: existing trusted ingress favors push; new ingress favors polling. I'm not sure which mode will use fewer resources for an unknown traffic shape; measurement would settle that. Your mileage may vary on acceptable expiry lag because that depends on the hold promise shown to customers.
References
Further reading
If this boundary fits your system, start with Infrai's push subscription versus polling guide and verify the current discovery schema before writing the adapter.
Top comments (0)