Running in production
This guide runs an SQS service on AWS: where its credentials and region come from, the timeouts it runs with, and the IAM permissions it needs.
Credentials and region from the environment
SqsBroker::new() takes nothing from the code.
The AWS SDK's default chain finds the region and the credentials when the service starts: environment variables, the shared ~/.aws files, SSO, and the role of the container or the instance.
A service that runs under an IAM role on ECS, EKS, Lambda or EC2 needs no configuration at all.
The SDK reads credentials when it signs its first request.
That request is the GetQueueUrl of the first subscription, at startup, so a service with wrong credentials stops before it handles a message.
A configuration of your own
- Macros
- Manual
/// The AWS configuration of the service: a pinned behaviour version, its region, the
/// credentials of the `orders-service` profile, read when the first request is signed, and a
/// request timeout above the 20 seconds a long poll waits.
fn aws_config() -> SdkConfig {
let credentials = ProfileFileCredentialsProvider::builder()
.profile_name("orders-service")
.build();
SdkConfig::builder()
.behavior_version(BehaviorVersion::v2026_01_12())
.region(Region::new("eu-west-1"))
.credentials_provider(SharedCredentialsProvider::new(credentials))
.timeout_config(
TimeoutConfig::builder()
.operation_attempt_timeout(Duration::from_secs(25))
.build(),
)
.build()
}
/// The AWS configuration of the service: a pinned behaviour version, its region, the
/// credentials of the `orders-service` profile, read when the first request is signed, and a
/// request timeout above the 20 seconds a long poll waits.
fn aws_config() -> SdkConfig {
let credentials = ProfileFileCredentialsProvider::builder()
.profile_name("orders-service")
.build();
SdkConfig::builder()
.behavior_version(BehaviorVersion::v2026_01_12())
.region(Region::new("eu-west-1"))
.credentials_provider(SharedCredentialsProvider::new(credentials))
.timeout_config(
TimeoutConfig::builder()
.operation_attempt_timeout(Duration::from_secs(25))
.build(),
)
.build()
}
SqsBroker::from_config takes an SdkConfig the service builds.
aws_config builds one from the environment with an async loader, while the app function is synchronous.
So the example builds it with SdkConfig::builder(), which does no I/O.
The profile provider reads the orders-service profile on the first request; a profile with a role_arn assumes that role.
The pinned behaviour version keeps the SDK's defaults from changing under the service when the SDK is updated.
The configuration also carries the request timeout described below: the broker keeps every setting of the configuration except an endpoint or a region named on the broker itself.
The example needs two more dependencies:
[dependencies]
aws-config = "1"
aws-sdk-sqs = "1"
The service
- Macros
- Manual
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "orders")]
struct Order {
id: u64,
amount_cents: u64,
}
// The queue belongs to the infrastructure, so the descriptor does not create it. It names its
// visibility, so the subscription does not read the queue's own.
#[subscriber(SqsQueue::new("orders").visibility(Duration::from_secs(120)))]
async fn take_order(order: &Order) -> HandlerOutcome {
if order.amount_cents == 0 {
tracing::warn!(order.id, "order without an amount dropped");
return HandlerOutcome::drop();
}
tracing::info!(order.id, order.amount_cents, "order taken");
HandlerOutcome::ack()
}
#[ruststream::app]
fn app() -> impl App {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
SqsBroker::from_config(aws_config()),
|b| {
b.include(take_order);
},
)
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Order {
id: u64,
amount_cents: u64,
}
impl OutgoingDestination for Order {
type Form = FixedName;
const DESTINATION: &'static str = "orders";
}
impl MessageHeaders for Order {
type Contract = NoHeaders;
}
impl MessageInfo for Order {
const NAME: &'static str = "Order";
}
struct TakeOrder;
impl Handle<Order> for TakeOrder {
fn handle(
&self,
order: &Order,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
if order.amount_cents == 0 {
tracing::warn!(order.id, "order without an amount dropped");
return ready(Err(HandlerOutcome::drop()));
}
tracing::info!(order.id, order.amount_cents, "order taken");
ready(Ok(()))
}
}
fn app() -> impl App {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
SqsBroker::from_config(aws_config()),
|b| {
// The queue belongs to the infrastructure, so the descriptor does not create it. It
// names its visibility, so the subscription does not read the queue's own.
b.include(
subscriber("orders", TakeOrder)
.map_source(|_| SqsQueue::new("orders").visibility(Duration::from_secs(120)))
.build(),
);
},
)
}
fn main() -> ExitCode {
run_main(app)
}
The orders queue belongs to the infrastructure: Terraform, CloudFormation or the console create it, with its redrive policy and encryption.
The descriptor leaves out create_if_missing, so a missing queue stops the service at startup instead of creating an empty one.
It names its visibility, so the subscription does not read the queue's own timeout.
endpoint and region point the broker at another endpoint, a VPC endpoint or a local stack.
On a broker built with from_config they replace the configuration's own endpoint_url and region.
The queue URLs SQS returns are rebased onto the endpoint.
Timeouts and transport
SqsBroker::new() gives each SQS request 25 seconds per attempt, above the 20 seconds a long poll may wait.
A configuration of your own sets this with timeout_config, as the example does; an attempt timeout under 20 seconds would end every long poll.
The SDK retries a failed request with its own retry policy.
Requests go over the SDK's default HTTPS client.
A handler on a dedicated thread (threads(n)) sends through SDK clients of its own, built from the same configuration and sharing its credentials.
IAM permissions
The service above needs these permissions on its queue:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"sqs:GetQueueUrl",
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:ChangeMessageVisibility"
],
"Resource": "arn:aws:sqs:eu-west-1:123456789012:orders"
}
]
}
Each feature adds its own:
| Feature | Permissions |
|---|---|
| a subscription that names no visibility | sqs:GetQueueAttributes on the queue |
max_attempts and dead_letter | sqs:SetQueueAttributes on the queue, sqs:GetQueueAttributes on the dead-letter queue |
create_if_missing | sqs:CreateQueue |
| a publish to a queue | sqs:GetQueueUrl and sqs:SendMessage on the queue |
| a publish to a topic | sns:CreateTopic and sns:Publish on the topic |
subscribe_queue_to_topic | sns:CreateTopic and sns:Subscribe on the topic, sqs:GetQueueUrl, sqs:GetQueueAttributes and sqs:SetQueueAttributes on the queue |
What the AsyncAPI document shows
With the asyncapi feature, the service's AsyncAPI document names one server: the host of the broker's endpoint, or else the regional host of its region, such as sqs.eu-west-1.amazonaws.com.
The document never holds a credential or a queue's ARN, so it can be published.
The tests in the example file start the same app in process; the in-process SQS takes the region from the configuration and signs nothing. Testing an SQS service covers the harness.