Gestire le query WHOIS in C#

Gestire le query WHOIS in C#

.NET mette a disposizione tutto ciò che serve per scrivere un client di rete moderno: TcpClient e NetworkStream con API completamente asincrone, i CancellationToken per propagare timeout e annullamenti, le espressioni regolari generate in fase di compilazione, i record per i dati immutabili e un'infrastruttura di dependency injection e caching pronta all'uso. In questo articolo costruiamo con .NET 8 un client WHOIS completo e lo esponiamo tramite una Minimal API di ASP.NET Core, curando timeout, limiti, codifica dei caratteri, referral, parsing, cache e limitazione della frequenza.

Qualche parola sul contesto. Dal 28 gennaio 2025 registry e registrar dei domini generici (gTLD) non sono più tenuti dai contratti con ICANN a offrire WHOIS sulla porta 43, e il protocollo di riferimento per quei domini è RDAP. WHOIS però resta molto usato: lo offrono gran parte dei registry nazionali, compresi quelli di .it e .de, e diversi registry generici, tra cui quello del .com. Il client dovrà quindi funzionare in modo affidabile dove il servizio esiste e fallire in modo pulito dove non risponde più.

Il protocollo

La RFC 3912 descrive un protocollo essenziale: il client apre una connessione TCP verso la porta 43, invia la query seguita da \r\n e legge finché il server non chiude la connessione. Non ci sono intestazioni, lunghezze o codifiche dichiarate, e la risposta è testo libero. Per sapere quale server interrogare si parte da whois.iana.org, che indica nel campo refer: il server del registry del TLD; la risposta del registry può a sua volta indicare, nel campo Registrar WHOIS Server:, il server del registrar.

Una prima query

La prima versione, un'applicazione console con istruzioni di primo livello, mostra quanto il protocollo sia semplice.

using System.Net.Sockets;
using System.Text;

using var client = new TcpClient();
await client.ConnectAsync("whois.verisign-grs.com", 43);
await using var stream = client.GetStream();

// La query è il dominio seguito da CRLF
await stream.WriteAsync(Encoding.ASCII.GetBytes("domain example.com\r\n"));

using var reader = new StreamReader(stream, Encoding.UTF8);
Console.WriteLine(await reader.ReadToEndAsync());

La parola chiave domain limita la ricerca ai soli domini sul server di Verisign. Il codice funziona, ma non ha timeout, legge quantità illimitate di dati e dà per scontata la codifica UTF-8. Prima di migliorarlo definiamo i tipi di base.

Eccezioni, opzioni e record

Definiamo una gerarchia di eccezioni che distingue gli input non validi dagli errori dei server remoti, una classe di opzioni configurabile tramite il sistema di configurazione di .NET e il record che rappresenta il risultato.

namespace Whois;

public class WhoisException(string message, Exception? inner = null) : Exception(message, inner);

public sealed class InvalidDomainException(string input)
    : WhoisException($"Dominio non valido: {input}");

public sealed class WhoisServerException(string server, string message, Exception? inner = null)
    : WhoisException(message, inner)
{
    public string Server { get; } = server;
}

public sealed class WhoisOptions
{
    public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);
    public TimeSpan Deadline { get; set; } = TimeSpan.FromSeconds(30);
    public int MaxBytes { get; set; } = 1024 * 1024;
    public TimeSpan MinInterval { get; set; } = TimeSpan.FromSeconds(1);
    public TimeSpan CacheTtl { get; set; } = TimeSpan.FromHours(1);
}

public sealed record WhoisRecord(
    string Domain,
    bool Available,
    string? Registrar,
    string? CreatedAt,
    string? UpdatedAt,
    string? ExpiresAt,
    IReadOnlyList<string> NameServers,
    IReadOnlyList<string> Status,
    IReadOnlyList<string> Servers,
    string Raw);

I costruttori primari, disponibili per le classi da C# 12, rendono le eccezioni molto compatte. Il record è immutabile per costruzione e, grazie all'uguaglianza basata sui valori, semplifica anche la scrittura dei test.

Normalizzare il dominio

La validazione dell'input ha un risvolto di sicurezza: la query WHOIS è una riga terminata da \r\n, e un input contenente caratteri di fine riga permetterebbe di iniettare comandi aggiuntivi. I domini internazionalizzati vanno invece convertiti in Punycode: caffè.it diventa xn--caff-8oa.it. La classe IdnMapping di System.Globalization effettua la conversione, e l'opzione UseStd3AsciiRules rifiuta i caratteri non ammessi nei nomi host.

Per le espressioni regolari usiamo l'attributo GeneratedRegex: il codice di matching viene generato in fase di compilazione, con prestazioni migliori e nessun costo di costruzione a runtime.

using System.Globalization;
using System.Text.RegularExpressions;

namespace Whois;

public static partial class DomainName
{
    [GeneratedRegex("^[a-z0-9.-]+$")]
    private static partial Regex AsciiDomain();

    public static string Normalize(string? input)
    {
        // Rimuove spazi, maiuscole e l'eventuale punto finale
        var domain = (input ?? string.Empty).Trim().ToLowerInvariant().TrimEnd('.');

        string ascii;
        try
        {
            ascii = new IdnMapping { UseStd3AsciiRules = true }.GetAscii(domain).ToLowerInvariant();
        }
        catch (ArgumentException)
        {
            throw new InvalidDomainException(input ?? string.Empty);
        }

        if (!AsciiDomain().IsMatch(ascii) || ascii.Length > 253)
        {
            throw new InvalidDomainException(input ?? string.Empty);
        }

        var labels = ascii.Split('.');
        if (labels.Length < 2 || labels.Any(label =>
                label.Length is 0 or > 63 || label.StartsWith('-') || label.EndsWith('-')))
        {
            throw new InvalidDomainException(input ?? string.Empty);
        }

        return ascii;
    }

    public static string TopLevelDomain(string domain) => domain[(domain.LastIndexOf('.') + 1)..];
}

Un'istanza di IdnMapping viene creata a ogni chiamata: l'oggetto è leggero, e così si evita qualsiasi dubbio sulla condivisione tra thread di un oggetto configurabile. L'eccezione ArgumentException sollevata da GetAscii() per gli input non validi viene tradotta nell'eccezione del nostro modulo, così chi chiama non deve conoscere i dettagli dell'API di globalizzazione.

Il trasporto asincrono

In .NET la gestione dei timeout passa per i CancellationToken. Creiamo un token collegato a quello del chiamante con CreateLinkedTokenSource() e programmiamo il suo annullamento con CancelAfter(): in questo modo la query si interrompe sia quando scade la scadenza complessiva, sia quando il chiamante annulla l'operazione, ad esempio perché il client HTTP si è disconnesso. Per distinguere il timeout dall'annullamento esterno basta controllare quale dei due token è stato annullato.

Per il timeout di inattività usiamo un secondo token, reimpostato a ogni blocco ricevuto: se il server smette di inviare dati per l'intervallo indicato, la lettura si interrompe anche se la scadenza complessiva non è ancora arrivata.

using System.Buffers;
using System.Net.Sockets;
using System.Text;

namespace Whois;

public interface IWhoisTransport
{
    Task<string> QueryAsync(string server, string query, CancellationToken cancellationToken);
}

public sealed class TcpWhoisTransport(WhoisOptions options) : IWhoisTransport
{
    public const int Port = 43;

    private static readonly Encoding StrictUtf8 =
        new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);

    public async Task<string> QueryAsync(string server, string query, CancellationToken cancellationToken)
    {
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(options.Deadline);

        var buffer = ArrayPool<byte>.Shared.Rent(8192);
        try
        {
            using var client = new TcpClient();

            using (var connectTimeout = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token))
            {
                connectTimeout.CancelAfter(options.Timeout);
                await client.ConnectAsync(server, Port, connectTimeout.Token);
            }

            await using var stream = client.GetStream();
            await stream.WriteAsync(Encoding.ASCII.GetBytes(query + "\r\n"), deadline.Token);

            using var response = new MemoryStream();
            while (true)
            {
                // Timeout di inattività: reimpostato a ogni lettura
                using var idle = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token);
                idle.CancelAfter(options.Timeout);

                var read = await stream.ReadAsync(buffer.AsMemory(), idle.Token);
                if (read == 0)
                {
                    break;
                }
                if (response.Length + read > options.MaxBytes)
                {
                    throw new WhoisServerException(server, $"Risposta oltre il limite di {options.MaxBytes} byte");
                }
                response.Write(buffer, 0, read);
            }

            return Decode(response.ToArray());
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            // L'annullamento non viene dal chiamante: è scaduto un nostro timeout
            throw new WhoisServerException(server, "Timeout durante la query");
        }
        catch (SocketException e)
        {
            throw new WhoisServerException(server, $"Errore di rete: {e.Message}", e);
        }
        catch (IOException e)
        {
            throw new WhoisServerException(server, $"Errore di I/O: {e.Message}", e);
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }

    public static string Decode(byte[] data)
    {
        try
        {
            return StrictUtf8.GetString(data);
        }
        catch (DecoderFallbackException)
        {
            // Latin-1 decodifica qualsiasi sequenza di byte
            return Encoding.Latin1.GetString(data);
        }
    }
}

Il filtro when sulla clausola catch è un dettaglio importante: se è stato il chiamante ad annullare, l'eccezione OperationCanceledException deve propagarsi così com'è, perché nel modello di .NET l'annullamento richiesto non è un errore da trasformare. Solo quando l'annullamento è dovuto ai nostri timeout lo convertiamo in un WhoisServerException. Il buffer di lettura viene preso in prestito da ArrayPool e restituito nel blocco finally, così le query frequenti non generano allocazioni inutili.

La decodifica usa un UTF8Encoding configurato per sollevare un'eccezione sui byte non validi. La proprietà Encoding.UTF8, al contrario, li sostituirebbe silenziosamente con il carattere di rimpiazzo, impedendoci di accorgerci che la risposta era in Latin-1. L'interfaccia IWhoisTransport servirà per sostituire la rete con un'implementazione finta nei test.

Il parser

Le risposte si dividono in due famiglie: righe Chiave: valore e blocchi formati da un'intestazione senza due punti seguita da righe rientrate, come nel registry italiano. Il parser gestisce entrambe e produce un dizionario di liste, perché molte chiavi si ripetono.

using System.Text.RegularExpressions;

namespace Whois;

public static partial class WhoisParser
{
    private static readonly string[] CommentPrefixes = ["%", "#", ">>>"];

    private static readonly Dictionary<string, string[]> FieldAliases = new()
    {
        ["registrar"] = ["registrar", "registrar.organization", "registrar.name", "sponsoring registrar"],
        ["created"] = ["creation date", "created", "created on", "registered on", "registration time"],
        ["updated"] = ["updated date", "last update", "last updated", "changed", "modified"],
        ["expires"] =
        [
            "registry expiry date", "registrar registration expiration date",
            "expiry date", "expire date", "expire", "expires", "expiration time",
        ],
        ["nameServers"] = ["name server", "nameservers", "nserver"],
        ["status"] = ["domain status", "status", "state"],
    };

    [GeneratedRegex(@"^\s*(?:refer|whois|Registrar WHOIS Server):\s*(\S+)", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
    private static partial Regex Referral();

    [GeneratedRegex(@"^[a-z0-9.-]+\.[a-z]{2,}$")]
    private static partial Regex Hostname();

    [GeneratedRegex(@"^(no match for|not found|status:\s*(available|free)\b)|no data found|no entries found|domain not found",
        RegexOptions.IgnoreCase | RegexOptions.Multiline)]
    private static partial Regex NotFound();

    public static Dictionary<string, List<string>> ParseFields(string text)
    {
        var fields = new Dictionary<string, List<string>>();
        string? section = null;

        void Add(string key, string value)
        {
            if (!fields.TryGetValue(key, out var values))
            {
                fields[key] = values = [];
            }
            values.Add(value);
        }

        foreach (var rawLine in text.Split('\n'))
        {
            var line = rawLine.Trim();

            // Una riga vuota chiude il blocco corrente
            if (line.Length == 0)
            {
                section = null;
                continue;
            }
            if (CommentPrefixes.Any(prefix => line.StartsWith(prefix, StringComparison.Ordinal)))
            {
                continue;
            }

            var separator = line.IndexOf(':');
            if (separator <= 0)
            {
                // Valore del blocco corrente oppure intestazione di un nuovo blocco
                if (section is not null)
                {
                    Add(section, line);
                }
                else
                {
                    section = line.ToLowerInvariant();
                }
                continue;
            }

            var key = line[..separator].Trim().ToLowerInvariant();
            var value = line[(separator + 1)..].Trim();
            if (value.Length == 0)
            {
                section = key;
                continue;
            }

            Add(section is null ? key : $"{section}.{key}", value);
        }

        return fields;
    }

    public static bool IsAvailable(string text) => NotFound().IsMatch(text);

    public static string? FindReferral(string text)
    {
        // Le corrispondenze vengono esaminate nell'ordine del testo
        foreach (Match match in Referral().Matches(text))
        {
            var host = Regex.Replace(match.Groups[1].Value, "^[a-z]+://", "", RegexOptions.IgnoreCase);
            var cut = host.IndexOfAny(['/', ':']);
            host = (cut >= 0 ? host[..cut] : host).ToLowerInvariant();
            if (Hostname().IsMatch(host))
            {
                return host;
            }
        }
        return null;
    }

    public static WhoisRecord ToRecord(string domain, string text, IReadOnlyList<string> servers)
    {
        var fields = ParseFields(text);

        string? First(string name) => FieldAliases[name]
            .Select(alias => fields.TryGetValue(alias, out var values) ? values[0] : null)
            .FirstOrDefault(value => value is not null);

        List<string> All(string name) => FieldAliases[name]
            .SelectMany(alias => fields.TryGetValue(alias, out var values) ? values : new List<string>())
            .Select(value => value.Split(' ', '\t')[0].ToLowerInvariant())
            .Distinct()
            .ToList();

        return new WhoisRecord(
            domain,
            IsAvailable(text),
            First("registrar"),
            First("created"),
            First("updated"),
            First("expires"),
            All("nameServers"),
            All("status"),
            servers,
            text);
    }
}

In questa versione le tre espressioni per i referral sono unite in un'unica alternanza. Questo cambia leggermente il comportamento rispetto a provare i modelli in sequenza: la prima corrispondenza trovata è quella che compare prima nel testo, non quella con il modello più prioritario. Per le risposte reali la differenza è irrilevante, perché ciascun server usa solo uno dei tre campi. Il controllo di disponibilità, come sempre, è un'euristica sul testo grezzo che solo il registrar può confermare al momento della registrazione.

La divisione delle righe avviene sul solo carattere \n, e il successivo Trim() rimuove l'eventuale \r finale: è un modo semplice e rapido per gestire sia le terminazioni Unix sia quelle Windows.

Il client: referral, cache e limitazione

Il client coordina la risoluzione. Ricorda il server del registry per ogni TLD, segue i referral evitando i cicli e tollera il fallimento del solo registrar. Per la cache usiamo IMemoryCache, mentre la deduplicazione delle richieste concorrenti è affidata a un ConcurrentDictionary di Lazy<Task<WhoisRecord>>: il metodo GetOrAdd() può invocare la funzione di creazione più volte in caso di concorrenza, ma con Lazy solo una delle istanze create verrà effettivamente eseguita.

using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;

namespace Whois;

public sealed class WhoisClient(IWhoisTransport transport, IMemoryCache cache, WhoisOptions options)
{
    public const string IanaServer = "whois.iana.org";
    private const int MaxHops = 4;

    private static readonly Dictionary<string, string> QueryTemplates = new()
    {
        ["whois.verisign-grs.com"] = "domain {0}",
    };

    private readonly ConcurrentDictionary<string, string> _tldServers = new();
    private readonly ConcurrentDictionary<string, Lazy<Task<WhoisRecord>>> _inFlight = new();
    private readonly Dictionary<string, DateTimeOffset> _nextSlot = new();
    private readonly object _slotLock = new();

    public async Task<WhoisRecord> LookupAsync(string input, bool fresh, CancellationToken cancellationToken)
    {
        var domain = DomainName.Normalize(input);
        var cacheKey = $"whois:{domain}";

        if (!fresh && cache.TryGetValue(cacheKey, out WhoisRecord? cached) && cached is not null)
        {
            return cached;
        }

        // Le richieste concorrenti per lo stesso dominio condividono un unico task
        var lazy = _inFlight.GetOrAdd(domain, key => new Lazy<Task<WhoisRecord>>(
            () => ResolveSharedAsync(key, cacheKey)));

        // Ogni chiamante attende con il proprio token, senza annullare il lavoro comune
        return await lazy.Value.WaitAsync(cancellationToken);
    }

    private async Task<WhoisRecord> ResolveSharedAsync(string domain, string cacheKey)
    {
        try
        {
            var record = await ResolveAsync(domain, CancellationToken.None);
            cache.Set(cacheKey, record, options.CacheTtl);
            return record;
        }
        finally
        {
            // A risoluzione conclusa, con successo o con errore, la voce viene rimossa
            _inFlight.TryRemove(domain, out _);
        }
    }

    private async Task<WhoisRecord> ResolveAsync(string domain, CancellationToken cancellationToken)
    {
        var tld = DomainName.TopLevelDomain(domain);
        var server = _tldServers.GetValueOrDefault(tld, IanaServer);
        var servers = new List<string>();
        var visited = new HashSet<string>();
        string? answer = null;

        while (server is not null && servers.Count < MaxHops && visited.Add(server))
        {
            servers.Add(server);
            string text;
            try
            {
                text = await QueryAsync(server, BuildQuery(server, domain), cancellationToken);
            }
            catch (WhoisServerException) when (answer is not null)
            {
                // Con una risposta del registry già ottenuta, un registrar muto non è fatale
                break;
            }

            var next = WhoisParser.FindReferral(text);
            if (server == IanaServer)
            {
                if (next is not null)
                {
                    _tldServers[tld] = next;
                }
            }
            else
            {
                answer = text;
            }
            server = next;
        }

        if (answer is null)
        {
            throw new WhoisServerException(IanaServer, $"Nessun server WHOIS trovato per {domain}");
        }

        return WhoisParser.ToRecord(domain, answer, servers);
    }

    private static string BuildQuery(string server, string domain) =>
        QueryTemplates.TryGetValue(server, out var template) ? string.Format(template, domain) : domain;

    private async Task<string> QueryAsync(string server, string query, CancellationToken cancellationToken)
    {
        TimeSpan wait;
        lock (_slotLock)
        {
            var now = DateTimeOffset.UtcNow;
            var slot = _nextSlot.TryGetValue(server, out var reserved) && reserved > now ? reserved : now;
            // Si prenota l'intervallo sotto lock, l'attesa avviene fuori
            _nextSlot[server] = slot + options.MinInterval;
            wait = slot - now;
        }

        if (wait > TimeSpan.Zero)
        {
            await Task.Delay(wait, cancellationToken);
        }
        return await transport.QueryAsync(server, query, cancellationToken);
    }
}

Alcune scelte meritano una spiegazione. La risoluzione condivisa viene avviata da ResolveSharedAsync() con CancellationToken.None: se usasse il token del primo chiamante, l'annullamento di quella singola richiesta farebbe fallire anche tutte le altre in attesa dello stesso dominio. Ogni chiamante attende invece il task condiviso con WaitAsync(), che rispetta il suo token senza annullare il lavoro comune; la scadenza complessiva del trasporto garantisce comunque che la risoluzione non duri all'infinito.

La rimozione dal dizionario avviene nel blocco finally della risoluzione condivisa, quindi anche quando la ricerca fallisce: in caso contrario un task terminato con errore resterebbe nel dizionario e verrebbe restituito a tutte le richieste successive per lo stesso dominio. Per lo stesso motivo il salvataggio in cache avviene lì e non nel codice del singolo chiamante, così il risultato viene memorizzato anche se tutti i chiamanti hanno nel frattempo rinunciato ad attenderlo.

Una Minimal API

Con ASP.NET Core il client si integra nel contenitore di dependency injection come singleton, insieme alla cache in memoria e alle opzioni lette dalla configurazione. L'endpoint traduce le eccezioni nei codici di stato appropriati.

using Microsoft.Extensions.Caching.Memory;
using Whois;

var builder = WebApplication.CreateBuilder(args);

var whoisOptions = builder.Configuration.GetSection("Whois").Get<WhoisOptions>() ?? new WhoisOptions();
builder.Services.AddSingleton(whoisOptions);
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IWhoisTransport, TcpWhoisTransport>();
builder.Services.AddSingleton<WhoisClient>();

var app = builder.Build();

app.MapGet("/whois/{domain}", async (string domain, bool? fresh, bool? raw, WhoisClient client, CancellationToken ct) =>
{
    try
    {
        var record = await client.LookupAsync(domain, fresh ?? false, ct);
        // Il testo originale si include solo su richiesta esplicita
        return Results.Ok(raw == true ? record : record with { Raw = string.Empty });
    }
    catch (InvalidDomainException e)
    {
        return Results.UnprocessableEntity(new { error = e.Message });
    }
    catch (WhoisServerException e)
    {
        return Results.Json(new { error = e.Message, server = e.Server }, statusCode: StatusCodes.Status502BadGateway);
    }
});

app.Run();

L'espressione record with { Raw = string.Empty } crea una copia del record con un solo campo modificato, lasciando intatto l'oggetto conservato in cache. Le opzioni si configurano in appsettings.json:

{
  "Whois": {
    "Timeout": "00:00:08",
    "Deadline": "00:00:25",
    "MaxBytes": 1048576,
    "MinInterval": "00:00:01.500",
    "CacheTtl": "01:00:00"
  }
}
dotnet run
curl http://localhost:5000/whois/example.com
curl 'http://localhost:5000/whois/example.it?raw=true'

Testare con un trasporto finto

Grazie all'interfaccia IWhoisTransport possiamo verificare la logica dei referral senza toccare la rete. Il test seguente, scritto con xUnit, simula la catena IANA, registry e registrar, con quest'ultimo che non risponde.

using Microsoft.Extensions.Caching.Memory;
using Whois;
using Xunit;

public sealed class FakeTransport(Dictionary<string, string?> responses) : IWhoisTransport
{
    public List<string> Calls { get; } = [];

    public Task<string> QueryAsync(string server, string query, CancellationToken cancellationToken)
    {
        Calls.Add(server);
        // Un valore nullo simula un server che non risponde
        return responses.GetValueOrDefault(server) is { } text
            ? Task.FromResult(text)
            : Task.FromException<string>(new WhoisServerException(server, "Timeout simulato"));
    }
}

public class WhoisClientTests
{
    [Fact]
    public async Task KeepsRegistryAnswerWhenRegistrarFails()
    {
        var transport = new FakeTransport(new Dictionary<string, string?>
        {
            ["whois.iana.org"] = "refer: whois.verisign-grs.com\n",
            ["whois.verisign-grs.com"] =
                "Domain Name: EXAMPLE.COM\nRegistrar WHOIS Server: whois.registrar.test\nRegistrar: Example Inc.\n",
            ["whois.registrar.test"] = null,
        });
        var options = new WhoisOptions { MinInterval = TimeSpan.Zero };
        var client = new WhoisClient(transport, new MemoryCache(new MemoryCacheOptions()), options);

        var record = await client.LookupAsync("example.com", fresh: false, CancellationToken.None);

        Assert.Equal("Example Inc.", record.Registrar);
        Assert.Equal(new[] { "whois.iana.org", "whois.verisign-grs.com", "whois.registrar.test" }, transport.Calls);
    }
}

Considerazioni finali

Il client che abbiamo costruito gestisce i casi tipici, ma va collocato nel quadro attuale. Per i domini generici il protocollo di riferimento è RDAP, che usa HTTPS e JSON strutturato e prevede un registro di bootstrap pubblicato da IANA per trovare il server corretto. Con HttpClient e System.Text.Json è semplice aggiungere un'implementazione RDAP e tentarla per prima, ricorrendo a WHOIS per i TLD che non la offrono; l'astrazione introdotta con IWhoisTransport si presta bene a questa evoluzione.

Dopo il GDPR molti dati degli intestatari sono oscurati, e diciture come REDACTED FOR PRIVACY sono la norma, non un errore. Infine, la cache e il limitatore vivono nella memoria del processo: se il servizio gira su più istanze, conviene passare a IDistributedCache con Redis e a un limitatore condiviso, altrimenti ogni istanza rispetterà i limiti di frequenza per conto proprio. Le condizioni d'uso dei server WHOIS vietano generalmente l'interrogazione massiva, e rispettarle è ciò che permette al client di continuare a funzionare.