C3 HTTP Request for Godot
Like HTTPRequest, but better!
C3HTTPRequest is a lightweight, async HTTP client for Godot 4 that covers nearly everything HTTPRequest does — and gets out of your way while doing it. There's no Node to instantiate, add to the tree, and free; no request_completed signal to connect; and no two-step "did the transfer work, and was the status 2xx?" dance. You await a single static call and check one field.
Here's a simple GET request with C3HTTPRequest:
var res := await C3HTTPRequest.request("https://api.example.com/todos/1")
if res.ok:
print(res.text)
print(res.json["title"])
else:
push_error(str(res.error))
No node, no signal, no tree — and res.ok is a single check that already accounts for transport failures, timeouts, and non-2xx statuses alike. Redirects are followed automatically (up to Options.max_redirects), so res reflects the final response the chain lands on.
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
Options: timeout, body size limit, gzip decompression, redirect control, custom TLS, proxy, and download-to-file request_raw()companion for sending a rawPackedByteArraybody (binary payloads) unencoded- Cancellation token — cancel an in-flight request from another coroutine or signal handler
- Server-Sent Events (SSE) — pass an
on_sse_eventcallback to consume a streamingtext/event-streamresponse incrementally - Download progress — pass an
on_progresscallback to track(bytes_received, total_bytes)as the body arrives - Connection status — pass an
on_status_changedcallback to observe theHTTPClientlifecycle (resolving, connecting, requesting, body) - Automatic gzip/deflate decompression when the server sends compressed responses
- Redirect following with a configurable depth limit
Comparison with HTTPRequest
| Feature | C3HTTPRequest | 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 |
✓ | — (integer result code) |
| Concurrent requests | Unlimited | One per node |
| Cancellation | ✓ Token-based | ✓ cancel_request() |
| Timeout | ✓ | ✓ |
| Gzip/deflate decompression | ✓ * | ✓ |
| Redirect following | ✓ | ✓ |
| Download to file | ✓ | ✓ |
| Body size limit | ✓ | ✓ |
| Custom TLS options | ✓ | ✓ |
| Binary response body in memory | ✓ | ✓ |
| Raw request body (bytes) | ✓ | ✓ |
| HTTP/HTTPS proxy | ✓ | ✓ |
| Download progress events | ✓ | ✓ |
| Connection status callback | ✓ | ✓ get_http_client_status() |
| Threaded requests (off main loop) | — | ✓ |
* When Options.download_file is set, the response body is written to disk as-is — decompression is skipped and the file may contain raw compressed bytes.
Compatibility
Tested on Godot 4.6.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 C3HTTPRequest 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
# GET
var res := await C3HTTPRequest.request("https://api.example.com/todos/1")
if not res.ok:
push_error(str(res.error))
return
print(res.status) # 200
print(res.text) # response body decoded as UTF-8
print(res.json) # response body parsed as JSON (Variant; null if invalid)
print(res.body) # raw response body bytes (PackedByteArray)
# POST with a JSON body and custom headers
var res2 := await C3HTTPRequest.request(
"https://api.example.com/posts",
PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer " + token,
]),
C3HTTPRequest.Method.POST,
'{"title": "hello"}'
)
# POST a raw binary body (sent as-is, not UTF-8 encoded)
var res_raw := await C3HTTPRequest.request_raw(
"https://api.example.com/upload",
PackedStringArray(["Content-Type: application/octet-stream"]),
C3HTTPRequest.Method.POST,
payload # a PackedByteArray
)
# Per-request options
var opts := C3HTTPRequest.Options.new()
opts.timeout = 10.0
var res3 := await C3HTTPRequest.request(url, PackedStringArray(), C3HTTPRequest.Method.GET, "", opts)
Response
| Field | Type | Description |
|---|---|---|
ok |
bool |
true when a 2xx status was received. Never affected by body content. |
status |
int |
HTTP status code, or 0 on transport failure. |
headers |
PackedStringArray |
Response headers as "Name: Value" strings. Empty on transport failure. |
body |
PackedByteArray |
Raw response body bytes. Empty when Options.download_file is set or no body was received. |
text |
String |
body decoded as UTF-8. Computed lazily on first access and cached. |
json |
Variant |
body parsed as JSON. Parsed lazily on first access and cached. null (and a pushed error) on invalid JSON. |
error |
RequestError |
Error details when ok is false; null otherwise. |
Options
| Property | Type | Default | Description |
|---|---|---|---|
timeout |
float |
0.0 |
Maximum seconds to wait. 0.0 disables the timeout. |
body_size_limit |
int |
-1 |
Maximum response body size in bytes. -1 is unlimited. |
download_chunk_size |
int |
65536 |
Read buffer size in bytes. |
accept_gzip |
bool |
true |
Inject Accept-Encoding: gzip, deflate and auto-decompress. |
max_redirects |
int |
8 |
Maximum redirects to follow. 0 disables following. |
download_file |
String |
"" |
Path to stream the body to on disk. Empty keeps the body in memory. |
tls_options |
TLSOptions |
null |
null uses TLSOptions.client(). Override for self-signed certificates. |
proxy_host |
String |
"" |
Route http/https requests through a proxy host. Empty = direct connection. |
proxy_port |
int |
-1 |
Port of proxy_host. Ignored when proxy_host is empty. |
cancellation_token |
CancellationToken |
null |
Token for cancelling the request. null disables cancellation support. |
on_sse_event |
Callable |
empty | When set, parse a 2xx body as an SSE stream and invoke this per event. See Server-Sent Events. |
on_progress |
Callable |
empty | When set, invoke (bytes_received, total_bytes) per chunk as the body downloads. See Download progress. |
on_status_changed |
Callable |
empty | When set, invoke (status) each time the HTTPClient status changes. See Connection status. |
Error handling
When res.ok is false, res.error is a RequestError describing what went wrong. Its kind field categorizes the failure:
kind |
Meaning |
|---|---|
RequestError.Kind.TRANSPORT |
No usable HTTP response (DNS, connection, TLS, or request could not start) |
RequestError.Kind.HTTP |
A non-2xx status was received |
RequestError.Kind.CLIENT |
The request was rejected before being sent (e.g. an invalid URL) |
RequestError.Kind.TIMEOUT |
No response was received before Options.timeout elapsed |
RequestError.Kind.CANCELLED |
The request was cancelled via a CancellationToken |
RequestError.Kind.BODY_SIZE_LIMIT_EXCEEDED |
The response body exceeded Options.body_size_limit |
str(error) produces a compact one-line summary: [transport] Could not connect. or [http] status=404 Request failed with status 404.
Cancellation
Pass a CancellationToken via Options.cancellation_token and call token.cancel() from anywhere; the polling loop checks the token between iterations and, once cancelled, abandons the request and returns a Response with error.kind == CANCELLED.
var token := C3HTTPRequest.CancellationToken.new()
var opts := C3HTTPRequest.Options.new()
opts.cancellation_token = token
# In another coroutine or signal handler:
token.cancel()
var res := await C3HTTPRequest.request(url, PackedStringArray(), C3HTTPRequest.Method.GET, "", opts)
if not res.ok and res.error.kind == C3HTTPRequest.RequestError.Kind.CANCELLED:
print("Cancelled.")
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) — as events arrive, and the same await you already use resolves to a final Response once the stream closes. No new method, no Node, no signal wiring.
var opts := C3HTTPRequest.Options.new()
opts.on_sse_event = func(data: String, event_type: String) -> void:
print("[%s] %s" % [event_type, data])
var res := await C3HTTPRequest.request("https://api.example.com/stream", PackedStringArray(), C3HTTPRequest.Method.GET, "", opts)
if not res.ok:
push_error(str(res.error)) # non-2xx body is collected normally into res.error
Behavior while streaming:
data:andevent:fields are surfaced;event_typedefaults to"message"when noevent:line is present. Multipledata:lines in one event are joined with newlines. Comment lines (:keep-alives) and events with nodata:are dropped. Theid:andretry:fields are ignored — this client does not auto-reconnect.- Both
\n\nand\r\n\r\nevent delimiters are supported, and a multi-byte UTF-8 character split across network reads is reassembled before decoding. Response.bodystays empty on a successful (2xx) stream — the bytes are delivered to your callback, not collected. A non-2xx response is collected normally, sores.ok,res.error, and the error body work as usual.timeoutbecomes an idle timeout — the maximum seconds _between_ events, not a total deadline — so a healthy long-lived stream is never cut off. A stalled connection still fails withKind.TIMEOUT. Use0.0to disable.accept_gzipanddownload_fileare ignored while streaming.- Stop a stream by cancelling its
cancellation_token(see Cancellation); theawaitthen resolves withKind.CANCELLED.
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 bytes_received is the cumulative count so far and total_bytes is the Content-Length, or -1 when the server doesn't send one (e.g. a chunked response). Compute a percentage only when total_bytes is positive.
var opts := C3HTTPRequest.Options.new()
opts.on_progress = func(bytes_received: int, total_bytes: int) -> void:
if total_bytes > 0:
print("%d%%" % (bytes_received * 100 / total_bytes))
else:
print("%d bytes" % bytes_received) # length unknown
var res := await C3HTTPRequest.request("https://example.com/large.bin", PackedStringArray(), C3HTTPRequest.Method.GET, "", opts)
Works for both in-memory and download_file downloads. bytes_received counts raw bytes off the wire, so it may differ from the final res.body.size() when a gzip/deflate body is decompressed after the transfer completes. It has no effect in SSE mode (on_sse_event), where the events themselves are the incremental signal.
Connection status
Set Options.on_status_changed to a Callable to observe the underlying HTTPClient as it advances through its lifecycle — the equivalent of polling the native node's get_http_client_status(). The callback fires once per change, on_status_changed.call(status), where status is an HTTPClient.Status value.
var opts := C3HTTPRequest.Options.new()
opts.on_status_changed = func(status: HTTPClient.Status) -> void:
print(status) # HTTPClient.STATUS_CONNECTING, STATUS_REQUESTING, STATUS_BODY, ...
var res := await C3HTTPRequest.request("https://example.com", PackedStringArray(), C3HTTPRequest.Method.GET, "", opts)
A typical request reports STATUS_RESOLVING/STATUS_CONNECTING → STATUS_CONNECTED → STATUS_REQUESTING → STATUS_BODY. Notes:
- Observational only — the request's outcome still arrives via the returned
Response; this callback never changes it. - Fires in every mode, including
download_fileand SSE (on_sse_event), since it tracks the connection, not the body. - Repeats per redirect hop — each hop opens a fresh connection, so the connect → request → body sequence is emitted again for every hop followed.
- Best-effort — a very brief intermediate state may be coalesced, since the status is sampled once per poll.
Changelog for version v0.2.0
No changelog provided for this version.