Skip to content

fix(https-outcalls): correct max_response_bytes semantics, cycle over-budgeting, and reject messages - #361

Merged
marc0olo merged 3 commits into
mainfrom
fix/https-outcalls-response-limit-and-cycles
Aug 25, 2026
Merged

fix(https-outcalls): correct max_response_bytes semantics, cycle over-budgeting, and reject messages#361
marc0olo merged 3 commits into
mainfrom
fix/https-outcalls-response-limit-and-cycles

Conversation

@marc0olo

@marc0olo marc0olo commented Aug 25, 2026

Copy link
Copy Markdown
Member

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 fix(https-outcalls): correct the 2MB byte value and the default-size cycle cost #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.
Full eval output
━━━ 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.

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.

Follow-up eval runs: new case 6, and case 3 re-run after the wording change
--- 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

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:

…-budgeting, and reject messages

Three corrections from #360, all verified against the interface spec, the `ic`
mops package, and the replica source.

max_response_bytes: the limit is not body-only. The spec measures it over
header names and values plus the body, and it also bounds the transform's
output (including Candid serialization overhead), so a transform cannot bring
an oversized response under the cap. Adds the spec's header limits and notes
they are enforced at argument-decode time on the request side.

Cycles: "safe to over-budget" omitted that attached cycles are held for the
duration of the call, so a hand-attached margin caps outcall concurrency. This
is why both wrappers attach the exact computed amount.

Reject messages: the troubleshooting block listed four paraphrases, two of
which ("Body size exceeds limit", "Not enough cycles") are not real strings.
Replaced with the exact messages and reject codes from the replica. Notably
there are three distinct size-limit messages because response headers are
counted before the body is read, and "Http body exceeds size limit of <N>
bytes." prints the full cap rather than the remaining allowance, so it fires
on bodies well under <N>.

Also corrects the timeout pitfall: a timeout is a retryable SysTransient
reject, not a trap.

Closes #360
@marc0olo
marc0olo requested review from a team and JoshDFN as code owners August 25, 2026 10:25
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

Skill Validation Report

Validating skill: /home/runner/work/icskills/icskills/skills/https-outcalls

Structure

  • Pass: SKILL.md found

Frontmatter

  • Pass: name: "https-outcalls" (valid)
  • Pass: description: (327 chars)
  • Pass: license: "Apache-2.0"
  • Pass: compatibility: (16 chars)
  • Pass: metadata: (2 entries)

Tokens

  • Warning: SKILL.md body is 5091 tokens (spec recommends < 5000)

Markdown

  • Pass: no unclosed code fences found

Tokens

File Tokens
SKILL.md body 5,091
Total 5,091

Content Analysis

Metric Value
Word count 2,971
Code block ratio 0.48
Imperative ratio 0.09
Information density 0.28
Instruction specificity 0.94
Sections 15
List items 21
Code blocks 9

Contamination Analysis

Metric Value
Contamination level low
Contamination score 0.12
Primary language category shell
Scope breadth 3
  • Warning: Language mismatch: config, systems (2 categories differ from primary)

Result: 1 warning

Project Checks


✓ Project checks passed for 1 skills (0 warnings)

Comment thread skills/https-outcalls/SKILL.md Outdated
Comment thread skills/https-outcalls/SKILL.md Outdated
Comment thread skills/https-outcalls/SKILL.md Outdated
Comment thread skills/https-outcalls/SKILL.md Outdated
Comment thread evaluations/https-outcalls.json Outdated
… reject code

Review feedback from @eichhorl on #361, all verified against dfinity/ic@339d220a83.

The transform claim was too absolute. The cap is enforced twice: on the raw
response as it arrives, and again on the transform's Candid-encoded output.
Stripping headers cannot rescue a raw response that already exceeded the cap
(that check runs first), but it can keep the transform's own output under it.
Reworded the pitfall, the troubleshooting note, and the eval behaviour.

There are two timeouts, not one:
- remote server silent for 30s: SysFatal, "Timeout expired"
  (DEFAULT_HTTP_REQUEST_TIMEOUT_SECS = 30; tonic Code::Cancelled maps to
  SysFatal via grpc_status_code_to_reject)
- subnet produces no response within 60s: SysTransient,
  "Canister http request timed out" (CANISTER_HTTP_TIMEOUT_INTERVAL = 60)

Insufficient-cycles reject code is CanisterReject, not the ErrorCode name
(ic-error-types: CanisterRejectedMessage => CanisterReject).

Header-size error fires on strictly exceeding the cap, not on meeting it:
checked_sub returns Some(0) when headers equal max_response_bytes.

Eval case 3 splits the two transform facts into separate behaviours. The
combined wording conflated them, and once corrected the case stopped
discriminating (4/4 vs 4/4) because the baseline already knows the raw
response is checked first. Split restores it: 5/5 with skill, 4/5 baseline,
failing on the transform-output bound.
marc0olo added a commit to dfinity/developer-docs that referenced this pull request Aug 25, 2026
…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.
@marc0olo

Copy link
Copy Markdown
Member Author

Thanks @eichhorl, all five verified against dfinity/ic@339d220a83 and applied in a180e9a.

Transform nuance (SKILL.md:42, evaluations:28). You're right, and the correction is sharper than a wording tweak: the cap is enforced twice, on the raw response as it arrives and again on the transform's Candid-encoded output. Stripping headers cannot rescue a raw response that already exceeded the cap (rpc_server.rs:403 fails before the transform runs), but it does keep the transform's own output under it. Reworded the pitfall and the troubleshooting note, which had the same absolutism ("stripping headers fixes none of them" now correctly says it fixes the third one only).

Worth flagging what this did to the eval. Splitting the claim apart mattered more than expected: with the two facts merged into one behavior, the corrected wording stopped discriminating entirely (4/4 with skill, 4/4 baseline) because the base model already knows the raw response is checked first. The transform-output bound was carrying all the signal. So case 3 now tests them as separate behaviors, and the delta is back: 5/5 with skill, 4/5 baseline, failing exactly on the second one.

Two timeouts (SKILL.md:50). Confirmed both, including the reject codes:

  • DEFAULT_HTTP_REQUEST_TIMEOUT_SECS = 30 in the adapter config. The gRPC status is Code::Cancelled, which grpc_status_code_to_reject maps through its _ => arm to SysFatal, message Timeout expired.
  • CANISTER_HTTP_TIMEOUT_INTERVAL = 60 in canister_http.rs, rejecting SysTransient with Canister http request timed out (payload_builder.rs:1394).

The skill had conflated the two, attaching the 30s figure to the 60s message. Both are now documented separately in the pitfall and the troubleshooting block.

CanisterReject (SKILL.md:440). Correct, applied. ic-error-types maps CanisterRejectedMessage => CanisterReject; I had put the ErrorCode name in a column that otherwise lists reject codes.

"met or exceeded" (SKILL.md:425). Correct, applied. checked_sub returns Some(0) when headers exactly equal max_response_bytes, so equality does not trip it.

The same two corrections are pushed to dfinity/developer-docs#352 (e27a82b), where the guide additionally claimed the call traps on timeout.

…or the two timeouts

Follow-up to the review, from reading the pricing and client code more closely
(dfinity/ic@339d220a83).

"Deadline Exceeded" [SysTransient] is a third timeout-shaped reject: the adapter
did not answer the replica within its 60s deadline (client.rs:412-419). It is
reachable under legacy pricing, since LegacyTracker always reports the full
MAX_RESPONSE_TIME (pricing/src/legacy.rs:24-30), but rarer than the two already
documented because the adapter's own 30s timeout usually fires first.

Deliberately NOT added: "Insufficient cycles" [CanisterReject]. All four sites
need either a PricingError from the budget tracker or a deadline below
MAX_RESPONSE_TIME, and both are pay-as-you-go-only. PAYG is not selectable
today (ALLOWED_HTTP_OUTCALLS_PRICING_VERSIONS = [PRICING_VERSION_LEGACY]), and
under legacy the PAYG 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 place for a reject that cannot fire.

Pitfall 5 spells out one implication the review left implicit: both checks
compare against the SAME max_response_bytes, so a raw response that only just
fits can still fail after the transform, because the Candid overhead is added
on top. This is what makes stripping headers in the transform genuinely useful.

New eval case for the two timeouts, which nothing covered: 4/4 with skill,
2/4 baseline (the baseline invents a single 2-minute end-to-end timeout and
never names either reject code). Case 3, whose wording this sharpened, is
unchanged at 5/5 with skill vs 4/5 baseline.
marc0olo added a commit to dfinity/developer-docs that referenced this pull request Aug 25, 2026
…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.
@marc0olo

Copy link
Copy Markdown
Member Author

One follow-up after your review, @eichhorl, pushed in 42bdb30. Two additions from reading the pricing and client code more closely.

A third timeout-shaped reject. Deadline Exceeded [SysTransient] fires when the adapter does not answer the replica within its 60s deadline (client/src/client.rs:412-419). It is reachable under legacy pricing, since LegacyTracker always reports the full MAX_RESPONSE_TIME (pricing/src/legacy.rs:24-30), but rarer than the two we documented because the adapter's own 30s timeout usually fires first. Added to the reject table.

One I deliberately left out: Insufficient cycles [CanisterReject] (client.rs:304/414/436/580). Every one of those sites 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), so a request asking for PAYG falls back to legacy; LegacyTracker never returns PricingError; and under legacy the PAYG tracker runs only as a shadow inside DarkLaunchTracker, whose results feed a metric and never affect behaviour. So it looks unreachable today. Flagging in case that is not the intent, but a table headed "match on these" seemed like the wrong home for a reject that cannot fire.

Your transform point, one notch sharper. It had an implication the wording did not spell out: both checks compare against the same max_response_bytes, so a raw response that only just fits can still fail after the transform, because the Candid overhead is added on top. That is exactly the case where stripping headers buys the room you described. Stated explicitly now.

New eval case for the two timeouts, which nothing covered: 4/4 with skill, 2/4 baseline. The baseline invents a single fixed 2-minute end-to-end timeout and names neither reject code. Case 3, whose wording this sharpened, is unchanged at 5/5 vs 4/5. Both runs are in the PR body.

The same concepts-page gap is fixed on dfinity/developer-docs#352.

@marc0olo
marc0olo enabled auto-merge (squash) August 25, 2026 14:57
@marc0olo
marc0olo merged commit 247bb5a into main Aug 25, 2026
6 checks passed
@marc0olo
marc0olo deleted the fix/https-outcalls-response-limit-and-cycles branch August 25, 2026 15:11
marc0olo added a commit to dfinity/developer-docs that referenced this pull request Aug 25, 2026
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

https-outcalls: over-budgeting isn't free, and the max_response_bytes guidance contradicts the interface spec

3 participants