what a 2019 api client teaches you about modern .net

i’ve been playing with SerpApi lately. nice service. one GET request and you get structured json for google, bing, maps, news, shopping and about a hundred other search surfaces. captchas and layout changes handled for you. their python and ruby stories are strong. their .net story is a time capsule.

the official google-search-results-dotnet package was written around 2019 and it shows. Hashtable parameters. Newtonsoft.Json. a synchronous api that blocks on Task.Result. not a knock on SerpApi, every company has a long tail of sdks. but it makes a great case study. the distance between “working 2019 c#” and “good 2026 c#” is exactly the stuff that bites people in production. so i did two things. sent a set of prs to the official library, and built a modern client from scratch (serpapi-dotnet). here’s what changed and why it matters.

1. sync-over-async is a deadlock waiting for a synchronization context

the original core looks like this:

Task<string> queryTask = createQuery(uri, parameter, jsonEnabled);
queryTask.ConfigureAwait(true);   // does nothing here, by the way
return queryTask.Result;

two problems. first, Task.Result wraps any failure in an AggregateException. callers catch (SerpApiSearchException) and miss. second, blocking on a task that resumes on a captured context deadlocks classic asp.net and ui apps. the ConfigureAwait(true) on a task variable (not an await) is a no-op. a hint that the intent was understood but the mechanics weren’t.

the fix when you must keep a sync api for compatibility: GetAwaiter().GetResult(), which rethrows the original exception, plus ConfigureAwait(false) on every await inside the library. the real fix: expose GetJsonAsync(CancellationToken) and let callers be async end to end. my pr does both without breaking the existing surface.

2. catch (Exception ex) => throw new X(ex.ToString()) destroys the stack

the original wraps every failure like this:

catch (Exception ex)
{
    throw new SerpApiSearchException(ex.ToString());
}

stringifying the exception into a message means no InnerException, no type to catch on, and cancellation gets swallowed into a generic error. the modern pattern: let OperationCanceledException flow untouched, wrap transport errors with the original as InnerException, and carry the http status code on your exception type. callers can then tell a 401 from a 429 without parsing strings.

3. Hashtable to typed request with an escape hatch

Hashtable is pre-generics .net. the interesting design question is what replaces it, because SerpApi has dozens of engines with different parameters. a rigid typed model can’t cover them all. a plain Dictionary<string,string> gives up on discoverability. the answer is both:

var request = new SearchRequest
{
    Engine = SearchEngine.GoogleNews,
    Query = "dotnet 9",
    Location = "Prague, Czechia",
    AdditionalParameters = { ["so"] = "1" },   // anything engine-specific
};

common parameters are typed and documented. everything else passes through. same philosophy on the response side: typed accessors for organic_results and friends, and a raw JsonElement Root for the long tail of answer boxes and knowledge graphs.

4. Newtonsoft to System.Text.Json source generation

dropping Newtonsoft isn’t about fashion. with [JsonSerializable] source generation you get reflection-free deserialization that’s trim-safe and native-aot-compatible. the library ends up with zero external dependencies. for an sdk, every dependency you don’t take is a diamond-dependency conflict your users don’t have.

one real-world wrinkle worth showing: SerpApi’s local_results is sometimes an array and sometimes an object containing a places array, depending on the engine. that’s the kind of thing you only learn by reading actual responses. handle it in the library so your users never see it.

5. new HttpClient() per instance, or bring your own

the 2019 client news up its own HttpClient. in 2026 the library should accept one. that’s what makes it work with IHttpClientFactory, polly resilience pipelines, and unit tests with a fake HttpMessageHandler. ship a di package with services.AddSerpApi(...) and an ISerpApiClient interface, and testing a search feature no longer requires mocking http at all.

the payoff: a web-grounded agent in 30 lines

the reason i care about search apis in .net at all is ai agents. every llm’s knowledge stops at its training cutoff. search grounding fixes that, and everyone does it in python. with a modern client, the c# version is a Semantic Kernel plugin:

public sealed class SerpApiSearchPlugin(ISerpApiClient client)
{
    [KernelFunction("search")]
    [Description("Searches the web and returns the top results.")]
    public async Task<string> SearchAsync(string query, CancellationToken ct = default)
    {
        using var result = await client.SearchAsync(new SearchRequest { Query = query }, ct);
        return string.Join("\n", result.OrganicResults.Select(r => $"{r.Title} - {r.Link}\n{r.Snippet}"));
    }
}

register it, enable automatic function calling, and the model decides when to search. the full sample, a working console agent, is in the repo.

takeaways

if you maintain an api client, the 2026 checklist is short. async-first with CancellationToken. exceptions that preserve their cause. accept an external HttpClient. source-generated System.Text.Json. typed requests with an untyped escape hatch. an interface for testability. none of it is exotic. it’s just the accumulated lessons of a decade of .net moving on.

the prs to the official library and the full serpapi-dotnet source are on my github. if you’re at SerpApi and reading this: i’d love to help you tell this story to .net developers properly.