TL;DR: DFINITY is announcing pay-as-you-go pricing for canister HTTPS outcalls, selected per call with pricing_version = 2. The new model charges for the resources a call actually consumes, not the response size it reserves. Version 1 is the deprecated default, and migrating to the new pricing is strongly recommended. Version 2 reduces the cost of most canisters, and avoids intermittent failures once the default changes.
Background
Until now, version 1 was the only way to price an outcall. It prices a call on what it might consume. The fee is fixed when the call is made, based on the request size and max_response_bytes. It is charged in full regardless of whether the response comes back at only a couple of bytes, or if it exhausts the whole limit:
n = subnet size
total_fee = base_fee + size_fee
base_fee = (3_000_000 + 60_000 * n) * n
size_fee = (400 * request_bytes + 800 * max_response_bytes) * n
The request_bytes are the sum of the request’s variable parts, namely the URL, all header names and values, the body, and the transform method name and context.
The 800 per reserved response byte dominates the fee. max_response_bytes defaults to 2 MB, so a canister that does not set it pays for 2 MB on every node. On a 13-node subnet that is about 20.85 billion cycles for a response that may well be a few hundred bytes of JSON.
Controlling the fee therefore means estimating the largest response the API might return, and setting max_response_bytes as close to that estimate as possible. However, the challenge is that setting it too small fails the call, whereas setting it too large wastes cycles on headroom that is never refunded.
Instead, pay-as-you-go pricing only charges a base fee in advance, and splits the remaining cycles attachment into per-replica allowances, that are consumed as work is being done. In the end, any unspent cycles are refunded.
How to use it
In Rust, version 2 arrives with ic-cdk-management-canister 0.2:
ic-cdk-management-canister = "0.2"
The release removes the free http_request function, and its replacement is a builder that always selects version 2. The builder computes the recommended cycles reservation with ic0.cost_http_request_v2 and attaches it:
use ic_cdk_management_canister::{HttpRequest, transform_context_from_query};
// cap the response at 4_000 bytes, and reserve for a 300 ms round trip
// and a transform using 1_000_000 instructions
let response = HttpRequest::new("https://example.com/api")
.with_max_response_bytes(4_000)
.with_transform(transform_context_from_query("transform".to_string(), vec![]))
.with_expected_roundtrip_time_ms(300)
.with_expected_transform_instructions(1_000_000)
.send()
.await?;
Every with_expected_* method narrows the cycles reservation available to the call. Anything left unset falls back to the maximum that parameter could reach. The call above narrows the round trip and the transform. Similarly, max_response_bytes caps the bytes returned by the server and the transform function, and therefore also narrows the amount of cycles that need to be reserved. A request that sets no transform reserves no extra cycles for one.
with_expected_transformed_response_bytes is not set above since the same expectation is already capped by setting max_response_bytes. However, setting it is useful to narrow the reservation in cases where the transform function significantly reduces the number of bytes compared to what was downloaded.
HttpRequest::from_args takes an existing HttpRequestArgs, so an existing call site can move over without rewriting how it builds its arguments. In this case the HttpRequestArgs’s new pricing_version field is ignored, as the builder always uses version 2.
What it costs
Version 2 attaches a budget and charges what the call actually used. The difference is refunded. The figures below are for one call on a 13-node subnet: a 200-byte request, a 2_000-byte response, a 300 ms round trip, and a transform using 1_000_000 instructions.
| Configuration | Attached (cycles) | Charged (cycles) |
|---|---|---|
Version 1, max_response_bytes unset |
20_850_180_000 | 20_850_180_000 |
Version 1, max_response_bytes tuned to 4_000 |
91_780_000 | 91_780_000 |
Version 2, expectations left at their maxima |
5_344_015_469 | ~60_997_599 |
Version 2, expectations as the example sets them |
112_185_476 | ~60_997_599 |
For this call, version 2 charges about 340 times less than the untuned version 1, and about a third less than the tuned one. However, note that the recommended amount of cycles to be attached increased compared to tuned version 1.
The charge is identical in both version 2 rows, because version 2 bills what the call consumed either way. Only the amount held during the call differs. Left at their maxima, the expectations give a reservation the call cannot exhaust, but they hold about 5.3 billion cycles while it runs. Narrowing the expectations therefore improves cycles liquidity, but limits the maximum amount of work the call can do, before running out and failing.
An interactive comparison across all three outcall modes and both pricing versions is available here: https://srz22-tqaaa-aaaas-qg4aq-cai.icp0.io/.
What version 2 charges
Version 2 charges only a base fee in advance. The rest of the attached cycles are reserved as a budget, against which each node performs the outcall. What is left unspent is refunded. The price has three parts:
n = subnet size, from ic0.subnet_self_node_count
base_fee = (1_000_000 + 50 * request_bytes + replication_term) * n
replication_term = 140_000 * n + 800 * n * n // fully replicated
= 90_000 * n + (2_000 * n + 100_000) * min_responses // otherwise
per-node usage = 50 * raw_response_bytes
+ 300 * roundtrip_ms
+ transform_instructions / 13
(+ 50 * n * transformed_bytes) // non-replicated and flexible only
delivery = n * (10 * n + 600) * transformed_bytes
Note that max_response_bytes appears in none of these terms. It still caps the response size and bounds the cycles to reserve. It no longer sets the price in advance.
The per-node usage fee is charged once per node that performs the outcall: all n of them for a fully replicated call, one for a non-replicated call, and total_requests for a flexible one. flexible_http_request, announced separately, is priced with version 2 only and has no pricing_version field. The delivery term is a single subnet-wide charge drawn from whatever was left unspent of the per-node budgets. It funds including the response in a block.
Reserving and settling
The new system API function ic0.cost_http_request_v2 quotes the cycles a call should attach, based on the caller’s expected resource consumption.
The function takes a Candid encoding of the following parameter record:
record {
request_bytes : nat64;
http_roundtrip_time_ms : nat64;
raw_response_bytes : nat64;
transformed_response_bytes : nat64;
transform_instructions : nat64;
outcall_type : opt variant {
fully_replicated : reserved;
non_replicated : reserved;
flexible : opt record {
min_responses : nat32;
max_responses : nat32;
total_requests : nat32;
}
}
}
An absent outcall_type prices a fully replicated call, and an absent flexible record uses the endpoint’s own default counts. The fields are not validated against each other, so an impossible combination yields a price no outcall will ever be charged.
The quote is deliberately higher than what the call will most likely settle at. Neither how many nodes respond nor which result they produce is known when the call is made, so the reserve covers the most expensive result still producible. The delivery term is scaled up on top of that, so every deliverable outcome funds itself. Whatever is left unspent is refunded when the request settles.
The attached cycles beyond the base fee bound each node’s execution: how many bytes it may download, how long it may wait for the server, and how many instructions its run of transform may use.
An under-funded call is accepted, not rejected. The base fee is covered either way, so the call runs with tighter per-node limits. A node that exhausts its budget returns a reject, possibly after the remote server has already been contacted. The failure is intermittent: it depends on how slow the endpoint was and how much work the transform did on that call.
Under http_request this surfaces as one of two errors. The reject carries CANISTER_REJECT and Insufficient cycles, which the subnet either agrees on or resolves as a divergence. If what the nodes left unspent no longer covers delivery, the canister gets a different CANISTER_REJECT describing the out of cycles error.
flexible_http_request reports both conditions as a reply rather than a reject: a node that exhausts its share counts towards too_many_rejects, and an exhausted collective allowance is a dedicated out_of_cycles error.
Because of these failure cases, read the price at runtime with ic0.cost_http_request_v2 rather than hard-coding a cycles amount. Pass what the call is expected to use. By default, the following maxima apply:
| Parameter | Maximum |
|---|---|
http_roundtrip_time_ms |
60_000, the longest the system waits for a response |
raw_response_bytes |
max_response_bytes, or 2_000_000 if it is unset |
transformed_response_bytes |
max_response_bytes plus 1_024 bytes for the Candid encoding, or 2_001_024 if it is unset |
transform_instructions |
5_000_000_000, the instruction limit of a query call |
Passing all four gives a reservation the call cannot exhaust, but it also withholds significantly more cycles for the duration of the call. A large reservation restricts how many outcalls a canister can have in flight at once.
Refunds do not all arrive with the response directly. Each node returns what it did not spend, and a node that never reports has its budget returned one minute after the response. A canister reading its own balance immediately after an outcall might still see it settling.
Migration
Version 1 is the deprecated default, chosen when pricing_version is unset. Version 2 is intended to become the default in a future release. Version 1 will be retired eventually, pending prior announcement. Migrating early is strongly recommended to reduce the cost of most canisters and avoid intermittent failures once the default changes.
Migrating means two things: set pricing_version = 2, and attach what ic0.cost_http_request_v2 quotes. This is necessary, because switching to version 2 while keeping a hand-computed version 1 cycle amount may not be sufficient.
Upgrading canisters to newer CDK versions and switching to the provided HttpRequest builders therefore migrates automatically: The new builders use pricing version 2 and attach the cycles recommended by ic0.cost_http_request_v2.
In contrast, an amount computed for version 1 carries no budget for elapsed time or transform instructions. Callers that tuned max_response_bytes tightly are therefore the most exposed. The figures below are for the reference call above, with max_response_bytes tuned to 4_000:
| Usage | Version 1 pays (cycles) |
Version 2 needs (cycles) |
|---|---|---|
| 2_000-byte response, 300 ms, 1 million instructions | 91_780_000 | 69_433_156 |
| 2_000-byte response, 10 s, 1 million instructions | 91_780_000 | 107_263_156 |
| 2_000-byte response, 300 ms, 1 billion instructions | 91_780_000 | 1_068_433_145 |
Version 2 is therefore not uniformly cheaper: against a tightly tuned version 1 amount, a slow endpoint or a heavy transform costs more. The last two rows are shortfalls, and a shortfall surfaces as the intermittent failure above. Once a deadline for switching the default to version 2 is set, DFINITY intends to publish IDs of canisters at risk of such a shortfall.
Documentation
- HTTPS outcalls skill
- Cycle costs for HTTPS outcalls
http_requestin the interface specification- Pay-as-you-go pricing examples
- Interactive pricing simulation
Future Work
Motoko support for pricing version 2 and for the new cost API is planned to arrive in a future release.