HttpClient: подключение и чтение ответа по частям
Полный неблокирующий C#-цикл: Poll, заголовки, chunked/EOF, лимит тела и срок ожидания.
Когда нужен низкий уровень
Для JSON API обычно проще HttpRequest. HttpClient нужен, когда вы сами управляете подключением, чтением частей и форматом ответа. Это не автоматическая многопользовательская синхронизация.
Ниже клиент GET: сетевое ожидание не блокирует _Process, тело собирается в памяти до 65536 байт. Это пример чтения частями, а не запись неограниченного файла на диск.
Запустить пример
Запустите LocalHttpFixture.py из руководства HTTP. Добавьте HttpStreamReader.cs и HttpStreamDemo.cs в C#-проект 1.0.0. Создайте уровень с корнем Entity, прикрепите HttpStreamDemo, соберите и нажмите F6. Output покажет status=200, пустой failure и JSON с сообщением. Поле Url по умолчанию указывает на локальный стенд.
HttpStreamDemo.cs
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
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();
}Жизненный цикл соединения
| Состояние | Действие |
|---|---|
| Resolving / Connecting | Продолжать Poll в следующих кадрах |
| Connected до запроса | Передать метод, путь с query и заголовки в Request |
| Requesting | Продолжать Poll, дождаться заголовков |
| Body | Читать ReadResponseBodyChunk и сохранять байты |
| Connected после тела | Проверить полноту и завершить |
| Disconnected при чтении до EOF | Успех только для подходящего способа определения длины; проверить известную длину |
| CantResolve / CantConnect / ConnectionError / TlsHandshakeError | Завершить с ошибкой, закрыть соединение |
ConnectToHost получает host и port; Request получает /path?query, а не полный URL. Uri в примере разделяет эти части. QueryStringFromDict кодирует значения query; не вставляйте пробелы и пользовательские строки напрямую в URL.
GetResponseBodyLength может вернуть -1. Это не «пустой ответ»: длина может определяться chunked-кодированием или закрытием соединения. Пустая часть чтения также не означает конец: данных пока может не быть. Статус и способ определения длины проверяются вместе.
При известном Content-Length недобор байтов — ошибка. При chunked нужно дождаться завершения framing, а не просто закрытия сокета. В стенде /message, /chunked и /eof дают одинаковый JSON разными способами; /truncated намеренно обрывает неполное тело и должен завершиться ошибкой.
Байты, HTTP-статус и сжатие
Клиент возвращает StatusCode отдельно от Failure. Получить полный ответ 404 технически возможно без ошибки передачи; решение о допустимом статусе принимает вызывающий код. HttpStreamDemo выводит тело для диагностики локального стенда; в рабочей игре не печатайте секретные ответы.
Здесь посылается Accept-Encoding: identity, а неожиданный Content-Encoding отвергается. HttpClient не выполняет распаковку HttpRequest автоматически. Путь /gzip намеренно игнорирует предпочтение и должен дать content-encoding. Если нужна распаковка, добавьте её с отдельным лимитом выходных данных или используйте HttpRequest.
Храните части как байты; UTF-8 символ может пересечь границу. Пример декодирует полное тело один раз. Для действительно большого потока нужны последовательная обработка/запись, проверка ошибок накопителя и отдельный бюджет памяти.
Ограничения и завершение
MaxBytes ограничивает собранное тело. ReadChunkSize = 4096 не доказывает такой же предел для любого внутреннего буфера: входящий HTTP chunk может иметь другой размер. Не описывайте этот пример как защиту от любого расхода памяти или всех повреждённых протокольных ответов.
Срок ожидания измеряется Stopwatch, а не игровым delta. Проверка выполняется в _Process; если обработка приостановлена, просроченный запрос завершится после её возобновления. Для продолжения работы во время паузы согласуйте ProcessMode владельца.
Cancel закрывает и освобождает клиент без Completed. Повторный Start допускается после завершения/отмены; конкурентный запуск получает Error.Busy. _ExitTree закрывает соединение. В коде нет автоматического перенаправления, cookie-хранилища, авторизации, пула соединений, повторов или HTTP/2-клиента.
Проверены реальные TCP-ответы с длиной, chunked и EOF, UTF-8, 404, сжатие как отказ, лимит, усечённое тело, срок ожидания и повтор после отмены. HTTPS использует TlsOptions; доступность других платформ и сетевых условий проверяется отдельно.