Skip to content

Harden AOT request header defaults to prevent CDN 428 responses - #229

Closed
bezzad with Copilot wants to merge 4 commits into
masterfrom
copilot/fix-aot-download-failure
Closed

Harden AOT request header defaults to prevent CDN 428 responses#229
bezzad with Copilot wants to merge 4 commits into
masterfrom
copilot/fix-aot-download-failure

Conversation

Copilot AI commented May 9, 2026

Copy link
Copy Markdown

In AOT/trimmed deployments, the default User-Agent could degrade to invalid forms (e.g., Downloader/ or zero-version variants), which some BunnyCDN/edge setups reject with HTTP 428. This change ensures request headers remain valid and deterministic in AOT while preserving explicit user configuration.

  • AOT-safe default User-Agent generation (RequestConfiguration)

    • Replaced Assembly.GetExecutingAssembly().GetName().Version usage with an explicit fallback chain on typeof(RequestConfiguration).Assembly:
      1. Assembly version
      2. AssemblyInformationalVersionAttribute
      3. AssemblyFileVersionAttribute
      4. hard fallback (5.0)
    • Normalizes version values so defaults cannot become empty or zero-version edge cases.
  • Defensive header normalization in SocketClient

    • Added User-Agent guardrails before attaching default headers:
      • fallback applied for null/empty, trailing-slash, and known invalid zero-version forms.
    • Added default Accept: */* when Accept is not configured.
    • Preserves explicitly configured User-Agent and Accept values unchanged.
  • Coverage for regression scenarios

    • Added/extended unit tests to assert:
      • default User-Agent is non-empty, Downloader/..., and never malformed;
      • SocketClient emits a safe fallback UA when configured UA is null/empty/invalid;
      • default Accept: */* is applied only when unset;
      • custom User-Agent/Accept values are preserved.
// RequestConfiguration default
UserAgent = BuildDefaultUserAgent();

// SocketClient defaults
AddHeaderIfNotEmpty(client.DefaultRequestHeaders, "Accept", ResolveAcceptHeader(requestConfig.Accept));
AddHeaderIfNotEmpty(client.DefaultRequestHeaders, "User-Agent", ResolveUserAgent(requestConfig.UserAgent));

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • nofile
    • Triggering command: /usr/share/dotnet/dotnet /usr/share/dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.runtimeconfig.json --depsfile /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.deps.json /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/testhost.dll --port 36289 --endpoint 127.0.0.1:036289 --role client --parentprocessid 8090 --telemetryoptedin false (dns block)
    • Triggering command: /usr/share/dotnet/dotnet /usr/share/dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.runtimeconfig.json --depsfile /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.deps.json /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/testhost.dll --port 43841 --endpoint 127.0.0.1:043841 --role client --parentprocessid 9649 --telemetryoptedin false (dns block)
    • Triggering command: /usr/share/dotnet/dotnet /usr/share/dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.runtimeconfig.json --depsfile /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.deps.json /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/testhost.dll --port 40769 --endpoint 127.0.0.1:040769 --role client --parentprocessid 11250 --telemetryoptedin false (dns block)
  • www.a.com
    • Triggering command: /usr/share/dotnet/dotnet /usr/share/dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.runtimeconfig.json --depsfile /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.deps.json /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/testhost.dll --port 36289 --endpoint 127.0.0.1:036289 --role client --parentprocessid 8090 --telemetryoptedin false (dns block)
    • Triggering command: /usr/share/dotnet/dotnet /usr/share/dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.runtimeconfig.json --depsfile /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.deps.json /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/testhost.dll --port 43841 --endpoint 127.0.0.1:043841 --role client --parentprocessid 9649 --telemetryoptedin false (dns block)
    • Triggering command: /usr/share/dotnet/dotnet /usr/share/dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.runtimeconfig.json --depsfile /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/Downloader.Test.deps.json /home/REDACTED/work/Downloader/Downloader/src/Downloader.Test/bin/Debug/net10.0/testhost.dll --port 40769 --endpoint 127.0.0.1:040769 --role client --parentprocessid 11250 --telemetryoptedin false (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Original prompt

Issue

Reference: #226

In the AOT (Ahead-Of-Time compiled) environment, downloading some links fails since version 4.1.0 (still broken on 5.3.0 / 5.4.0). The same URLs download fine in JIT mode and worked in version 4.0.3.

Failing URL

https://cdn.vintagestory.at/gamefiles/stable/vs_client_linux-x64_1.22.0.tar.gz

Stack trace from the user

System.Net.Http.HttpRequestException: Response status code does not indicate success: 428.
   at System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()
   at Downloader.SocketClient.SendRequestAsync(...)
   at Downloader.ChunkDownloader.DownloadChunk(...)
   at Downloader.ChunkDownloader.Download(...)
   at Downloader.DownloadService.DownloadChunk(...)

HTTP 428 = "Precondition Required". The server (BunnyCDN / Cloudflare-style edge) requires the client to send specific headers such as If-Match, If-Range, or — most commonly with this CDN — a real, well-formed User-Agent. The CDN rejects requests whose default User-Agent it considers "bad" or whose request shape it doesn't like.

The user confirmed:

  • v4.0.3 works.
  • v4.1.0 → 5.4.0 (latest) all fail with 428.
  • Bug only reproduces in AOT (PublishAot=true). Same code, JIT-published, works.

Investigation hints

The relevant request-building code lives in:

  • src/Downloader/SocketClient.csGetHttpClientWithSocketHandler(...) and FetchResponseHeaders(...) and SendRequestAsync(...).
  • src/Downloader/RequestConfiguration.cs — default UserAgent is built using:
    UserAgent = $"{nameof(Downloader)}/{Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)}";
  • src/Downloader/Request.csGetRequest() creates HttpRequestMessage with Configuration.ProtocolVersion (HttpVersion.Version11).

Likely root causes (please verify and fix)

  1. Assembly.GetExecutingAssembly().GetName().Version returns null (or 0.0.0) under AOT / single-file / trimmed publish.
    When that happens, the default UserAgent becomes "Downloader/" (trailing slash, empty version) or "Downloader/0.0.0". BunnyCDN and similar edges respond with HTTP 428 to malformed/empty/suspicious User-Agents.

    • In JIT this evaluates to a real version like Downloader/5.4.0 and the CDN accepts it.
    • In AOT, Assembly.GetExecutingAssembly() works but GetName().Version is often 0.0.0.0 for trimmed/AOT assemblies unless <AssemblyVersion> / <Version> is explicitly embedded — and even then, on some configurations the value is null.
    • This is a regression introduced in 4.1.0 (before 4.1.0 the User-Agent was likely set differently or had a fallback).
  2. The current AddHeaderIfNotEmpty skips empty values, so if UserAgent ends up being just "Downloader/" (non-empty), it's still sent and rejected. We must also guard against the version being missing.

  3. Some CDNs additionally reject requests that don't include an Accept header. The default Accept is null and is not added. Adding */* as a safe default for the request-level Accept header (only when not configured by the user) avoids further 428s on these CDNs.

Required fix

Please make the following changes:

1. src/Downloader/RequestConfiguration.cs

Change the default UserAgent so it always produces a valid, non-empty, version-bearing User-Agent string under AOT and trimmed/single-file deployments. Use a robust helper that:

  1. Tries typeof(RequestConfiguration).Assembly.GetName().Version.
  2. Falls back to AssemblyInformationalVersionAttribute (InformationalVersion) read via typeof(RequestConfiguration).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion.
  3. Falls back to AssemblyFileVersionAttribute.
  4. As a last resort, falls back to a hardcoded current-major version string (e.g. "5.0").

Final default should look like e.g. "Downloader/5.4.0", never "Downloader/" or "Downloader/0.0.0.0".

Replace the line:

UserAgent = $"{nameof(Downloader)}/{Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)}";

with a call to a helper, e.g.:

UserAgent = BuildDefaultUserAgent();

and add a private static method BuildDefaultUserAgent() implementing the fallback chain above. Use typeof(RequestConfiguration).Assembly instead of Assembly.GetExecutingAssembly() (more AOT-friendly and unambiguous in single-file scenarios).

2. src/Downloader/SocketClient.cs (GetHttpClientWithSocketHandler)

After the existing logic that adds Accept and User-Agent:

  • If the resolved User-Agent (after defaults) is null/empty/"Downloader/"/ends with '/', replace it with a safe fallback such as "Downloader/5.0" before adding it to DefaultRequestHeaders. This is a defense-in-depth safeguard so that even a user who explicitly sets UserAgent = "" still produces a working request.
  • If requestConfig.Accept is null/empty, add a default `Accep...

This pull request was created from Copilot chat.

Comment thread src/Downloader/SocketClient.cs Outdated
Copilot AI changed the title [WIP] Fix AOT download failure for specific links Harden AOT request header defaults to prevent CDN 428 responses May 9, 2026
Copilot finished work on behalf of bezzad May 9, 2026 07:32
Copilot AI requested a review from bezzad May 9, 2026 07:32
Co-authored-by: codefactor-io[bot] <47775046+codefactor-io[bot]@users.noreply.github.com>

@bezzad bezzad left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

@bezzad bezzad closed this May 9, 2026
@bezzad
bezzad deleted the copilot/fix-aot-download-failure branch May 9, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants