reckfellENGINE

HTTP: fetch JSON, validate the response and cancel

A complete C# client and local server: GET, POST, transport/HTTP/JSON failures, bounds and cancellation.

On this page

Run a working request

Use a Reckfell 1.0.0 C# project. The local fixture needs Python 3 with no extra packages. If you already have an API, use its URL and a response shaped like {"message":"text"}.

  1. Save LocalHttpFixture.py in a separate folder and run python LocalHttpFixture.py there. Keep the terminal open. It listens only on 127.0.0.1:8765: a fixture, not a production game server.
  2. Add HttpJsonRequest.cs and HttpJsonDemo.cs to the project. Create a separate Entity-root level, attach HttpJsonDemo, build C# and press F6.
  3. Output shows transport=Success, status=200 and message=Привет 🌍. If the port is occupied, stop your previous fixture or change the port in create_server and Url.
HttpJsonDemo.cs
C#
using Reckfell;

public partial class HttpJsonDemo : Entity
{
    [Export] public string Url { get; set; } = "http://127.0.0.1:8765/message";
    public override void _Ready()
    {
        var api = new HttpJsonRequest(); AddChild(api);
        api.Completed += reply => ReckfellApi.Print($"HTTP: transport={reply.Transport}, status={reply.StatusCode}, failure={reply.Failure}, message={reply.Message}");
        Error start = api.Start(Url);
        if (start != Error.Ok) ReckfellApi.Print($"HTTP start: {start}");
    }
}
HttpJsonRequest.cs
C#
using System;
using System.Text.Json;
using Reckfell;

public partial class HttpJsonRequest : Entity
{
    public sealed record Reply(HttpRequest.Result Transport, int StatusCode, string Message, string Failure)
    {
        public bool IsSuccess => Failure.Length == 0;
    }
    public int MaxBytes { get; set; } = 65536;
    public double TimeoutSeconds { get; set; } = 5;
    public bool UseThreads { get; set; }
    public TlsOptions? Trust { get; set; }
    public bool Busy => _active is not null;
    public Reply? LastReply { get; private set; }
    public event Action<Reply>? Completed;
    private HttpRequest? _active;

    public Error Start(string url, HttpClient.Method method = HttpClient.Method.Get, string jsonBody = "")
    {
        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;
        var request = new HttpRequest { BodySizeLimit = MaxBytes, Timeout = TimeoutSeconds, MaxRedirects = 0, UseThreads = UseThreads };
        if (Trust is not null) request.SetTlsOptions(Trust);
        _active = request; LastReply = null; AddChild(request);
        request.RequestCompleted += (result, code, headers, body) =>
        {
            if (_active != request) return;
            _active = null;
            var transport = (HttpRequest.Result)result;
            Reply reply;
            if (transport != HttpRequest.Result.Success) reply = new(transport, (int)code, "", "transport");
            else if (code < 200 || code >= 300) reply = new(transport, (int)code, "", "http");
            else
            {
                try
                {
                    using JsonDocument doc = JsonDocument.Parse(body);
                    if (doc.RootElement.ValueKind == JsonValueKind.Object &&
                        doc.RootElement.TryGetProperty("message", out JsonElement value) &&
                        value.ValueKind == JsonValueKind.String && value.GetString() is string message && message.Length <= 128)
                        reply = new(transport, (int)code, message, "");
                    else reply = new(transport, (int)code, "", "schema");
                }
                catch (JsonException) { reply = new(transport, (int)code, "", "json"); }
            }
            LastReply = reply; request.QueueFree(); Completed?.Invoke(reply);
        };
        string[] headers = jsonBody.Length == 0 ? ["Accept: application/json"] :
            ["Accept: application/json", "Content-Type: application/json; charset=utf-8"];
        Error error = request.Request(url, headers, method, jsonBody);
        if (error != Error.Ok) { _active = null; request.CancelRequest(); request.QueueFree(); }
        return error;
    }
    public void Cancel()
    {
        HttpRequest? request = _active; _active = null;
        if (request is null) return;
        request.CancelRequest(); request.QueueFree();
    }
    public override void _ExitTree() => Cancel();
}
LocalHttpFixture.py
Python
"""A loopback-only HTTP fixture. Python 3 standard library; no production data."""
import gzip
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class Handler(BaseHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'

    def log_message(self, *args):
        pass

    def handle(self):
        try:
            super().handle()
        except ConnectionError:
            self.close_connection = True

    def reply(self, status, body, headers=None):
        self.send_response(status)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        for name, value in (headers or {}).items():
            self.send_header(name, value)
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        body = json.dumps({'message': 'Привет 🌍'}, ensure_ascii=False).encode('utf-8')
        path = self.path.split('?', 1)[0]
        try:
            if path == '/slow':
                time.sleep(0.5)
            if path == '/missing':
                self.reply(404, b'{"error":"missing"}')
            elif path == '/bad-json':
                self.reply(200, b'{')
            elif path == '/wrong-schema':
                self.reply(200, b'{"message":42}')
            elif path == '/empty':
                self.reply(204, b'')
            elif path == '/large':
                self.reply(200, b'x' * 70000)
            elif path == '/gzip':
                self.reply(200, gzip.compress(body), {'Content-Encoding': 'gzip'})
            elif path == '/gzip-large':
                self.reply(200, gzip.compress(b'x' * 70000), {'Content-Encoding': 'gzip'})
            elif path == '/redirect':
                self.reply(302, b'', {'Location': '/message'})
            elif path in ('/chunked', '/eof', '/truncated'):
                self.send_response(200)
                self.send_header('Content-Type', 'application/json; charset=utf-8')
                if path == '/chunked':
                    self.send_header('Transfer-Encoding', 'chunked')
                elif path == '/truncated':
                    self.send_header('Content-Length', str(len(body) + 20))
                else:
                    self.send_header('Connection', 'close')
                self.end_headers()
                if path == '/chunked':
                    for start in range(0, len(body), 4):
                        chunk = body[start:start + 4]
                        self.wfile.write(f'{len(chunk):X}\r\n'.encode() + chunk + b'\r\n')
                    self.wfile.write(b'0\r\n\r\n')
                else:
                    self.wfile.write(body)
                    self.close_connection = True
            else:
                self.reply(200, body)
        except ConnectionError:
            self.close_connection = True

    def do_POST(self):
        length = int(self.headers.get('Content-Length', '0'))
        if length > 65536:
            self.reply(413, b'{}')
            self.close_connection = True
            return
        self.reply(200, self.rfile.read(length))


def create_server(port=8765):
    server = ThreadingHTTPServer(('127.0.0.1', port), Handler)
    server.daemon_threads = True
    return server


if __name__ == '__main__':
    with create_server() as server:
        print('HTTP fixture: http://127.0.0.1:8765/message', flush=True)
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            pass

Three result checks

Start returns a startup Error. Error.Ok means the request was accepted, not that a server response has arrived. Completed comes later with three separate validation layers:

CheckMeaning of failure
Transport != SuccessConnection, TLS, timeout, a limit or transfer did not complete successfully
StatusCode outside 200–299The server responded but the application did not accept the request; 404 can have Transport = Success
Failure = json or schemaBytes arrived but JSON is malformed or lacks a string message of at most 128 characters

This client expects a JSON object with message. HTTP permits a bodyless 204 response, but that does not match this schema. Define the schema and status rules for a different API; do not turn every response into successful gameplay.

In the Inspector, change only the path of the complete Url, for example http://127.0.0.1:8765/missing: /missing → http/404; /bad-json → json; /wrong-schema → schema; /large and /gzip-large → BodySizeLimitExceeded; /chunked and /gzip → the correct message. For /slow, create the client in HttpJsonDemo with new HttpJsonRequest { TimeoutSeconds = 0.1 }. Decode multibyte characters after assembling the body rather than decoding each network fragment separately.

POST and binary data

Start accepts a method and jsonBody. Call Start(url, HttpClient.Method.Post, jsonBody) from your handler to send a message; build JSON with a serializer rather than concatenating user input. Content-Type is added when jsonBody is nonempty. The local /echo returns the received bytes for POST testing.

String Request encodes the body as UTF-8. For an image, archive or other binary payload, use HttpRequest.RequestRaw, byte[] and the appropriate Content-Type; the JSON client above is unsuitable for that response. Specify an explicit header array if string[]/ReadOnlySpan overloads become ambiguous.

Cancellation, retries and lifetime

One client instance handles one request; another Start while Busy returns Error.Busy. Use separate instances for independent parallel requests. Cancel clears Busy and frees the current HttpRequest. It does not promise Completed: this implementation emits no completion for cancellation. Update cancellation UI from your cancel handler.

Each start creates a separate HttpRequest. The _active != request guard drops an old response after cancellation and replacement. _ExitTree cancels when the owner is removed. Do not leave a handler that accesses an already deleted screen.

Do not retry every _Process. Bound attempts and delay; repeating POST can repeat the server operation. Your API must define when repeating an operation is safe.

Bounds, redirects and files

This example uses a 65536-byte body limit, TimeoutSeconds = 5 and MaxRedirects = 0. HttpRequest automatically decompresses gzip/deflate with AcceptGZip; the decompressed result is also subject to the limit. Timeout uses a timer with IgnoreTimeScale, but processing still depends on the tree and pause mode. It is not an independent process outside EntityTree.

MaxRedirects = 0 deliberately disables automatic redirects: /redirect ends with RedirectLimitReached. If your API redirects, separately verify allowed destinations, method and headers. Do not forward authorization to an arbitrary server.

UseThreads enables an internal worker when available in the build. It does not move your JSON parser or UI handlers into an automatically safe background context. Ordinary requests need no manual Poll; low-level HttpClient differs.

DownloadFile writes the body to a file while signal bytes remain empty. Even a 404 body can be saved there. Use a separate temporary path, validate transport result, HTTP status and expected content/hash, then publish the file. Account for KeepPartialDownload on cancellation; AppendToDownloadFile is not a complete correct-resume system without server-response validation.

Real local TCP tests cover UTF-8, GET/POST, exact binary bytes, gzip/chunked/EOF, malformed responses, bounds, deadlines, cancellation/replacement and owner deletion. For HTTPS use certificate verification. Exports, proxies, browser restrictions and mobile permissions 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.