fix(https-outcalls): correct max_response_bytes semantics and the 2MB default cost - #352
Conversation
… default cost The 2MB response limit was documented as 2,097,152 bytes and scoped to the response body. Per the interface spec it is 2,000,000 bytes (decimal), it is measured over header names and values plus the body, and it also bounds the transform function's output. Also corrects the cost of omitting max_response_bytes: ~20.85 billion cycles on a 13-node subnet, not ~21.5 billion. The formula already on the cycle-costs page gives 49_140_000 + 10_400 * 2_000_000 = 20_849_140_000. - concepts/https-outcalls.md: byte figure, headers-plus-body scoping, transform bound, default-size cost - guides/backends/https-outcalls.mdx: same, plus a note in the transform section that a transform cannot shrink a response under the cap - references/cycle-costs.md: max_response_bytes defaults to 2 MB, not 2 MiB Closes #351
Both places claiming "In Motoko, cycles must be attached explicitly with `await (with cycles = ...)`" are stale. The `ic` package provides `Call.httpRequest`, which computes the exact cost via `ic0.cost_http_request` and attaches it, matching the Rust wrapper. concepts/https-outcalls.md already described both wrappers correctly. Also notes why a hand-picked margin is counterproductive: attached cycles are held for the duration of the call, so a margin caps outcall concurrency. Depends on dfinity/examples#1477, which switches the embedded Motoko examples off the hardcoded `with cycles = 230_949_972_000`. Requires a submodule bump before merge.
|
Converted to draft: this now depends on dfinity/examples#1477 and needs a submodule bump before it can merge. WhyThe guide stated in two places that "In Motoko, cycles must be attached explicitly with dfinity/examples#1477 switches both Motoko outcall examples to Blocking checklist
The snippet paths are not a no-opWorth flagging for whoever does the bump.
Per The factual corrections in the first commit (8b840b3) are independent of all of this and could be split out if the blocking is inconvenient. |
…t claim Review feedback from @eichhorl, mirroring the corrections on dfinity/icskills#361. The transform claim was too absolute. max_response_bytes is enforced twice: on the raw response as it arrives, and again on the transform's own output. Stripping headers in the transform cannot rescue a response that already exceeded the cap, since that check runs first, but it does keep the transform's own output within the cap. Also corrects the timeout pitfall, which claimed the call traps. There are two timeouts and neither traps: the remote server going silent for 30s rejects with SysFatal, and the subnet failing to produce a response within 60s rejects with SysTransient.
|
Thanks @eichhorl. Applied in e27a82b, mirroring the corrections on dfinity/icskills#361. Transform nuance. The paragraph now says One more found while applying it. The Limitations section claimed "If the external server does not respond within the timeout, the call traps." It does not trap, and there are two timeouts, not one: 30s for the remote server ( Still draft and still blocked on the submodule bump described above; this only addresses the review. |
…e too
The review comment on the guide ("comments of dfinity/icskills#361 also apply
here") applies to concepts/https-outcalls.md as well, which this branch also
edits. Two bullets there still carried the claims the review corrected:
- The 2MB bullet said a transform "cannot bring an oversized response back
under the cap" full stop. The cap is enforced twice against the same value:
on the raw response in the adapter (rpc_server.rs:402-417, before the
transform runs) and on the transform's Candid-encoded output
(client.rs:250). Stripping headers cannot rescue a response that failed the
first check, but it does keep the transform's own output under the second.
- The timeout bullet described a single ~30s timeout. There are two: 30s for
the remote server (SysFatal, "Timeout expired") and 60s for the subnet to
produce a response (SysTransient, "Canister http request timed out").
Both now match the wording already applied to guides/backends/https-outcalls.mdx.
Verified against dfinity/ic@339d220a83.
|
Thanks @eichhorl, you were right that this reaches past the guide.
Both pages read the same way now. Verified against Still a draft, still blocked on the submodule bump described above. |
…-budgeting, and reject messages (#361) Closes #360. All three points in the issue are valid. Verified independently against the interface spec (`developer-docs/docs/references/ic-interface-spec/management-canister.md`), the `ic` mops package (`v4.2.0`), and the replica source (`dfinity/ic@339d220a83`) rather than taking the issue at its word. ## 1. `max_response_bytes` is not body-only (pitfalls 4, 5, 6) The skill said *"The maximum response **body** is 2MB"*. The spec measures the limit over **header names and values plus the body**, and the same definition caps the request you send. A real API commonly sends 1–2 KB of response headers before a single byte of body, which against a tight cap is a large share of the budget, and the failure is total rather than a truncation. The spec also binds the **transform's output** to `max_response_bytes` (including Candid serialization overhead), so a tight cap cannot be rescued by stripping headers in the transform. That is now its own pitfall, since it is the natural wrong inference from the old wording. Also adds the spec's header limits (≤64 headers, ≤8 KiB per name or value, ≤48 KiB combined, URL ≤8192) with the non-obvious part: on the request side these are enforced when the replica **decodes your arguments**, so an over-limit request never leaves the subnet and fails with `InvalidManagementPayload`, not with anything HTTP-looking. ## 2. Over-budgeting cycles is safe but not free (Cycle Cost Estimation) *"Unused cycles are refunded, so it is safe to over-budget"* was true but misleading. Attached cycles leave the canister's spendable balance for the **duration of the call**, so a hand-attached margin caps how many outcalls can be in flight. For a canister making one outcall per user action that margin is a concurrency limit. This is exactly why both wrappers attach the computed amount: `Cost.httpRequest` calls `Prim.costHttpRequest(requestSize, maxResponseBytes)` with no margin, and the package documents the reason. ## 3. Two of the four documented error strings were invented Not in the issue. Found while fixing §2, and the more consequential defect, since agents match on these. `"Body size exceeds limit"` and `"Not enough cycles"` do not exist in the replica. The real strings: | Actual message | Reject code | Raised when | |---|---|---| | `Timeout expired` | `SysFatal` | remote server did not respond within 30s | | `Canister http request timed out` | `SysTransient` | subnet did not produce a response within 60s (retryable) | | `Deadline Exceeded` | `SysTransient` | adapter did not answer the replica within its 60s deadline | | `No consensus could be reached. Replicas had different responses. …` | `SysTransient` | transform not stripping enough | | `Header size exceeds specified response size limit <N>` | `SysFatal` | response headers alone exhausted the cap | | `Http body exceeds size limit of <N> bytes.` | `SysFatal` | body exceeded the allowance **remaining** after headers | | `Transformed http response exceeds limit: <N>` | `SysFatal` | Candid-encoded transform output exceeded the cap | | `http_request request sent with <X> cycles, but <Y> cycles are required.` | `CanisterReject` | attached below the computed cost | There is no single "response too large" error: headers are subtracted from `max_response_bytes` before the body is read (`rpc_server.rs:403`), which is why there are three. And the body message interpolates the **full** cap rather than the remainder it actually measured against, so a 3 KB body can fail with *"exceeds size limit of 10000 bytes"*. That is the issue's own §2 misreading, baked into the error text. Also corrects the timeout pitfall. The skill claimed a single ~30s timeout that traps. There are two, and neither traps: 30s for the remote server (`SysFatal`, `Timeout expired`) and 60s for the subnet to produce a response (`SysTransient`, `Canister http request timed out`, the retryable one). The skill had attached the 30s figure to the 60s message. ## Evals Four adversarial cases added. Cases 1 and 2 re-run because this PR rewrites content they cover. All six run with baseline. | Case | With skill | Baseline | |---|---|---| | 1. 2,000,000 bytes and default-size cost | 3/3 | 3/3 | | 2. Cloud engine cycle cost | 3/3 | **0/3** | | 3. `max_response_bytes` covers headers, bounds transform output | 5/5 | 4/5 | | 4. Over-attaching cycles is safe but not free | 4/4 | 3/4 | | 5. `Http body exceeds size limit` on an under-cap body | 4/4 | **1/4** | | 6. Two distinct timeouts, neither of which traps | 4/4 | **2/4** | Two things worth noting in these numbers: - **Case 2 is the strongest signal in the suite** (3/3 vs 0/3). Without the skill the model applies the Application-subnet formula on a cloud engine, concludes ~20-25B cycles, and tells you to attach them: wrong on all three counts. - **Case 1 no longer discriminates** (3/3 vs 3/3). The base model now independently gets both the 2,000,000 figure and the ~20.85B cost, which it did not when this case was added in #317. It still works as a regression guard, but it no longer demonstrates any value from the skill. Worth considering for retirement or sharpening in a follow-up; left as-is here since this PR is not about that case. - Case 5 is the best of the new cases: baseline missed three of four behaviors and volunteered the wrong fix, *"use a transform function to discard unnecessary headers before the size check"* — precisely the misconception this PR documents. <details> <summary>Full eval output</summary> ``` ━━━ 2MB limit is 2,000,000 bytes and the default-size cost ━━━ WITH skill: 3/3 passed ✅ States the maximum as 2,000,000 bytes (decimal), NOT 2_097_152 / 2^21 / 2 MiB ✅ Gives the omitted-max_response_bytes cost as roughly 20.8-20.9 billion cycles ✅ Does NOT state the cost as ~21.5 billion or ~21.86 billion cycles WITHOUT skill: 3/3 passed ✅ States the maximum as 2,000,000 bytes (decimal), NOT 2_097_152 / 2^21 / 2 MiB ✅ Gives the omitted-max_response_bytes cost as roughly 20.8-20.9 billion cycles ✅ Does NOT state the cost as ~21.5 billion or ~21.86 billion cycles ━━━ Adversarial: outcall cycle cost on a cloud engine ━━━ WITH skill: 3/3 passed ✅ States the cost is 0 on a cloud engine, not the ~20.8 billion Application-subnet figure ✅ Says no cycles need to be attached and the standard wrapper already handles this correctly ✅ Does NOT instruct topping up the canister with cycles or attaching a hardcoded non-zero fee WITHOUT skill: 0/3 passed ❌ States the cost is 0 on a cloud engine, not the ~20.8 billion Application-subnet figure → The output never mentions CloudEngine subnets and instead presents the standard Application-subnet formula, concluding ~20-25B cycles. ❌ Says no cycles need to be attached and the standard wrapper already handles this correctly → The output explicitly states the opposite, saying cycles must be attached and too few will cause the call to fail. ❌ Does NOT instruct topping up the canister with cycles or attaching a hardcoded non-zero fee → The output instructs budgeting and attaching a large non-zero amount (~20-25B cycles, or 100M-500M with a smaller response cap). ━━━ Adversarial: max_response_bytes covers headers and bounds the transform output ━━━ WITH skill: 4/4 passed ✅ States that max_response_bytes covers HTTP header names and values plus the body, not the body alone ✅ States that stripping headers in the transform does NOT bring an oversized response under the cap, because max_response_bytes also bounds the transform's own output ✅ Advises sizing the cap against headers + body as received from the server (real APIs often send 1-2 KB of headers), so 1024 is too tight ✅ Does NOT claim the 1024-byte cap will work because the transform removes the headers WITHOUT skill: 3/4 passed ✅ States that max_response_bytes covers HTTP header names and values plus the body, not the body alone ❌ States that stripping headers in the transform does NOT bring an oversized response under the cap, because max_response_bytes also bounds the transform's own output → The output correctly says the cap is enforced before the transform runs, but never states that max_response_bytes also bounds the transform's output size. ✅ Advises sizing the cap against headers + body as received from the server (real APIs often send 1-2 KB of headers), so 1024 is too tight ✅ Does NOT claim the 1024-byte cap will work because the transform removes the headers ━━━ Adversarial: over-attaching cycles is safe but not free ━━━ WITH skill: 4/4 passed ✅ Confirms unused cycles are refunded but states that over-attaching is still not free ✅ Explains that attached cycles are held for the duration of the call, so a margin reduces how many outcalls can be in flight (caps concurrency) ✅ Recommends attaching the exact computed amount via the wrapper (Call.httpRequest / ic_cdk::management_canister::http_request) or cost_http_request rather than a hand-picked buffer ✅ Does NOT endorse attaching the 1B round number as a harmless safety margin WITHOUT skill: 3/4 passed ✅ Confirms unused cycles are refunded but states that over-attaching is still not free ✅ Explains that attached cycles are held for the duration of the call, so a margin reduces how many outcalls can be in flight (caps concurrency) ❌ Recommends attaching the exact computed amount via the wrapper (Call.httpRequest / ic_cdk::management_canister::http_request) or cost_http_request rather than a hand-picked buffer → The output never mentions the wrapper/cost_http_request approach and instead explicitly recommends a hand-picked 1.5-2x buffer (300-400M). ✅ Does NOT endorse attaching the 1B round number as a harmless safety margin ━━━ Adversarial: 'Http body exceeds size limit' fires on a body well under the cap ━━━ WITH skill: 4/4 passed ✅ Explains that response header bytes are subtracted from max_response_bytes first, so the body was measured against the remaining allowance, not the full 10000 ✅ Notes the message prints the full cap rather than the remaining allowance, which is why a 3 KB body can trip a 10000-byte cap ✅ Recommends raising max_response_bytes to cover headers plus body ✅ Does NOT suggest that stripping headers in the transform function will fix this error WITHOUT skill: 1/4 passed ❌ Explains that response header bytes are subtracted from max_response_bytes first, so the body was measured against the remaining allowance, not the full 10000 → The output vaguely notes headers count toward the total response size but never explains the specific mechanism of header bytes being subtracted first to leave a reduced allowance for the body. ❌ Notes the message prints the full cap rather than the remaining allowance, which is why a 3 KB body can trip a 10000-byte cap → The output never mentions that the error message reports the configured max_response_bytes value rather than the actual remaining byte allowance. ✅ Recommends raising max_response_bytes to cover headers plus body ❌ Does NOT suggest that stripping headers in the transform function will fix this error → The output explicitly suggests using a transform function 'to discard unnecessary headers before the size check', even though it partially hedges this elsewhere, so the incorrect suggestion is present. ``` </details> ## Review follow-up Two rounds after @eichhorl's review, both verified against `dfinity/ic@339d220a83`. **Round 1 (a180e9a)** applied all five comments: the transform nuance (the cap is enforced twice, so stripping headers cannot rescue a raw response that already failed the first check but does bound the transform's own output), the two timeouts, `CanisterReject` in place of the `ErrorCode` name, and `exceeded` rather than `met or exceeded` for the header-size message (`checked_sub` returns `Some(0)` on equality). Case 3 was split into two behaviours, because with the two facts merged the corrected wording stopped discriminating at all (4/4 vs 4/4): the base model already knows the raw response is checked first, and the transform-output bound was carrying the whole signal. **Round 2 (42bdb30)** adds `Deadline Exceeded` `[SysTransient]` to the reject table: the adapter did not answer the replica within its 60s deadline (`client/src/client.rs:412-419`), reachable under legacy pricing since `LegacyTracker` always reports the full `MAX_RESPONSE_TIME` (`pricing/src/legacy.rs:24-30`), though rarer than the two above because the adapter's own 30s timeout usually fires first. It also spells out one implication the review left implicit: both size checks compare against the **same** `max_response_bytes`, which is why a raw response that only just fits can still fail after the transform, the Candid overhead being added on top. That is precisely when stripping headers in the transform buys real room. `Insufficient cycles` `[CanisterReject]` (`client.rs:304/414/436/580`) was deliberately left out. Every site needs either a `PricingError` from the budget tracker or a deadline below `MAX_RESPONSE_TIME`, and both are pay-as-you-go-only: `ALLOWED_HTTP_OUTCALLS_PRICING_VERSIONS = &[PRICING_VERSION_LEGACY]` (`management_canister_types/src/http.rs:72`), `LegacyTracker` never returns `PricingError`, and under legacy the pay-as-you-go tracker runs only as a shadow inside `DarkLaunchTracker`, whose results feed a metric and never affect behaviour. A table headed "match on these" is the wrong home for a reject that cannot currently fire. <details> <summary>Follow-up eval runs: new case 6, and case 3 re-run after the wording change</summary> ``` --- Adversarial: two distinct outcall timeouts, neither of which traps --- WITH skill: 4/4 passed PASS Identifies TWO distinct timeouts: ~30s remote-server, ~60s subnet PASS Pairs each with the right reject: SysFatal/'Timeout expired' for 30s, SysTransient/'Canister http request timed out' for 60s PASS States the call is rejected rather than trapped, catchable via Motoko Error / Rust Err PASS Does NOT claim a single ~30s timeout and does NOT claim the call traps WITHOUT skill: 2/4 passed FAIL Identifies TWO distinct timeouts: about 30 seconds for the remote server to respond, and 60 seconds for the subnet to produce a response -> The output describes a single fixed 2-minute end-to-end replica timeout, not the two distinct 30s/60s timeouts. FAIL Pairs each with the right reject: SysFatal with message 'Timeout expired' for the 30s remote-server timeout, and SysTransient with message 'Canister http request timed out' for the 60s subnet timeout -> The output only mentions a generic SysTransient reject with a vague 'error message about the timeout', never SysFatal/'Timeout expired' or the specific 'Canister http request timed out' text, and doesn't pair either with a distinct timeout. PASS States the call is rejected rather than trapped, so it is catchable (in Motoko the await raises an Error, in Rust the wrapper returns Err) PASS Does NOT claim there is a single ~30 second timeout, and does NOT claim the call traps --- Adversarial: max_response_bytes covers headers and bounds the transform output --- WITH skill: 5/5 passed PASS States that max_response_bytes covers HTTP header names and values plus the body, not the body alone PASS States that stripping headers in the transform will NOT rescue this request, because the raw response is checked against the cap before the transform runs PASS Also notes that max_response_bytes separately bounds the transform's own output, so the cap is enforced twice rather than only on the raw response PASS Advises sizing the cap against headers + body as received from the server (real APIs often send 1-2 KB of headers), so 1024 is too tight PASS Does NOT claim the 1024-byte cap will work because the transform removes the headers WITHOUT skill: 4/5 passed PASS States that max_response_bytes covers HTTP header names and values plus the body, not the body alone PASS States that stripping headers in the transform will NOT rescue this request, because the raw response is checked against the cap before the transform runs FAIL Also notes that max_response_bytes separately bounds the transform's own output, so the cap is enforced twice rather than only on the raw response -> The output never mentions that the cap is also applied to the transform function's output. PASS Advises sizing the cap against headers + body as received from the server (real APIs often send 1-2 KB of headers), so 1024 is too tight PASS Does NOT claim the 1024-byte cap will work because the transform removes the headers ``` </details> Note that the case-3 output inside the block above supersedes the case-3 section in the earlier block, which predates the split into two behaviours. `npm run validate` passes, warnings unchanged. ## Related The issue's §3 (the docs page stating `2,097,152`) is a developer-docs defect; the skill was already correct. Tracked and fixed separately: - dfinity/developer-docs#351 → dfinity/developer-docs#352 (draft), which also corrects a wrong default-size cycle figure (~21.5B → ~20.85B) on two pages and a "2 MiB" in the cycle-costs reference - dfinity/examples#1477, switching both Motoko outcall examples off a hardcoded `with cycles = 230_949_972_000` to `Call.httpRequest` — the same over-budgeting anti-pattern as §1, shipped as the canonical example
…paths Bumps the examples submodule from d4ea422 to b4fe175 (master), which contains dfinity/examples#1477: both Motoko HTTPS outcall examples now use Call.httpRequest instead of a hardcoded `with cycles = 230_949_972_000`. The guide's prose on this branch already describes the wrapper, so the embedded snippets and the surrounding text now agree. The pinned commit 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. Verified every file and region resolves at the new commit by replicating remark-snippet's extraction; a missing file or region is a hard build error, so CI covers this too. guides/backends/https-outcalls.mdx is the only page using CodeExample, and examples tracks master so it carries no .sources/VERSIONS entry.
|
Submodule bumped in 89bf33f. dfinity/examples#1477 merged as
The Motoko snippets on this page now render Verification. All six file+region pairs were confirmed to resolve at the new commit by replicating Scope, per Ready to come out of draft whenever you are. |
Closes #351.
The issue reported two problems with how the HTTPS outcalls pages describe
max_response_bytes. Both are confirmed against the interface spec 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 of2MB(2,000,000B) is used as the limit." Confirmed in the replica asMAX_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_bytesis 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_bytescosts ~21.5 billion cycles. The formula already published onreferences/cycle-costs.mdgives: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.mdsaidmax_response_bytesdefaults 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:
SysFatalTimeout expiredSysTransientCanister http request timed outTelling 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 = ...)". Theicpackage providesCall.httpRequest, which computes the exact cost viaic0.cost_http_requestand 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 asb4fe175..sources/examplesis bumpedd4ea422→b4fe175here, so the snippets now renderawait Call.httpRequest(request)and code and prose agree.The old pin predated the examples restructure, so all six
snippet=paths moved and are updated:Region names (
transform,get_request,post_request) are unchanged.Per
.agents/submodule-bumping.md:guides/backends/https-outcalls.mdxis the only page usingCodeExample, so no other page is affected by the moves, andexamplestracks master so it carries no.sources/VERSIONSentry.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 thehttps-outcallsskill.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, sinceremark-snippettreats a missing file or region as a hard build error.b4fe175by replicating the plugin's extraction logic.Related
https-outcallsskill, including the reject-message set these pages do not enumerate.max_response_bytesis ignored under pricing v2, andic0.cost_http_requestis deprecated. Flagged there with the specific lines, including thatreferences/cycle-costs.mdneeds both cost models rather than an edit in place. As ofdfinity/ic@339d220a83v2 is still gated off, so the pages are correct today.