diff --git a/.sources/examples b/.sources/examples index d4ea4220..b4fe175c 160000 --- a/.sources/examples +++ b/.sources/examples @@ -1 +1 @@ -Subproject commit d4ea4220c26b46e676721145d21ca21c8d7dfaa6 +Subproject commit b4fe175c14b3755698d56ece8c4dd353824538bb diff --git a/docs/concepts/https-outcalls.md b/docs/concepts/https-outcalls.md index 7a1c61a1..a2e0fbf2 100644 --- a/docs/concepts/https-outcalls.md +++ b/docs/concepts/https-outcalls.md @@ -74,17 +74,17 @@ The cost depends on two factors: - **Request size**: the combined byte length of the URL, headers, body, transform function name, and transform context. - **`max_response_bytes`**: the maximum response size you declare. This is what you're charged for, not the actual response size. -If you omit `max_response_bytes`, the system assumes the maximum of 2 MB and charges accordingly: roughly 21.5 billion cycles on a 13-node subnet. Always set this to a reasonable upper bound for your expected response to avoid overpaying. Unused cycles are refunded. +If you omit `max_response_bytes`, the system assumes the maximum of 2 MB and charges accordingly: roughly 20.85 billion cycles on a 13-node subnet. Always set this to a reasonable upper bound for your expected response to avoid overpaying. Unused cycles are refunded. For exact pricing formulas, see the [cycles costs reference](../references/cycle-costs.md). ## Limitations - **HTTPS only.** Plain HTTP is not supported. The target server must have a valid TLS certificate. -- **2 MB response limit.** The maximum response body is 2,097,152 bytes. If the response exceeds `max_response_bytes`, the call fails. +- **2 MB response limit.** The maximum is 2,000,000 bytes (decimal, not 2^21). The limit covers the response's header names and values plus the body, not the body alone, and it is enforced twice: on the raw response as it arrives from the server, and again on the output of the transform function. A transform therefore cannot rescue a response that already exceeded the cap, because the first check runs before the transform does. Size `max_response_bytes` for the headers and body as they arrive from the server. - **Public endpoints only.** Canisters cannot reach localhost, private IP ranges (10.x.x.x, 192.168.x.x), or other non-routable addresses. - **No streaming or WebSocket.** Outcalls are single request-response pairs. Long-lived connections are not supported. -- **~30-second timeout.** If the external server doesn't respond in time, the call fails. +- **Two timeouts.** If the external server does not respond within 30 seconds, or the subnet does not produce a response within 60 seconds, the call is rejected. It does not trap, so handle the error case rather than relying on a trap. - **Rate limiting.** All canisters on a subnet share the same IPv6 prefixes. If many canisters on the same subnet call the same server, they share its rate limit quota. Using API keys with per-key quotas mitigates this. - **Shared API keys are visible to all replicas.** An API key stored in canister state is readable by every replica. A compromised replica could use the key to make entirely different, unauthorized requests to the external service: not just replay the canister's intended request. [TEE-enabled subnets](node-infrastructure.md#trusted-execution-environments) mitigate this by running replicas in hardware-enforced enclaves, preventing node operators from reading canister memory. Consider deploying canisters that store sensitive credentials on a TEE-enabled subnet. diff --git a/docs/guides/backends/https-outcalls.mdx b/docs/guides/backends/https-outcalls.mdx index f7cc2678..3e63ab50 100644 --- a/docs/guides/backends/https-outcalls.mdx +++ b/docs/guides/backends/https-outcalls.mdx @@ -18,8 +18,8 @@ For how the consensus mechanism works for outcalls, see [Concepts: HTTPS Outcall By default, every replica node in the subnet independently makes the same HTTP request: called **replicated mode**. All nodes must agree on the response before execution continues. Two constraints apply regardless of mode: -- [Cycles](../../concepts/cycles.md) to cover the request cost **must be attached** at call time. In Rust, `ic_cdk::management_canister::http_request` auto-calculates and attaches cycles. In Motoko, cycles must be attached explicitly with `await (with cycles = ...)`. -- The **maximum response body is 2MB** (2,097,152 bytes). Requests exceeding this limit fail. Always set `max_response_bytes` to a tight upper bound: omitting it defaults to 2MB and charges cycles accordingly. +- [Cycles](../../concepts/cycles.md) to cover the request cost **must be attached** at call time. Both languages provide a wrapper that computes the exact amount and attaches it: `ic_cdk::management_canister::http_request` in Rust, and `Call.httpRequest` from the `ic` package in Motoko. Prefer these over a hand-picked figure: attached cycles are held for the duration of the call, so an arbitrary margin caps how many outcalls the canister can have in flight. +- The **maximum response size is 2MB** (2,000,000 bytes). This covers the response's header names and values plus the body, so size a cap against both: a response can carry 1–2 KB of headers before any body. Requests exceeding this limit fail. Always set `max_response_bytes` to a tight upper bound: omitting it defaults to 2MB and charges cycles accordingly. In replicated mode, a transform function is strongly recommended: without one, responses across nodes will likely differ and consensus will fail. In non-replicated mode (`is_replicated = false`), a transform is unnecessary because only one node makes the request. See [Replicated vs non-replicated mode](#replicated-vs-non-replicated-mode) below. @@ -51,7 +51,7 @@ A minimal example that sends a GET request to an echo service. The response body -```motoko snippet="send_http_get/src/send_http_get_backend/main.mo#get_request" +```motoko snippet="send_http_get/backend/main.mo#get_request" ``` @@ -61,7 +61,7 @@ A minimal example that sends a GET request to an echo service. The response body -```rust snippet="send_http_get/src/send_http_get_backend/src/lib.rs#get_request" +```rust snippet="send_http_get/backend/src/lib.rs#get_request" ``` @@ -76,7 +76,7 @@ Because these examples use replicated mode, they include a transform function to -```motoko snippet="send_http_get/src/send_http_get_backend/main.mo#transform" +```motoko snippet="send_http_get/backend/main.mo#transform" ``` @@ -86,7 +86,7 @@ Because these examples use replicated mode, they include a transform function to -```rust snippet="send_http_get/src/send_http_get_backend/src/lib.rs#transform" +```rust snippet="send_http_get/backend/src/lib.rs#transform" ``` @@ -106,7 +106,7 @@ POST requests work the same way, with two additional considerations: -```motoko snippet="send_http_post/src/send_http_post_backend/main.mo#post_request" +```motoko snippet="send_http_post/backend/main.mo#post_request" ``` @@ -116,7 +116,7 @@ POST requests work the same way, with two additional considerations: -```rust snippet="send_http_post/src/send_http_post_backend/src/lib.rs#post_request" +```rust snippet="send_http_post/backend/src/lib.rs#post_request" ``` @@ -133,13 +133,15 @@ In replicated mode, a transform function is strongly recommended (without one, r If the response body also contains dynamic fields (timestamps, per-request IDs, the caller's IP), parse and re-serialize the body to extract only the deterministic fields you need. +`max_response_bytes` is enforced twice: once on the raw response as it arrives from the server, and again on the transform's own output. Stripping headers in the transform therefore cannot rescue a response that already exceeded the cap, because the first check runs before the transform does. It only keeps the transform's own output within the cap. Size `max_response_bytes` for the headers and body as they arrive from the server. + **Debugging "no consensus" errors:** If you see `"No consensus could be reached"`, the transform is not making responses identical. Common culprits: response headers differ, JSON fields arrive in a different order, or the response body contains timestamps. Strip all headers first; if that doesn't resolve it, also normalize or strip the body. ## Cycle costs -HTTPS outcall costs are based on `max_response_bytes`, not the actual response size. If you omit `max_response_bytes`, the system assumes 2MB and charges approximately **21.5 billion cycles**: even for a 1KB response. Always set a tight upper bound. Unused cycles are refunded, but you still pay for the declared maximum. +HTTPS outcall costs are based on `max_response_bytes`, not the actual response size. If you omit `max_response_bytes`, the system assumes 2MB and charges approximately **20.85 billion cycles**: even for a 1KB response. Always set a tight upper bound. Unused cycles are refunded, but you still pay for the declared maximum. -In Rust, `ic_cdk::management_canister::http_request` computes and attaches the exact cost automatically using the `ic0.cost_http_request` system API. In Motoko, cycles must be attached explicitly with `await (with cycles = ...)`. +In Rust, `ic_cdk::management_canister::http_request` computes and attaches the exact cost automatically using the `ic0.cost_http_request` system API. In Motoko, `Call.httpRequest` from the `ic` package does the same. Attaching a hand-picked amount instead is counterproductive: the cycles are held for the duration of the call, so a margin caps how many outcalls the canister can have in flight. For reference, on a 13-node subnet: - Base cost: ~49 million cycles @@ -152,7 +154,7 @@ See [Cycles costs](../../references/cycle-costs.md#https-outcalls) for the full - **Public endpoints only.** HTTPS outcalls can only reach public internet endpoints. Localhost (`127.0.0.1`), private IP ranges (`10.x.x.x`, `192.168.x.x`), and other non-routable addresses are blocked. - **`Host` header may be required.** Some API endpoints require the `Host` header to be explicitly set. The IC does not automatically set it from the URL: add it to your headers if the server requires it. -- **~30-second timeout.** If the external server does not respond within the timeout, the call traps. Design for failure and handle errors gracefully. +- **Two timeouts.** If the external server does not respond within 30 seconds, or the subnet does not produce a response within 60 seconds, the call is rejected. It does not trap, so handle the error case rather than relying on a trap. ## Testing locally diff --git a/docs/references/cycle-costs.md b/docs/references/cycle-costs.md index f30450aa..b89d0fed 100644 --- a/docs/references/cycle-costs.md +++ b/docs/references/cycle-costs.md @@ -124,7 +124,7 @@ base_fee = (3_000_000 + 60_000 * n) * n size_fee = (400 * request_bytes + 800 * max_response_bytes) * n ``` -`request_bytes` is the total serialized request size (URL + headers + body + transform name/context). `max_response_bytes` defaults to 2 MiB if not explicitly set by the canister. +`request_bytes` is the total serialized request size (URL + headers + body + transform name/context). `max_response_bytes` defaults to 2 MB (2,000,000 bytes, decimal) if not explicitly set by the canister, which on a 13-node subnet costs roughly 20.85 billion cycles. | Component | 13-node cycles | ~USD | 34-node cycles | ~USD | |-----------|----------------|------|----------------|------|