reckfellENGINE

HttpClient: connect and read a response in chunks

A complete nonblocking C# loop: Poll, headers, chunked/EOF framing, body bounds and deadline.

On this page

When to use the low level

For a JSON API, HttpRequest is usually simpler. Use HttpClient when you need to manage connection state, chunk reads and response interpretation yourself. It does not automatically synchronize multiplayer state.

The GET client below does not block _Process while waiting for the network. It assembles at most 65536 response bytes in memory: chunk reading, not unbounded file-to-disk streaming.

Run the example

Run LocalHttpFixture.py from the HTTP guide. Add HttpStreamReader.cs and HttpStreamDemo.cs to a 1.0.0 C# project. Create an Entity-root level, attach HttpStreamDemo, build and press F6. Output shows status=200, an empty failure and JSON with the message. Url defaults to the local fixture.

HttpStreamDemo.cs
C#
using System.Text;
using Reckfell;

public partial class HttpStreamDemo : Entity
{
    [Export] public string Url { get; set; } = "http://127.0.0.1:8765/message";
    public override void _Ready()
    {
        var reader = new HttpStreamReader(); AddChild(reader);
        reader.Completed += reply => ReckfellApi.Print($"HTTP stream: status={reply.StatusCode}, failure={reply.Failure}, bytes={reply.Body.Length}, body={Encoding.UTF8.GetString(reply.Body)}");
        Error start = reader.Start(Url);
        if (start != Error.Ok) ReckfellApi.Print($"HTTP stream start: {start}");
    }
}
HttpStreamReader.cs
C#
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Reckfell;

public partial class HttpStreamReader : Entity
{
    public sealed record Reply(int StatusCode, byte[] Body, string[] Headers, bool Chunked, long ExpectedBytes, string Failure);
    public int MaxBytes { get; set; } = 65536;
    public double TimeoutSeconds { get; set; } = 5;
    public bool Busy => _client is not null;
    public Reply? LastReply { get; private set; }
    public event Action<Reply>? Completed;
    private HttpClient? _client;
    private readonly Stopwatch _clock = new();
    private readonly List<byte> _body = new();
    private string _path = "/";
    private bool _sent, _headersRead, _chunked;
    private int _statusCode;
    private long _expected;
    private string[] _headers = [];

    public Error Start(string url, TlsOptions? trust = null)
    {
        if (Busy) return Error.Busy;
        if (!IsInsideTree()) return Error.Unconfigured;
        if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) ||
            (uri.Scheme != "http" && uri.Scheme != "https") || uri.UserInfo.Length != 0 ||
            MaxBytes <= 0 || !double.IsFinite(TimeoutSeconds) || TimeoutSeconds <= 0)
            return Error.InvalidParameter;
        _body.Clear(); _headers = []; _sent = _headersRead = _chunked = false;
        _expected = -1; _statusCode = 0; LastReply = null; _path = uri.PathAndQuery;
        _client = new HttpClient { BlockingModeEnabled = false, ReadChunkSize = 4096 };
        Error error = _client.ConnectToHost(uri.DnsSafeHost, uri.Port,
            uri.Scheme == "https" ? trust ?? TlsOptions.Client() : null);
        if (error != Error.Ok) { Cancel(); return error; }
        _clock.Restart(); return Error.Ok;
    }
    public override void _Process(double delta)
    {
        if (_client is null) return;
        if (_clock.Elapsed.TotalSeconds >= TimeoutSeconds) { Finish("timeout"); return; }
        Error error = _client.Poll();
        var state = _client.GetStatus();
        if (error != Error.Ok) { Finish(state.ToString()); return; }
        if (state is HttpClient.Status.Resolving or HttpClient.Status.Connecting) return;
        if (!_sent && state == HttpClient.Status.Connected)
        {
            error = _client.Request(HttpClient.Method.Get, _path, ["Accept-Encoding: identity"]);
            if (error != Error.Ok) { Finish(error.ToString()); return; }
            _sent = true; return;
        }
        if (_sent && !_headersRead && _client.HasResponse())
        {
            _headersRead = true; _statusCode = _client.GetResponseCode();
            _headers = _client.GetResponseHeaders(); _expected = _client.GetResponseBodyLength(); _chunked = _client.IsResponseChunked();
            foreach (string header in _headers)
                if (header.StartsWith("Content-Encoding:", StringComparison.OrdinalIgnoreCase) &&
                    !header[(header.IndexOf(':') + 1)..].Trim().Equals("identity", StringComparison.OrdinalIgnoreCase))
                { Finish("content-encoding"); return; }
            if (_expected > MaxBytes) { Finish("size-limit"); return; }
        }
        if (state == HttpClient.Status.Body)
        {
            byte[] chunk = _client.ReadResponseBodyChunk();
            if (chunk.Length > MaxBytes - _body.Count) { Finish("size-limit"); return; }
            _body.AddRange(chunk); state = _client.GetStatus();
        }
        if (_headersRead && state == HttpClient.Status.Connected)
        { Finish(_expected >= 0 && _body.Count != _expected ? "truncated" : ""); return; }
        if (state == HttpClient.Status.Disconnected)
        {
            bool complete = _headersRead && ((_expected >= 0 && _body.Count == _expected) || (_expected < 0 && !_chunked));
            Finish(complete ? "" : "truncated"); return;
        }
        if (state is HttpClient.Status.CantResolve or HttpClient.Status.CantConnect or
            HttpClient.Status.ConnectionError or HttpClient.Status.TlsHandshakeError)
            Finish(state.ToString());
    }
    private void Finish(string failure)
    {
        var reply = new Reply(_statusCode, failure.Length == 0 ? _body.ToArray() : [], _headers, _chunked, _expected, failure);
        Cancel(); LastReply = reply; Completed?.Invoke(reply);
    }
    public void Cancel() { _client?.Close(); _client?.Dispose(); _client = null; _clock.Stop(); _body.Clear(); }
    public override void _ExitTree() => Cancel();
}

Connection lifecycle

StateAction
Resolving / ConnectingContinue Poll on later frames
Connected before the requestPass method, path with query and headers to Request
RequestingContinue Poll and wait for headers
BodyRead ReadResponseBodyChunk and retain bytes
Connected after the bodyCheck completeness and finish
Disconnected when reading to EOFSuccess only for appropriate response framing; validate a known length
CantResolve / CantConnect / ConnectionError / TlsHandshakeErrorFinish with failure and close

ConnectToHost takes host and port; Request takes /path?query, not the full URL. Uri splits those parts in this example. QueryStringFromDict encodes query values; do not insert spaces or user strings directly into URLs.

GetResponseBodyLength can return -1. That does not mean an empty response: chunked framing or connection closure may determine the length. An empty chunk read does not imply completion either; data may not yet be available. Check state and response framing together.

A known Content-Length with missing bytes is an error. For chunked responses, wait for framing completion rather than mere socket closure. The fixture's /message, /chunked and /eof return identical JSON with different framing; /truncated intentionally cuts off an incomplete body and must fail.

Bytes, HTTP status and compression

The client reports StatusCode separately from Failure. A complete 404 response can arrive without a transfer failure; the caller decides acceptable status codes. HttpStreamDemo prints the local fixture body for diagnosis; do not log secret responses in a production game.

This example requests Accept-Encoding: identity and rejects unexpected Content-Encoding. HttpClient does not automatically perform HttpRequest decompression. /gzip deliberately ignores that preference and should produce content-encoding. For decompression, add an output-size bound or use HttpRequest.

Keep chunks as bytes; a UTF-8 character may cross a boundary. Decode the complete body once in this example. A truly large stream needs incremental processing/writing, storage-error handling and a separate memory budget.

Bounds and completion

MaxBytes bounds the assembled body. ReadChunkSize = 4096 does not prove the same limit for every internal buffer: an incoming HTTP chunk can have a different size. This is not protection against all memory usage or every malformed protocol response.

The deadline uses Stopwatch rather than game delta. It is checked in _Process; when processing is suspended, an overdue request finishes after processing resumes. Coordinate the owner's ProcessMode if requests should continue during pause.

Cancel closes/disposes the client without Completed. Start may be reused after completion/cancellation; concurrent starts return Error.Busy. _ExitTree closes the connection. The code implements no automatic redirects, cookie jar, authentication, connection pool, retries or HTTP/2 client.

Real TCP tests cover known-length, chunked and EOF responses, UTF-8, 404, compression rejection, body bounds, truncation, deadlines and reuse after cancellation. HTTPS uses TlsOptions; other platforms and network conditions need separate checks.

Reckfell Engine documentationBack to top ↑
QUICK LINKSGetting startedEntity hierarchyC# & .NET 10Scenes and prefabs
Reckfell guides and full API K
reckfellENGINE
RECKFELL ACCOUNT

Sign in

Sign in to your Reckfell account.

One account for the entire Reckfell platform.