Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 65 additions & 5 deletions crates/iroh-http-adapter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use iroh_http_core::{
respond, CoreError, ErrorCode, HandleStore, RequestPayload, ResponseHeadEntry,
DEFAULT_MAX_REQUEST_BODY_BYTES, DEFAULT_MAX_RESPONSE_BODY_BYTES,
};

/// Maximum number of header rows accepted at an adapter boundary.
Expand All @@ -20,8 +21,10 @@ pub const MAX_HEADER_NAME_LEN: usize = 256;
pub const MAX_HEADER_VALUE_LEN: usize = 8_192;
/// Maximum adapter-level timeout in milliseconds.
pub const MAX_TIMEOUT_MS: u64 = 300_000;
/// Maximum adapter-level body cap in bytes.
pub const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
/// Maximum adapter-level request body cap in bytes.
pub const MAX_BODY_BYTES: usize = DEFAULT_MAX_REQUEST_BODY_BYTES;
/// Maximum adapter-level response body cap in bytes.
pub const MAX_RESPONSE_BODY_BYTES: usize = DEFAULT_MAX_RESPONSE_BODY_BYTES;
/// Maximum total simultaneous connections a served endpoint will accept.
pub const MAX_TOTAL_CONNECTIONS: usize = 100_000;
/// Maximum header block size in bytes accepted for a served endpoint.
Expand Down Expand Up @@ -418,7 +421,7 @@ pub fn coerce_fetch_options(raw: RawFetchOptions) -> Result<FetchOptions, Adapte
.transpose()?;
let max_response_body_bytes = raw
.max_response_body_bytes
.map(|b| safe_f64_to_usize(b, "maxResponseBodyBytes", MAX_BODY_BYTES))
.map(|b| safe_f64_to_usize(b, "maxResponseBodyBytes", MAX_RESPONSE_BODY_BYTES))
.transpose()?;
Ok(FetchOptions {
node_id: raw.node_id,
Expand Down Expand Up @@ -836,10 +839,10 @@ mod tests {
f64::INFINITY,
-1.0,
1.5,
(MAX_BODY_BYTES + 1) as f64,
(MAX_RESPONSE_BODY_BYTES + 1) as f64,
] {
assert!(matches!(
safe_f64_to_usize(value, "maxResponseBodyBytes", MAX_BODY_BYTES),
safe_f64_to_usize(value, "maxResponseBodyBytes", MAX_RESPONSE_BODY_BYTES),
Err(AdapterInputError::InvalidArgument {
field: "maxResponseBodyBytes",
..
Expand Down Expand Up @@ -991,6 +994,47 @@ mod tests {
));
}

#[test]
fn coerce_fetch_options_accepts_response_limits_through_core_default() {
const CORE_DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 256 * 1024 * 1024;

for value in [
MAX_BODY_BYTES,
64 * 1024 * 1024,
CORE_DEFAULT_MAX_RESPONSE_BODY_BYTES,
] {
let options = coerce_fetch_options(RawFetchOptions {
node_id: "aaaa".to_string(),
url: "httpi://peer/".to_string(),
method: "GET".to_string(),
direct_addrs: None,
headers: vec![],
timeout_ms: None,
max_response_body_bytes: Some(value as f64),
})
.expect("response limit through the core default should be accepted");

assert_eq!(options.max_response_body_bytes, Some(value));
}

let too_large = coerce_fetch_options(RawFetchOptions {
node_id: "aaaa".to_string(),
url: "httpi://peer/".to_string(),
method: "GET".to_string(),
direct_addrs: None,
headers: vec![],
timeout_ms: None,
max_response_body_bytes: Some((CORE_DEFAULT_MAX_RESPONSE_BODY_BYTES + 1) as f64),
});
assert!(matches!(
too_large,
Err(AdapterInputError::InvalidArgument {
field: "maxResponseBodyBytes",
..
})
));
}

#[test]
fn coerce_endpoint_options_validates_and_coerces() {
let ok = coerce_endpoint_options(RawEndpointOptions {
Expand Down Expand Up @@ -1068,5 +1112,21 @@ mod tests {
..
})
));

for bad in [
RawServeOptions {
max_request_body_wire_bytes: Some((MAX_BODY_BYTES + 1) as f64),
..Default::default()
},
RawServeOptions {
max_request_body_decoded_bytes: Some((MAX_BODY_BYTES + 1) as f64),
..Default::default()
},
] {
assert!(matches!(
coerce_serve_options(bad),
Err(AdapterInputError::InvalidArgument { .. })
));
}
}
}
7 changes: 4 additions & 3 deletions crates/iroh-http-core/src/http/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use crate::{Body, ConnectionEvent, IrohEndpoint};
use self::accept::{accept_loop, AcceptConfig};
use self::options::{
DEFAULT_CONCURRENCY, DEFAULT_DRAIN_TIMEOUT_MS, DEFAULT_MAX_CONNECTIONS_PER_PEER,
DEFAULT_MAX_REQUEST_BODY_BYTES, DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_REQUEST_TIMEOUT_MS,
};

// Re-exported from sub-modules so external paths
Expand All @@ -44,8 +44,9 @@ use self::options::{
// unchanged after Slice C.7 split.
pub(crate) use self::error_layer::HandleLayerErrorLayer;
pub use self::handle::ServeHandle;
pub use self::options::ServeOptions;
pub(crate) use self::options::DEFAULT_MAX_RESPONSE_BODY_BYTES;
pub use self::options::{
ServeOptions, DEFAULT_MAX_REQUEST_BODY_BYTES, DEFAULT_MAX_RESPONSE_BODY_BYTES,
};

// ── Connection-event callback type ───────────────────────────────────────────

Expand Down
4 changes: 2 additions & 2 deletions crates/iroh-http-core/src/http/server/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ pub(crate) const DEFAULT_DRAIN_TIMEOUT_MS: u64 = 30_000;
/// 16 MiB — applied when `max_request_body_wire_bytes` or
/// `max_request_body_decoded_bytes` is not explicitly set.
/// Prevents memory exhaustion from unbounded request bodies.
pub(crate) const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024;
pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024;
/// 256 MiB — applied when `max_response_body_bytes` is not explicitly set.
/// Prevents memory exhaustion from a malicious server sending a compressed
/// response that expands to an unbounded size (compression bomb).
pub(crate) const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 256 * 1024 * 1024;
pub const DEFAULT_MAX_RESPONSE_BODY_BYTES: usize = 256 * 1024 * 1024;
5 changes: 4 additions & 1 deletion crates/iroh-http-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ pub mod registry {
// ── Pure-Rust HTTP API surface (`mod http`) ───────────────────────────────────
pub use http::body::{Body, BoxError};
pub use http::client::{fetch_request, FetchError};
pub use http::server::{serve, serve_with_events, RemoteNodeId, ServeHandle, ServeOptions};
pub use http::server::{
serve, serve_with_events, RemoteNodeId, ServeHandle, ServeOptions,
DEFAULT_MAX_REQUEST_BODY_BYTES, DEFAULT_MAX_RESPONSE_BODY_BYTES,
};

// ── FFI bridge surface (`mod ffi`) ────────────────────────────────
pub use ffi::dispatcher::{ffi_serve, ffi_serve_with_callback, respond};
Expand Down
9 changes: 9 additions & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ interface IrohFetchInit extends RequestInit {
directAddrs?: string[];
/** Explicit home relay URL, usually obtained from peer discovery. */
relayUrl?: string;
/** Per-request timeout in milliseconds. Maximum: 300 000. */
requestTimeout?: number;
/** Whether to decompress the response body. Default: true. */
decompress?: boolean;
/**
* Maximum decompressed response body size in bytes for this request.
* Range: 0 through 268 435 456 (256 MiB). Default: 256 MiB.
*/
maxResponseBodyBytes?: number;
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ mDNS uses UDP multicast on port 5353. Common blockers:
| `InvalidInput` | `IrohArgumentError` | Bad node ID, invalid URL scheme, invalid header | Check input format; URLs must use `httpi://` scheme |
| `ConnectionFailed` | `IrohConnectError` | No network path, peer offline | See [connection errors](#networkerror-on-fetch--connection-refused-or-connection-failed) |
| `Timeout` | `IrohConnectError` | Peer too slow, network timeout | Increase `requestTimeout`; retry with backoff |
| `BodyTooLarge` | `IrohProtocolError` | Request body exceeds `maxRequestBodyWireBytes` / `maxRequestBodyDecodedBytes` | Send smaller payload or increase limit |
| `BodyTooLarge` | `IrohProtocolError` | A request body exceeds `maxRequestBodyWireBytes` / `maxRequestBodyDecodedBytes`, or a response exceeds `maxResponseBodyBytes` | Send a smaller payload or increase the corresponding limit within its supported range |
| `HeaderTooLarge` | `IrohProtocolError` | Header block exceeds `maxHeaderBytes` | Reduce header count/size or increase limit |
| `PeerRejected` | `IrohConnectError` | Peer rejected connection at app layer | See [peer rejected](#networkerror--peer-rejected) |
| `Cancelled` | `IrohAbortError` | `AbortSignal` fired or `fetch()` cancelled | Handle `AbortError` in caller; do not retry cancelled requests |
Expand Down
20 changes: 13 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion packages/iroh-http-node/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {

import {
classifyBindError,
classifyError,
type DiscoveryInfo,
IrohNode,
type IrohNodeWithSecret,
Expand Down Expand Up @@ -182,7 +183,14 @@ class NodeAdapter extends IrohAdapter {
try {
const chunk = jsTryNextChunk(this.#eh, handle);
return Promise.resolve(chunk ? new Uint8Array(chunk) : null);
} catch {
} catch (error) {
const classified = classifyError(error);
if (
classified.code !== "INTERNAL" ||
!classified.message.startsWith("try_next_chunk:")
) {
throw classified;
}
// Channel empty or lock contended — fall back to async.
return jsNextChunk(this.#eh, handle);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/iroh-http-node/test/adapter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ test("fetch numeric validation rejects invalid values instead of defaulting", as
const { id } = await node.addr();
const url = `httpi://${id}/validation`;
const maxTimeoutMs = 300_000;
const maxBodyBytes = 16 * 1024 * 1024;
const maxResponseBodyBytes = 256 * 1024 * 1024;

try {
for (
Expand All @@ -110,7 +110,7 @@ test("fetch numeric validation rejects invalid values instead of defaulting", as
Number.POSITIVE_INFINITY,
-1,
1.5,
maxBodyBytes + 1,
maxResponseBodyBytes + 1,
]
) {
await assert.rejects(
Expand Down
7 changes: 4 additions & 3 deletions packages/iroh-http-shared/src/IrohAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ export interface IrohFetchInit extends RequestInit {
*/
decompress?: boolean;
/**
* Per-call response body byte limit. When set, overrides the endpoint-wide
* `maxResponseBodyBytes` default for this single request.
* Maximum decompressed response body bytes accepted for this request.
* Must be an integer from 0 through 268_435_456 (256 MiB). When omitted,
* the core default is 256 MiB.
*/
maxResponseBodyBytes?: number;
}
Expand All @@ -118,7 +119,7 @@ export interface FetchOptions {
timeoutMs?: number;
/** When `false`, the response body is not decompressed. @default true */
decompress?: boolean;
/** Per-call response body byte limit. Overrides endpoint default. */
/** Per-call response body byte limit. Range: 0 through 256 MiB. */
maxResponseBodyBytes?: number;
}

Expand Down
16 changes: 11 additions & 5 deletions packages/iroh-http-shared/src/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type { IrohAdapter } from "./IrohAdapter.js";
import { classifyError } from "./errors.js";

/**
* Wrap a `BodyReader` handle in a web-standard `ReadableStream<Uint8Array>`.
Expand All @@ -25,12 +26,17 @@ export function makeReadable(
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
async pull(controller) {
const chunk = await adapter.nextChunk(handle);
if (chunk === null) {
controller.close();
try {
const chunk = await adapter.nextChunk(handle);
if (chunk === null) {
controller.close();
onClose?.();
} else {
controller.enqueue(chunk);
}
} catch (error) {
onClose?.();
} else {
controller.enqueue(chunk);
throw classifyError(error);
}
},
cancel() {
Expand Down
8 changes: 4 additions & 4 deletions packages/iroh-http-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading