{"id":126,"date":"2026-07-21T13:13:34","date_gmt":"2026-07-21T13:13:34","guid":{"rendered":"https:\/\/www.ivanjurina.com\/?p=126"},"modified":"2026-07-21T13:13:35","modified_gmt":"2026-07-21T13:13:35","slug":"what-a-2019-api-client-teaches-you-about-modern-net","status":"publish","type":"post","link":"https:\/\/www.ivanjurina.com\/index.php\/2026\/07\/21\/what-a-2019-api-client-teaches-you-about-modern-net\/","title":{"rendered":"what a 2019 api client teaches you about modern .net"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">i&#8217;ve been playing with <a href=\"https:\/\/serpapi.com\/\">SerpApi<\/a> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">the official <a href=\"https:\/\/github.com\/serpapi\/google-search-results-dotnet\"><code>google-search-results-dotnet<\/code><\/a> package was written around 2019 and it shows. <code>Hashtable<\/code> parameters. Newtonsoft.Json. a synchronous api that blocks on <code>Task.Result<\/code>. not a knock on SerpApi, every company has a long tail of sdks. but it makes a great case study. the distance between &#8220;working 2019 c#&#8221; and &#8220;good 2026 c#&#8221; 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 (<a href=\"https:\/\/github.com\/ivanjurina\/serpapi-dotnet\">serpapi-dotnet<\/a>). here&#8217;s what changed and why it matters.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. sync-over-async is a deadlock waiting for a synchronization context<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">the original core looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Task&lt;string&gt; queryTask = createQuery(uri, parameter, jsonEnabled);\nqueryTask.ConfigureAwait(true);   \/\/ does nothing here, by the way\nreturn queryTask.Result;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">two problems. first, <code>Task.Result<\/code> wraps any failure in an <code>AggregateException<\/code>. callers <code>catch (SerpApiSearchException)<\/code> and miss. second, blocking on a task that resumes on a captured context deadlocks classic asp.net and ui apps. the <code>ConfigureAwait(true)<\/code> on a task variable (not an await) is a no-op. a hint that the intent was understood but the mechanics weren&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">the fix when you must keep a sync api for compatibility: <code>GetAwaiter().GetResult()<\/code>, which rethrows the original exception, plus <code>ConfigureAwait(false)<\/code> on every await inside the library. the real fix: expose <code>GetJsonAsync(CancellationToken)<\/code> and let callers be async end to end. my pr does both without breaking the existing surface.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. <code>catch (Exception ex) =&gt; throw new X(ex.ToString())<\/code> destroys the stack<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">the original wraps every failure like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>catch (Exception ex)\n{\n    throw new SerpApiSearchException(ex.ToString());\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">stringifying the exception into a message means no <code>InnerException<\/code>, no type to catch on, and cancellation gets swallowed into a generic error. the modern pattern: let <code>OperationCanceledException<\/code> flow untouched, wrap transport errors with the original as <code>InnerException<\/code>, and carry the http status code on your exception type. callers can then tell a 401 from a 429 without parsing strings.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. <code>Hashtable<\/code> to typed request with an escape hatch<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Hashtable<\/code> 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&#8217;t cover them all. a plain <code>Dictionary&lt;string,string&gt;<\/code> gives up on discoverability. the answer is both:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var request = new SearchRequest\n{\n    Engine = SearchEngine.GoogleNews,\n    Query = \"dotnet 9\",\n    Location = \"Prague, Czechia\",\n    AdditionalParameters = { &#91;\"so\"] = \"1\" },   \/\/ anything engine-specific\n};\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">common parameters are typed and documented. everything else passes through. same philosophy on the response side: typed accessors for <code>organic_results<\/code> and friends, and a raw <code>JsonElement Root<\/code> for the long tail of answer boxes and knowledge graphs.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Newtonsoft to System.Text.Json source generation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">dropping Newtonsoft isn&#8217;t about fashion. with <code>[JsonSerializable]<\/code> source generation you get reflection-free deserialization that&#8217;s trim-safe and native-aot-compatible. the library ends up with zero external dependencies. for an sdk, every dependency you don&#8217;t take is a diamond-dependency conflict your users don&#8217;t have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">one real-world wrinkle worth showing: SerpApi&#8217;s <code>local_results<\/code> is sometimes an array and sometimes an object containing a <code>places<\/code> array, depending on the engine. that&#8217;s the kind of thing you only learn by reading actual responses. handle it in the library so your users never see it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. <code>new HttpClient()<\/code> per instance, or bring your own<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">the 2019 client news up its own <code>HttpClient<\/code>. in 2026 the library should accept one. that&#8217;s what makes it work with <code>IHttpClientFactory<\/code>, polly resilience pipelines, and unit tests with a fake <code>HttpMessageHandler<\/code>. ship a di package with <code>services.AddSerpApi(...)<\/code> and an <code>ISerpApiClient<\/code> interface, and testing a search feature no longer requires mocking http at all.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">the payoff: a web-grounded agent in 30 lines<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">the reason i care about search apis in .net at all is ai agents. every llm&#8217;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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>public sealed class SerpApiSearchPlugin(ISerpApiClient client)\n{\n    &#91;KernelFunction(\"search\")]\n    &#91;Description(\"Searches the web and returns the top results.\")]\n    public async Task&lt;string&gt; SearchAsync(string query, CancellationToken ct = default)\n    {\n        using var result = await client.SearchAsync(new SearchRequest { Query = query }, ct);\n        return string.Join(\"\\n\", result.OrganicResults.Select(r =&gt; $\"{r.Title} - {r.Link}\\n{r.Snippet}\"));\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">register it, enable automatic function calling, and the model decides when to search. the full sample, a working console agent, is in the repo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">takeaways<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">if you maintain an api client, the 2026 checklist is short. async-first with <code>CancellationToken<\/code>. exceptions that preserve their cause. accept an external <code>HttpClient<\/code>. source-generated <code>System.Text.Json<\/code>. typed requests with an untyped escape hatch. an interface for testability. none of it is exotic. it&#8217;s just the accumulated lessons of a decade of .net moving on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>the prs to the official library and the full serpapi-dotnet source are on my github. if you&#8217;re at SerpApi and reading this: i&#8217;d love to help you tell this story to .net developers properly.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>i&#8217;ve been playing with SerpApi lately. nice service. one GET request and you get structured json for google, bing, maps, [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-126","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/posts\/126","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/comments?post=126"}],"version-history":[{"count":1,"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/posts\/126\/revisions"}],"predecessor-version":[{"id":127,"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/posts\/126\/revisions\/127"}],"wp:attachment":[{"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/media?parent=126"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/categories?post=126"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.ivanjurina.com\/index.php\/wp-json\/wp\/v2\/tags?post=126"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}