Skip to content

Commit 41e8424

Browse files
authored
fix(https-outcalls): correct max_response_bytes semantics and the 2MB default cost (#352)
Closes #351. The issue reported two problems with how the HTTPS outcalls pages describe `max_response_bytes`. Both are confirmed against the [interface spec](https://github.com/dfinity/developer-docs/blob/main/docs/references/ic-interface-spec/management-canister.md) and fixed here, along with several further defects found while fixing them. ## What the issue reported **1. Wrong byte figure.** `2,097,152` → `2,000,000`. The spec: *"the default value of `2MB` (`2,000,000B`) is used as the limit."* Confirmed in the replica as `MAX_CANISTER_HTTP_RESPONSE_BYTES = 2_000_000`. **2. The limit is not body-scoped.** The spec defines the measured quantity as *"the total number of bytes representing the names and values of HTTP headers and the HTTP body."* Both pages now say headers plus body. **3. The transform bound** (raised in the issue body). `max_response_bytes` is enforced **twice**: on the raw response as it arrives, and again on the transform's output. A transform 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. Stated in the guide's transform section, where a reader would form the "I'll strip headers to fit" plan, and in the concepts Limitations bullet. ## Additional defects found **4. The default-size cost was wrong on both pages.** Both said omitting `max_response_bytes` costs *~21.5 billion cycles*. The formula already published on `references/cycle-costs.md` gives: ``` 49_140_000 + 10_400 * 2_000_000 = 20_849_140_000 (~20.85 billion) ``` Corrected to ~20.85 billion in both places. 21.5B matches neither the decimal nor the binary reading, so it appears independently wrong rather than downstream of the byte-figure error. **5. `references/cycle-costs.md` said `max_response_bytes` defaults to "2 MiB".** Same decimal-vs-binary error, on the page the other two link to for exact pricing. Corrected, with the resulting cycle figure added. **6. Both pages claimed a single ~30 second timeout, and the guide said the call *traps*.** There are two timeouts and neither traps: | Trigger | Reject | Message | |---|---|---| | Remote server silent for 30s | `SysFatal` | `Timeout expired` | | Subnet produces no response within 60s | `SysTransient` | `Canister http request timed out` | Telling readers to expect a trap points them at the wrong error handling. **7. The Motoko cycle guidance was stale.** Both pages said *"In Motoko, cycles must be attached explicitly with `await (with cycles = ...)`"*. The `ic` package provides `Call.httpRequest`, which computes the exact cost via `ic0.cost_http_request` and attaches it, matching the Rust wrapper. The pages now also explain why a hand-picked margin is counterproductive: attached cycles are held for the duration of the call, so a margin caps outcall concurrency. ## Submodule bump Item 7 could not be fixed in prose alone, because the embedded Motoko snippets hardcoded `with cycles = 230_949_972_000`: correcting the text would have left the page contradicting its own code. That was fixed upstream first in dfinity/examples#1477, merged as `b4fe175`. `.sources/examples` is bumped `d4ea422` → `b4fe175` here, so the snippets now render `await Call.httpRequest(request)` and code and prose agree. The old pin predated the examples restructure, so all six `snippet=` paths moved and are updated: ``` send_http_{get,post}/src/send_http_{get,post}_backend/main.mo -> send_http_{get,post}/backend/main.mo send_http_{get,post}/src/send_http_{get,post}_backend/src/lib.rs -> send_http_{get,post}/backend/src/lib.rs ``` Region names (`transform`, `get_request`, `post_request`) are unchanged. Per `.agents/submodule-bumping.md`: `guides/backends/https-outcalls.mdx` is the only page using `CodeExample`, so no other page is affected by the moves, and `examples` tracks master so it carries no `.sources/VERSIONS` entry. ## Scope Kept deliberately tight per `CONTRIBUTING.md`: `concepts/` stays explanatory, and the spec's header limits (≤64 headers, ≤8 KiB per name or value, ≤48 KiB total) are **not** added. The issue marked them optional, and enumerating them duplicates content that belongs in the interface spec and the `https-outcalls` skill. ## Verification - `npm run validate`: no errors in the touched files. - `build_and_deploy`: passing against the new submodule. This is the meaningful check for the bump, since `remark-snippet` treats a missing file or region as a hard build error. - Before pushing the bump, all six file+region pairs were confirmed to resolve at `b4fe175` by replicating the plugin's extraction logic. ## Related - dfinity/icskills#361 carries the same corrections in the `https-outcalls` skill, including the reject-message set these pages do not enumerate. - #254 (flexible outcalls) will invalidate the v1 pricing assumptions on these pages when it lands: `max_response_bytes` is *ignored* under pricing v2, and `ic0.cost_http_request` is deprecated. Flagged there with the specific lines, including that `references/cycle-costs.md` needs both cost models rather than an edit in place. As of `dfinity/ic@339d220a83` v2 is still gated off, so the pages are correct today.
1 parent 1a2e942 commit 41e8424

4 files changed

Lines changed: 18 additions & 16 deletions

File tree

.sources/examples

Submodule examples updated 2454 files

docs/concepts/https-outcalls.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,17 @@ The cost depends on two factors:
7474
- **Request size**: the combined byte length of the URL, headers, body, transform function name, and transform context.
7575
- **`max_response_bytes`**: the maximum response size you declare. This is what you're charged for, not the actual response size.
7676

77-
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.
77+
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.
7878

7979
For exact pricing formulas, see the [cycles costs reference](../references/cycle-costs.md).
8080

8181
## Limitations
8282

8383
- **HTTPS only.** Plain HTTP is not supported. The target server must have a valid TLS certificate.
84-
- **2 MB response limit.** The maximum response body is 2,097,152 bytes. If the response exceeds `max_response_bytes`, the call fails.
84+
- **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.
8585
- **Public endpoints only.** Canisters cannot reach localhost, private IP ranges (10.x.x.x, 192.168.x.x), or other non-routable addresses.
8686
- **No streaming or WebSocket.** Outcalls are single request-response pairs. Long-lived connections are not supported.
87-
- **~30-second timeout.** If the external server doesn't respond in time, the call fails.
87+
- **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.
8888
- **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.
8989
- **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.
9090

docs/guides/backends/https-outcalls.mdx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ For how the consensus mechanism works for outcalls, see [Concepts: HTTPS Outcall
1818

1919
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:
2020

21-
- [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 = ...)`.
22-
- 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.
21+
- [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.
22+
- 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.
2323

2424
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.
2525

@@ -51,7 +51,7 @@ A minimal example that sends a GET request to an echo service. The response body
5151

5252
<CodeExample example="send_http_get" lang="motoko">
5353

54-
```motoko snippet="send_http_get/src/send_http_get_backend/main.mo#get_request"
54+
```motoko snippet="send_http_get/backend/main.mo#get_request"
5555
```
5656

5757
</CodeExample>
@@ -61,7 +61,7 @@ A minimal example that sends a GET request to an echo service. The response body
6161

6262
<CodeExample example="send_http_get" lang="rust">
6363

64-
```rust snippet="send_http_get/src/send_http_get_backend/src/lib.rs#get_request"
64+
```rust snippet="send_http_get/backend/src/lib.rs#get_request"
6565
```
6666

6767
</CodeExample>
@@ -76,7 +76,7 @@ Because these examples use replicated mode, they include a transform function to
7676

7777
<CodeExample example="send_http_get" lang="motoko">
7878

79-
```motoko snippet="send_http_get/src/send_http_get_backend/main.mo#transform"
79+
```motoko snippet="send_http_get/backend/main.mo#transform"
8080
```
8181

8282
</CodeExample>
@@ -86,7 +86,7 @@ Because these examples use replicated mode, they include a transform function to
8686

8787
<CodeExample example="send_http_get" lang="rust">
8888

89-
```rust snippet="send_http_get/src/send_http_get_backend/src/lib.rs#transform"
89+
```rust snippet="send_http_get/backend/src/lib.rs#transform"
9090
```
9191

9292
</CodeExample>
@@ -106,7 +106,7 @@ POST requests work the same way, with two additional considerations:
106106

107107
<CodeExample example="send_http_post" lang="motoko">
108108

109-
```motoko snippet="send_http_post/src/send_http_post_backend/main.mo#post_request"
109+
```motoko snippet="send_http_post/backend/main.mo#post_request"
110110
```
111111

112112
</CodeExample>
@@ -116,7 +116,7 @@ POST requests work the same way, with two additional considerations:
116116

117117
<CodeExample example="send_http_post" lang="rust">
118118

119-
```rust snippet="send_http_post/src/send_http_post_backend/src/lib.rs#post_request"
119+
```rust snippet="send_http_post/backend/src/lib.rs#post_request"
120120
```
121121

122122
</CodeExample>
@@ -133,13 +133,15 @@ In replicated mode, a transform function is strongly recommended (without one, r
133133

134134
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.
135135

136+
`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.
137+
136138
**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.
137139

138140
## Cycle costs
139141

140-
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.
142+
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.
141143

142-
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 = ...)`.
144+
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.
143145

144146
For reference, on a 13-node subnet:
145147
- Base cost: ~49 million cycles
@@ -152,7 +154,7 @@ See [Cycles costs](../../references/cycle-costs.md#https-outcalls) for the full
152154

153155
- **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.
154156
- **`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.
155-
- **~30-second timeout.** If the external server does not respond within the timeout, the call traps. Design for failure and handle errors gracefully.
157+
- **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.
156158

157159
## Testing locally
158160

docs/references/cycle-costs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ base_fee = (3_000_000 + 60_000 * n) * n
124124
size_fee = (400 * request_bytes + 800 * max_response_bytes) * n
125125
```
126126

127-
`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.
127+
`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.
128128

129129
| Component | 13-node cycles | ~USD | 34-node cycles | ~USD |
130130
|-----------|----------------|------|----------------|------|

0 commit comments

Comments
 (0)