Skip to content

Transport and Authentication

Алексей . edited this page Jul 26, 2026 · 1 revision

Transport and Authentication

English Русский

This page covers the layer below LogsParserClient: the ILogsParserDataSource contract, the LogsParserHttpDataSource implementation, the reactive authentication state machine, retries, rate limiting and the React anti-DDoS challenge. It is the page to read when a request hangs, loops or returns something that does not look like log HTML.

ILogsParserDataSource

Namespace LogsParser.Abstractions.

public interface ILogsParserDataSource
{
    Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default);
}

The interface has exactly one member. LogsParserClient builds a relative URI, hands it to this method, receives a raw HTML string and passes it to a static parser — it never touches HttpClient, cookies or credentials. As a consequence, the entire transport is replaceable: implement the interface and the whole client keeps working against files, a cache, a recorded fixture set or a proxy service.

Member Type Description
GetContentAsync Task<string> Returns the raw HTML for request.RelativeUri. Nothing about the return value is validated by the caller — parsers accept whatever string comes back.

ParserRequest

Namespace LogsParser (root).

public sealed record ParserRequest(string RelativeUri, ICookieStorage? CookieStorage = null);
Parameter Type Default Description
RelativeUri string — Relative URI with its query string, as produced by LogsRequestUriBuilder. Resolved against the HttpClient's BaseAddress.
CookieStorage ICookieStorage? null Storage that overrides the data source's default storage for this one request. null means "use the default".

The per-request storage is how one process can serve several independent sessions through a single data source: see Cookie Storage.

A custom data source

A minimal implementation that serves saved HTML from disk instead of hitting the network:

using LogsParser;
using LogsParser.Abstractions;
using LogsParser.Models;

public sealed class FileHtmlDataSource : ILogsParserDataSource
{
    private readonly string _root;

    public FileHtmlDataSource(string root) => _root = root;

    public Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default)
    {
        var name = string.Concat(request.RelativeUri.Split(Path.GetInvalidFileNameChars()));
        return File.ReadAllTextAsync(Path.Combine(_root, name + ".html"), cancellationToken);
    }
}

var client = new LogsParserClient(new FileHtmlDataSource(@"C:\fixtures"));
var page = await client.GetLogsAsync(new LogsQuery(ServerId: 201));

Nothing else in the library has to change. A custom source is also the simplest way to add caching, request throttling or logging in front of the real HTTP source by wrapping LogsParserHttpDataSource and delegating to it.

LogsParserHttpDataSource

Namespace LogsParser.Net. This is the only implementation shipped with the package.

public sealed class LogsParserHttpDataSource : ILogsParserDataSource, IDisposable

Constructor

public LogsParserHttpDataSource(
    LogsParserCredentials? credentials = null,
    ICookieStorage? cookieStorage = null,
    LogsParserHttpOptions? options = null,
    HttpClient? httpClient = null,
    ILoggerFactory? loggerFactory = null)
Parameter Type Default Description
credentials LogsParserCredentials? null Login, password and Base32 TOTP secret. Without them the data source works only for pages that need no session, and throws AuthenticationRequiredException as soon as the service asks for one.
cookieStorage ICookieStorage? null Default cookie storage for every request. When omitted, a new MemoryCookieStorage is created.
options LogsParserHttpOptions? null Transport options. When omitted, a new LogsParserHttpOptions() with all defaults is used.
httpClient HttpClient? null Externally owned client. When omitted, the data source creates and owns one.
loggerFactory ILoggerFactory? null When not null, it is installed into LogsParserLogging — see Logging.

What the constructor actually does

  • No HttpClient supplied. It builds new HttpClientHandler { AllowAutoRedirect = false, AutomaticDecompression = DecompressionMethods.All }, wraps it in an HttpClient created with disposeHandler: true, and remembers that this client is its own. Dispose() disposes it.
  • An HttpClient supplied. The instance is used as-is and is not disposed by Dispose(); its lifetime stays with whoever created it.
  • BaseAddress is assigned with ??=: the LogsParserHttpOptions.BaseUri value is applied only when the client has no BaseAddress yet.
  • User-Agent is added only when DefaultRequestHeaders.UserAgent is empty, Accept only when DefaultRequestHeaders.Accept is empty. An injected client that already sets these keeps its own values and the corresponding options are silently ignored.
  • loggerFactory, when supplied, calls LogsParserLogging.UseLoggerFactory(loggerFactory), which is a process-wide switch, not a per-instance setting.
  • It logs one Debug line describing the base URI, whether credentials are present, MaxRetryAttempts, the cookie storage type and whether the HttpClient is internal or external.

LogsParserHttpOptions

Namespace LogsParser.Models. Full reference in Models; repeated here because every knob on this page lives on this record.

Property Type Default Description
BaseUri Uri https://arizonarp.logsparser.info/ Applied only if the HttpClient has no BaseAddress.
UserAgent string a Chrome 128 desktop UA string Applied only if the client sends no User-Agent.
Accept string the browser text/html,… accept string Applied only if the client sends no Accept.
MaxRetryAttempts int 5 Upper bound on transient-failure attempts.
WaitForRateLimitReset bool true On 429, wait for the reset instant instead of throwing.

GetContentAsync

public async Task<string> GetContentAsync(ParserRequest request, CancellationToken cancellationToken = default)

One call may perform several HTTP round trips: challenge solving, authentication and rate-limit waits all retry the same request internally. Cookies are read from the storage into the request headers before every send and written back from the response headers after it, so the session survives across calls.

Throws:

Exception When
ArgumentNullException request is null.
AuthenticationRequiredException The service redirected to /login or /authenticator and credentials was null.
AuthenticationFailedException Login page unavailable, credentials rejected, or the login branch ran 3 times without stabilising.
TwoFactorAuthenticationException TOTP rejected, secret empty or not Base32, required time zone missing, or the 2FA branch ran 3 times without stabilising.
AccountConfigurationException The service redirected to /profile.
CsrfTokenNotFoundException A page fetched during authentication contained no <meta name="csrf-token" content="…">.
RateLimitExceededException 429 with WaitForRateLimitReset = false, or retries exhausted while backing off from a 429 when no reset instant is known.
ReactShieldBypassException The challenge payload could not be decrypted, or the service kept serving the challenge after 3 solved tokens.
LogsParserHttpException Any other non-success status code, or a transient failure that survived MaxRetryAttempts (the original exception is the InnerException).
OperationCanceledException The token was cancelled, or the retry loop ended without producing content.

All of these except ArgumentNullException and OperationCanceledException derive from LogsParserException — see Exceptions.

Rate-limit properties

public int RateLimitMax { get; private set; }
public int RateLimitRemaining { get; private set; }
public DateTimeOffset? RateLimitReset { get; private set; }

They are declared on the class, not on ILogsParserDataSource, so reading them requires a reference to the concrete type rather than the interface.

Dispose

public void Dispose()

Disposes the internally created HttpClient (and, through disposeHandler: true, its handler). If an HttpClient was injected, Dispose() does nothing to it.

Injecting an HttpClient

Warning — always set AllowAutoRedirect = false. The entire authentication protocol is read off 302 responses: the library inspects Location and decides whether to log in, re-confirm the second factor or fail. A handler that follows redirects automatically resolves those 302s inside HttpClient, so the library never sees them. The call then succeeds and returns the HTML of the login page as if it were content — no exception, no warning, just parsers that find nothing. The default, library-created handler sets AllowAutoRedirect = false; every injected client must do the same.

The usual reason to inject a client is a proxy:

using System.Net;
using LogsParser;
using LogsParser.Models;
using LogsParser.Net;

var handler = new HttpClientHandler
{
    AllowAutoRedirect = false,                        // mandatory
    AutomaticDecompression = DecompressionMethods.All,
    Proxy = new WebProxy("socks5://127.0.0.1:1488"),
    UseProxy = true
};

using var httpClient = new HttpClient(handler)
{
    Timeout = TimeSpan.FromSeconds(60)
};

using var dataSource = new LogsParserHttpDataSource(
    credentials: new LogsParserCredentials("my_login", "my_password", "BASE32SECRET"),
    options: new LogsParserHttpOptions { MaxRetryAttempts = 3 },
    httpClient: httpClient);

var client = new LogsParserClient(dataSource);

Here BaseAddress, User-Agent and Accept are still taken from the options, because this client sets none of them. Setting httpClient.BaseAddress yourself would make LogsParserHttpOptions.BaseUri inert. httpClient is disposed by the using in this snippet, not by the data source.

Authentication

Reactive, not proactive

There is no LoginAsync, no SignInAsync and no "connect" step. Authentication happens because a data request came back as a redirect: the data source recognises the target, runs the matching flow, and then retries the original request. The first call in a process therefore transparently performs the login, and later calls do nothing extra until the service says otherwise.

The state machine

Applies to a 302 Found on the requested URI, keyed on Location.AbsolutePath:

Location What the library does
/login Full login. GET /login, read the token out of <meta name="csrf-token" content="…">, POST /login with _token, name and password, then fall straight through into the TOTP confirmation. A response that redirects back to /login means the credentials were rejected → AuthenticationFailedException.
/authenticator TOTP re-confirmation only. GET /authenticator, take a fresh CSRF token, POST /authenticator with _token and code. The password is not re-posted — this is the mid-session path where the first factor is still valid but the 2FA confirmation window lapsed. A response that redirects back to /authenticator means the code was rejected → TwoFactorAuthenticationException.
/profile AccountConfigurationException. The account exists but is not set up on the site; no retry can fix it.

If credentials were not supplied, the /login and /authenticator branches both throw AuthenticationRequiredException before doing any work.

One detail of the 2FA branch: if GET /authenticator itself answers with a redirect, the form is not being served — either the first factor is gone (/login) or the session is already confirmed — so nothing is submitted and the original request is simply retried, which re-enters the state machine with the new signal.

Attempt bounds

The /login and /authenticator branches share one counter with a hard-coded bound of 3. Exceeding it throws AuthenticationFailedException from the login branch and TwoFactorAuthenticationException from the 2FA branch. The bound protects against a service that keeps redirecting to a login flow which reports success.

TOTP

The library implements RFC 6238 itself: Base32 decode → HMAC-SHA1 over the 30-second counter → dynamic truncation → a 6-digit code. It consumes a Base32 secret string and nothing else — QR images, otpauth:// URIs and Google Authenticator migration payloads are not parsed; extract the secret yourself and pass it as LogsParserCredentials.TotpSecret.

var credentials = new LogsParserCredentials(
    Login: "my_login",
    Password: "my_password",
    TotpSecret: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");

The code is generated against the site's own time zone, resolved as America/Juneau with the Windows identifier Alaskan Standard Time as a fallback. When neither identifier exists on the host, generation throws TwoFactorAuthenticationException rather than quietly falling back to UTC, so a container without time-zone data fails loudly instead of producing codes the site rejects. An empty secret and a secret containing non-Base32 characters throw the same exception.

Retries and backoff

Three independent bounds are at work inside one GetContentAsync call:

Bound Value Applies to
LogsParserHttpOptions.MaxRetryAttempts 5 by default Transient failures — arbitrary exceptions from the send, and 429 backoff when no reset instant is known.
Authentication attempts 3, hard-coded Shared by the /login and /authenticator branches.
Challenge attempts 3, hard-coded React challenge solving.

Transient failures back off exponentially: after the n-th failure the loop waits 2^n seconds (2 s, 4 s, 8 s, …) before sending again. When the attempts are used up, the last exception is wrapped in a LogsParserHttpException whose message names the URI and the attempt count.

Two things deliberately do not consume a retry attempt: solving a React challenge, and waiting out a rate limit when the reset instant is known. Both are expected, self-correcting states rather than failures, and charging them against MaxRetryAttempts would let a busy period exhaust the budget before a real error ever occurred.

Any exception deriving from LogsParserException is rethrown immediately and is never retried. That includes every exception in the table above. A new domain exception that does not derive from LogsParserException would be treated as a transient failure and silently retried MaxRetryAttempts times.

Cancellation is also never retried: OperationCanceledException propagates as-is.

Rate limiting

Every response to the requested URI updates three properties from response headers:

Header Property Type Notes
X-Ratelimit-Limit RateLimitMax int Requests allowed per window.
X-Ratelimit-Remaining RateLimitRemaining int Requests left in the current window.
X-Ratelimit-Reset RateLimitReset DateTimeOffset? Unix seconds; applied only when the value parses and is greater than zero.

A header that is absent or unparseable leaves the corresponding property at its previous value, so the properties always report the last observation rather than resetting to zero.

On 429 Too Many Requests:

  • WaitForRateLimitReset = true (default). If RateLimitReset is known, the loop sleeps until that instant plus one second and retries without consuming a retry attempt. If it is not known, it falls back to the Retry-After header, or to 2^(attempt+1) seconds when that header is missing too — and this fallback path does consume a retry attempt, throwing RateLimitExceededException once they are exhausted.
  • WaitForRateLimitReset = false. Throws RateLimitExceededException immediately.

RateLimitExceededException (namespace LogsParser.Exceptions) carries the wait it would have used:

Property Type Description
RetryAfterSeconds int Seconds resolved from Retry-After, or the exponential fallback.
ResetAt DateTimeOffset? The RateLimitReset value at the moment of the failure, when known.

A polite client checks the remaining budget between calls instead of waiting for a 429:

using var dataSource = new LogsParserHttpDataSource(
    credentials: credentials,
    options: new LogsParserHttpOptions { WaitForRateLimitReset = false });

var client = new LogsParserClient(dataSource);

foreach (var serverId in serverIds)
{
    if (dataSource.RateLimitRemaining <= 1 && dataSource.RateLimitReset is { } resetAt)
    {
        var pause = resetAt - DateTimeOffset.UtcNow + TimeSpan.FromSeconds(1);
        if (pause > TimeSpan.Zero)
        {
            await Task.Delay(pause);
        }
    }

    var page = await client.GetLogsAsync(new LogsQuery(ServerId: serverId));
    Console.WriteLine($"{serverId}: {page.Entries.Count} entries, {dataSource.RateLimitRemaining}/{dataSource.RateLimitMax} left");
}

The React anti-DDoS challenge

The site sits behind a React/vDDoS shield that occasionally answers a normal request with 200 OK and a page of obfuscated JavaScript instead of content. The page contains an AES-128-CBC puzzle; a real browser runs the script, derives a token, stores it as a cookie and reloads.

The library does the same thing, and it does it automatically — there is no API to call and no option to enable:

  1. Detection. A 200 response is treated as a challenge only when its server header equals nginx and its cache-control header equals no-cache, and the body contains /vddosw3data.js or Please turn JavaScript on and reload the page. These literals mirror the live service.
  2. Solving. The AES key, IV and payload are recovered from the script and the block is decrypted. The preferred path reads them from the obfuscated indexed array; if that shape does not match, structurally plausible triples of hex values are tried in turn.
  3. Storing. The resulting token is written into the same cookie storage as the session cookies, under the exact name R3ACTLB. Persisting the storage therefore persists the shield token together with the session — see Cookie Storage.
  4. Retrying. The original request is sent again with the new cookie, without consuming a retry attempt.

Nothing in the decrypted plaintext identifies it as the correct token, so a solved token cannot be validated before it is used: a wrong one simply produces another challenge. That is exactly why the challenge branch carries its own bound of 3 — without it, a service that kept re-issuing the challenge would spin forever. On the fourth challenge the call throws ReactShieldBypassException instead. The same exception is thrown when the payload cannot be decrypted at all, or when the page yields fewer than three distinct string literals to try.

ReactShieldBypass itself is an internal implementation detail and is not part of the public API.

Diagnosing a stuck request

Symptom Cause Fix
The call succeeds but the content is the login page; parsers return empty results An injected HttpClient whose handler follows redirects — the library never sees the 302s Set AllowAutoRedirect = false on the handler
AuthenticationRequiredException The service asked for a session and credentials was null Pass LogsParserCredentials to the constructor, or via Dependency Injection
AuthenticationFailedException on the first request Login or password rejected (redirect back to /login), or the login page did not load Verify the credentials against the site in a browser
TwoFactorAuthenticationException mentioning time zones Neither America/Juneau nor Alaskan Standard Time is installed on the host — common in slim Linux containers Install the tz database (for example the tzdata package)
TwoFactorAuthenticationException on a rejected code Wrong Base32 secret, or host clock drift Re-copy the secret; synchronise the system clock
AccountConfigurationException The service redirected to /profile: the account is not configured on the site Finish the account setup in a browser; retries cannot help
ReactShieldBypassException The shield kept re-issuing the challenge, or its payload changed shape Retry later; if it persists, the challenge format has moved and the solver needs updating
Calls stall for long stretches with Warning logs about waiting 429 responses with WaitForRateLimitReset = true — this is the intended behaviour Lower the request rate, or set WaitForRateLimitReset = false and handle RateLimitExceededException
LogsParserHttpException wrapping a TaskCanceledException Network or HttpClient.Timeout failures retried to exhaustion Raise the timeout, check the proxy, inspect the InnerException
Nothing is logged at all No logger factory was installed Pass loggerFactory to the constructor — see Logging

Turning the log level up to Trace shows every send with its status code, the cookie names in play and the rate-limit counters, which is usually enough to tell these cases apart.

See also

Clone this wiki locally