Every send settles in a known state, even under timeouts, cancellation, or overload. This page covers the outcome model end to end: how idempotency makes retries safe, what each delivery state means, and how structured concurrency and backpressure behave.
Idempotency and delivery outcomes#
Repost dedupes on the idempotency key. Omit it and the runtime generates a stable key per operation and reuses it across retries; pass one (the idempotencyKey parameter on any send) to make a send safe to repeat.
Every failure is a RepostException carrying a stable errorCode, a best-known deliveryState, the idempotencyKey, and an isRetryable flag, never a raw server body or nested throwable. The five delivery states:
DeliveryState | Meaning | What to do |
|---|---|---|
ACCEPTED | Repost accepted the event. | Done: you get a SendResult, not an exception. |
NOT_SENT | The request definitely never left. | Safe to retry with the same key. |
POSSIBLY_SENT | The request may have been received; the response was lost, ambiguous (HTTP 409), or the deadline elapsed mid-flight. | Reuse the same idempotency key and reconcile, never mint a new key, or you risk a duplicate. |
REJECTED | Repost definitively rejected it (a non-retryable 4xx). | Fix the request; do not retry blindly. |
CANCELLED_UNKNOWN | You cancelled after the request may have been sent. | Same as POSSIBLY_SENT: reconcile with the same key. |
try {
val result = repost.webhooks.order.created(customerId = "customer-123") { id = "order-123" }
println("accepted ${result.id}")
} catch (failure: RepostException) {
when {
// Repost may already have the event. Reuse the SAME idempotency key and
// reconcile — never mint a new key, or you risk a duplicate delivery.
failure.deliveryState == DeliveryState.POSSIBLY_SENT -> reconcile(failure.idempotencyKey)
// Definitely not sent and safe to retry with the same key.
failure.isRetryable -> retryLater(failure.idempotencyKey)
// Terminal failure; the code is stable and carries no payload or credentials.
else -> throw failure
}
}The subclass names the category: RepostConfigurationException, RepostValidationException, RepostSerializationException, RepostTransportException (connect/TLS/timeout/cancel/overload), RepostPublishException (server rejection or retryable failure), and RepostDescriptorVersionException (regenerate or upgrade).
Coroutines and cancellation#
A suspend send suspends the coroutine without blocking a thread and honours structured concurrency: cancelling the coroutine cancels the underlying operation, and the coroutine sees a native CancellationException. To reconcile after a cancellation, keep the operation handle from createdOperation(...) and read its outcome(), which settles even when the send is cancelled:
val operation = repost.webhooks.order.createdOperation("customer-123", Order { id = "order-123" })
// Cancelling the coroutine that awaits the send also cancels this operation; here we
// cancel it directly. The delivery outcome still settles and never throws.
operation.cancel(true)
operation.outcome().thenAccept { outcome ->
if (outcome.deliveryState == DeliveryState.CANCELLED_UNKNOWN) {
// The request may have reached Repost. Reconcile with the same key.
reconcile(outcome.idempotencyKey)
}
}Cancelling before the request commits settles NOT_SENT; cancelling after it may have been written settles CANCELLED_UNKNOWN. Either way the operation settles exactly once and closes only the work it owns.
Bounded admission#
maxInFlightOperations is a hard bulkhead, not a queue: when it is exceeded, a send fails immediately with an OVERLOADED error and NOT_SENT. Treat it as backpressure, back off, and retry. The runtime never buffers unbounded work or paces your traffic.