C3 HTTP Request (C3Http)
C3Http is a lightweight nodeless replacement for HTTPRequest. It offers significant improvements in ergonomics, performance, and testability.
Here is a complete working example of how to use C3Http in a script:
extends Node2D
func _ready() -> void:
var res := await C3Http.request("https://jsonplaceholder.typicode.com/todos/1")
if res.ok:
print(res.body.get_string_from_utf8()) # .body is PackedStringArray
print(res.text) # .text is String
print(res.json["title"]) # .json is Variant
else:
print(res.error) # "[http] status=404 Request failed with status 404."
print(res.error.status) # 404
Features
- Static
await-ablerequest()callable from any script — noNodeto add or configure - Every call returns a typed
Responseobject — a singleif not res.okcheck covers transport failures, timeouts, and non-2xx statuses alike - Per-request
Optionsthat mirrorHTTPRequest's properties (use_threads,accept_gzip, etc.), plus additional features - HTTP keep-alive — set
Options.sessionto pool and reuse connections across calls to the same host - Server-Sent Events (SSE) — pass an
on_sse_eventcallback to consume a streamingtext/event-streamresponse incrementally, with theLast-Event-IDcursor andretry:backoff surfaced for reconnects - Download progress — pass an
on_progresscallback to track(bytes_received, total_bytes)as the body arrives - Cancellation token — cancel an in-flight request from another coroutine or signal handler
- Connection status — pass an
on_status_changedcallback to observe theHTTPClientlifecycle (resolving, connecting, requesting, body) - Built-in test mock —
C3Http.Mockintercepts all requests in tests without a network, with stubs to configure responses and a call log for assertions
Comparison with HTTPRequest
| Feature | C3Http |
HTTPRequest |
|---|---|---|
| No Node to add or configure | ✅ | ❌ |
await-able (no signal wiring) |
✅ | ❌ |
Single ok check (transport + non-2xx) |
✅ | ❌ |
Decoded text body accessor |
✅ | ❌ |
Parsed json body accessor |
✅ | ❌ |
| Server-Sent Events (SSE) streaming | ✅ | ❌ |
Typed RequestError with Kind |
✅ | ❌ |
| Built-in test mock | ✅ | ❌ |
| HTTP keep-alive and connection reuse | ✅ | ❌ |
| Cancellation | ✅ | ✅ |
| Timeout | ✅ | ✅ |
| Gzip decompression | ✅ | ✅ |
| Redirect following | ✅ | ✅ |
| Download to file | ✅ | ✅ |
| Body size limit | ✅ | ✅ |
| Custom TLS options | ✅ | ✅ |
| Raw request body (bytes) | ✅ | ✅ |
| HTTP/HTTPS proxy | ✅ | ✅ |
| Download progress events | ✅ | ✅ |
| Connection status checking | ✅ | ✅ |
| Threaded requests (off main loop) | ✅ | ✅ |
Benchmarks
A benchmark analysis was performed using Godot 4.7 on Windows 11 against a remote API. The benchmark scene can be found in examples/benchmark/. The raw results can be found in BENCHMARK.md, and docs/benchmarks.md has the full analysis.
Highlights:
C3Httpexcels at large downloads: In its defaultuse_threads = falsemode,HTTPRequestreads exactly one chunk per frame, capping throughput atdownload_chunk_size × frame_rate(~3.93 MB/s at 60 fps with 64 KB chunks) no matter how fast the link is.C3Httpdrains every available chunk each frame, so its time tracks bandwidth instead — ~5× faster at 8 MB (487 ms vs 2367 ms) and ~9× faster at 32 MB (984 ms vs 8800 ms).- Sessions (keep-alive) are the strongest lever: With
C3Http, passing a sharedSessionviaOptions.sessionreuses a warm TLS/TCP connection, eliminating ~125 ms of handshake and TCP slow-start per request: single-request latency drops from ~162 ms to ~34 ms, and a 400 KB download from ~300 ms to ~51 ms. NativeHTTPRequesthas no equivalent. - Latency:
C3Httpis ~1 frame faster whenuse_threads = false: Withuse_threads = false(the default for both clients) at any capped frame rate,C3Httpresolves one frame earlier than native (e.g. 183 ms vs 200 ms at 60 fps) because when the underlyingHTTPClient's status transitions (e.g. from requesting to reading the body after headers arrive),C3Httpre-reads the client status in the same loop iteration rather than waiting for the next frame.
Compatibility
Tested on Godot 4.7.x with automated (GUT) and manual tests. Manually verified to work back to Godot 4.2.0.
Installation
Click the "Asset Store" tab at the top of the Godot editor and search for "C3 HTTP Request". Then click "Download" and "Install". The addon will be automatically added to your project, and C3Http will be available as a global class immediately — no plugin activation required.
Alternatively, download the latest release from GitHub and copy the addons/c3_http_request folder into your project's addons/ directory.
Quick start
Install the addon (see Installation above) and C3Http is available immediately as a global class. Await its static request() method to make a request and get a typed Response object back:
# GET
var res := await C3Http.request("https://api.example.com/todos/1")
if not res.ok:
push_error(str(res.error))
return
print(res.status) # 200
print(res.body) # raw response body bytes (PackedByteArray)
print(res.text) # response body decoded as UTF-8
print(res.json) # response body parsed as JSON (Variant; null if invalid)
# POST with a JSON body and custom headers
var res2 := await C3Http.request(
"https://api.example.com/posts",
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer " + token,
]),
HTTPClient.METHOD_POST,
'{"title": "hello"}'
)
# POST a raw binary body (sent as-is, not UTF-8 encoded)
var res3 := await C3Http.request_raw(
"https://api.example.com/upload",
PackedStringArray(["Content-Type: application/octet-stream"]),
HTTPClient.METHOD_POST,
payload # a PackedByteArray
)
# Per-request options
var opts := C3Http.Options.new()
opts.timeout = 10.0
var res4 := await C3Http.request(url, PackedStringArray(), HTTPClient.METHOD_GET, "", opts)
Response
Every call returns a Response, whether it succeeded or not. ok is the field you'll check most: true on a 2xx status, false for anything else (transport failure, timeout, or a non-2xx status). Beyond that, status and headers mirror what the server sent, and the body is available three ways depending on what you need: body as raw bytes, text decoded as UTF-8, or json parsed as a Variant — text and json are computed lazily on first access and cached, so reading them repeatedly is cheap.
var res := await C3Http.request("https://api.example.com/todos/1")
if not res.ok:
push_error(str(res.error)) # RequestError; See "Error handling" section.
return
print(res.status) # 200
print(res.headers) # PackedStringArray of "Name: Value" strings
print(res.body) # raw response body bytes (PackedByteArray)
print(res.text) # response body decoded as UTF-8
print(res.json) # response body parsed as JSON (Variant; null if invalid)
See the Response reference for more information.
Options
The optional last argument to request() lets you tune a single call without changing its shape. Most fields have sensible defaults and only need setting when you want non-default behavior — a timeout, a body size cap, a proxy, TLS overrides for self-signed certificates, or a background thread via use_threads (see Threaded requests). The more involved features — cancellation, SSE, download progress, connection status, and session reuse — each get their own section below with a working example.
var opts := C3Http.Options.new()
opts.timeout = 10.0
opts.max_redirects = 0
var res := await C3Http.request(url, PackedStringArray(), HTTPClient.METHOD_GET, "", opts)
See the Options reference for more information.
Error handling
When res.ok is false, res.error is a RequestError describing what went wrong, so you can react differently to a timeout than to a 404 or a cancellation. Its kind field (a RequestError.Kind enum: TRANSPORT, HTTP, CLIENT, TIMEOUT, CANCELLED, BODY_SIZE_LIMIT_EXCEEDED) categorizes the failure, and str(error) gives a compact one-line summary for logging:
if not res.ok:
if res.error.kind == C3Http.RequestError.Kind.TIMEOUT:
print("timed out, retrying...")
else:
push_error(str(res.error)) # e.g. "[http] status=404 Request failed with status 404."
return
See the RequestError reference for more information.
Cancellation
Pass a CancellationToken via Options.cancellation_token and call token.cancel() from anywhere to abandon an in-flight request. The polling loop checks the token between iterations and returns a Response with error.kind == CANCELLED.
var token := C3Http.CancellationToken.new()
var opts := C3Http.Options.new()
opts.cancellation_token = token
var request := C3Http.request(
"https://api.example.com/slow",
PackedStringArray(),
HTTPClient.METHOD_GET,
"",
opts
)
get_tree().create_timer(2.0).timeout.connect(token.cancel)
var res := await request
if not res.ok and res.error.kind == C3Http.RequestError.Kind.CANCELLED:
print("request cancelled")
See the Cancellation guide and C3Http.CancellationToken reference for usage and examples.
Server-Sent Events (SSE)
Set Options.on_sse_event to a Callable to consume a streaming text/event-stream response. The callback fires once per event — on_sse_event.call(data, event_type, last_event_id) — as events arrive, and the await resolves to a final Response once the stream closes. Response.sse_retry_ms surfaces the server's retry: backoff for reconnects.
var opts := C3Http.Options.new()
opts.on_sse_event = func(data: String, event_type: String, last_event_id: String) -> void:
print("[%s] %s" % [event_type, data])
var res := await C3Http.request(
"https://api.example.com/stream",
PackedStringArray(),
HTTPClient.METHOD_GET,
"",
opts
)
See the SSE guide for full details, including reconnect patterns.
Download progress
Set Options.on_progress to a Callable to track a download as it arrives. The callback fires once per chunk — on_progress.call(bytes_received, total_bytes) — where total_bytes is the Content-Length or -1 when the server doesn't send one.
var opts := C3Http.Options.new()
opts.on_progress = func(bytes_received: int, total_bytes: int) -> void:
print("%d / %d" % [bytes_received, total_bytes])
var res := await C3Http.request(
"https://api.example.com/large-file",
PackedStringArray(),
HTTPClient.METHOD_GET,
"",
opts
)
See the Download progress guide for usage and examples.
Connection status
Set Options.on_status_changed to a Callable to observe the underlying HTTPClient as it advances through its lifecycle — the equivalent of HTTPRequest's get_http_client_status(). The callback fires once per change with an HTTPClient.Status value.
var opts := C3Http.Options.new()
opts.on_status_changed = func(status: HTTPClient.Status) -> void:
print(status)
var res := await C3Http.request(
"https://api.example.com/todos/1",
PackedStringArray(),
HTTPClient.METHOD_GET,
"",
opts
)
See the Connection status guide for usage and examples.
Threaded requests
By default the polling loop yields to the scene tree once per frame (the same cadence as HTTPRequest). Set Options.use_threads to true to run the loop on a dedicated background thread that polls at OS speed — lowering latency for fast endpoints and keeping the main thread free during large or streaming downloads. The await API is unchanged; callbacks are auto-marshaled back to the main thread.
var opts := C3Http.Options.new()
opts.use_threads = true
var res := await C3Http.request(
"https://api.example.com/todos/1",
PackedStringArray(),
HTTPClient.METHOD_GET,
"",
opts
)
See the Threaded requests guide for details and caveats.
Sessions (Keep-Alive)
Set Options.session to a Session object to pool and reuse connections across calls to the same host, skipping the TCP/TLS handshake on subsequent requests. Session exposes max_connections_per_host (default 6) and idle_timeout (default 60.0 seconds). Call session.close() to release all pooled connections early.
var session := C3Http.Session.new()
var opts := C3Http.Options.new()
opts.session = session
for i in 5:
var res := await C3Http.request(
"https://api.example.com/todos/%d" % i,
PackedStringArray(), HTTPClient.METHOD_GET,
"",
opts
)
See the Sessions guide for usage and examples.
Testing
C3Http.Mock intercepts all request() calls in tests without touching the network. Install it in before_each and uninstall in after_each; register canned responses with mock.stub() and assert outgoing calls via mock.calls / mock.call_count / mock.last_call.
extends GutTest
var mock: C3Http.Mock
func before_each() -> void:
mock = C3Http.Mock.new()
mock.install()
func after_each() -> void:
mock.uninstall()
func test_fetches_todo() -> void:
mock.stub().ok({"title": "hello"})
var res := await C3Http.request("https://api.example.com/todos/1")
assert_eq(res.json["title"], "hello")
assert_eq(mock.call_count, 1)
See the Testing guide and Mock reference for full usage, including stubbing and call assertions. For a real-world example, see the tests/test_public_api.gd file, which use the Mock class to verify the public API of C3Http without touching the network.
Changelog for version v0.4.0
No changelog provided for this version.