<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dinuka Shavinda</title>
    <description>The latest articles on DEV Community by Dinuka Shavinda (@dinukashavi).</description>
    <link>https://dev.to/dinukashavi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4126995%2F4c39eeee-d0a0-412b-a18f-7374debc4277.png</url>
      <title>DEV Community: Dinuka Shavinda</title>
      <link>https://dev.to/dinukashavi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dinukashavi"/>
    <language>en</language>
    <item>
      <title>I Rate-Limited an API by Hand. Then I Configured One in a Form Field.</title>
      <dc:creator>Dinuka Shavinda</dc:creator>
      <pubDate>Sun, 20 Sep 2026 07:21:26 +0000</pubDate>
      <link>https://dev.to/dinukashavi/i-rate-limited-an-api-by-hand-then-i-configured-one-in-a-form-field-365d</link>
      <guid>https://dev.to/dinukashavi/i-rate-limited-an-api-by-hand-then-i-configured-one-in-a-form-field-365d</guid>
      <description>&lt;p&gt;My hotel booking backend has an GET endpoint that has no rate limiting at all, nothing stops a client from calling it as fast as it can respond. When I needed to expose the backend publicly for a separate piece of testing, the only safe option I had was to switch that endpoint off entirely rather than risk it being hit repeatedly by anyone who found the URL.&lt;/p&gt;

&lt;p&gt;So when I published a different, read-only endpoint through &lt;a href="https://wso2.com/bijira/" rel="noopener noreferrer"&gt;Bijira&lt;/a&gt;, WSO2's API management platform, I wanted to see what a managed rate limit actually does under real traffic, not as an upgrade over something I'd built, but as a look at protection I currently don't have at all.&lt;/p&gt;

&lt;p&gt;This isn't a tutorial. It's what I found when I pushed a live API past its configured limit, read the response headers closely, and had to correct my own first conclusion twice along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I took a real endpoint from the same backend, &lt;code&gt;GET /api/hotels&lt;/code&gt;, and published it through Bijira as a managed API proxy. The backend stayed exactly where it already was, on my own machine, reached through a tunnel. Bijira's cloud gateway sat in front of it, handling routing, security, and, for this test, the rate limit.&lt;/p&gt;

&lt;p&gt;I set the limit to 5 requests per 60 seconds through Bijira's console. No code, no middleware, just a numeric field and a time-unit dropdown.&lt;/p&gt;

&lt;p&gt;Worth flagging up front: this was Bijira's sandbox environment, a &lt;code&gt;-dev&lt;/code&gt; host and a sandbox-scoped key. Production deployments could behave differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first attempt taught me the wrong lesson
&lt;/h2&gt;

&lt;p&gt;I sent 10 requests roughly a second apart, waiting for each response before sending the next. Two of those requests hung for 60 seconds each on an unexplained gateway timeout. That stretched my burst across three separate one-minute windows, and nothing ever got rejected.&lt;/p&gt;

&lt;p&gt;My first instinct was to blame the spacing, that one request a second apart was too slow to "exercise" the limit. That wasn't it. Ten requests a second apart take about ten seconds, well past a limit of 5, and would still be rejected after the fifth, as long as all ten land in the same clock minute. Start that burst in the last few seconds of a minute, though, and some requests fall into the next one, where the count has already reset. The real problem in my first run wasn't the spacing itself, it was that each request waited for the previous response before sending. When two requests hung for 60 seconds, everything queued behind them got pushed into later minutes, each of which had room again.&lt;/p&gt;

&lt;p&gt;The fix wasn't sending requests faster. It was firing on a fixed schedule regardless of how long earlier responses took.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corrected run
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Forebhbx0n3a7u22ksp5u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Forebhbx0n3a7u22ksp5u.png" alt=" " width="796" height="53"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69hfig9nkmwwf3qe18rd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69hfig9nkmwwf3qe18rd.png" alt=" " width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iweiuid44k5ssmfq9i9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iweiuid44k5ssmfq9i9.png" alt=" " width="796" height="53"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3v9e5diirwl4j60a0ec6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3v9e5diirwl4j60a0ec6.png" alt=" " width="800" height="122"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I sent all 10 requests on a fixed 200ms schedule, not waiting for any response before firing the next, starting 2 seconds after a new minute began, close enough to the start of the window that the whole burst landed inside one minute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;req    sent (UTC)                status  took    remaining
1      12:14:02.010Z             200     2171ms  4
2      12:14:02.226Z             200     1954ms  3
3      12:14:02.412Z             200     1762ms  2
4      12:14:02.615Z             200     1623ms  1
5      12:14:02.817Z             200     2055ms  0
6      12:14:03.019Z             429     1020ms  0
7      12:14:03.222Z             429      994ms  0
8      12:14:03.410Z             429      964ms  0
9      12:14:03.613Z             429      995ms  0
10     12:14:03.816Z             429     1035ms  0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exactly 5 requests through, exactly 5 rejected, cut off precisely at the configured limit. Every 429 carried the same body: &lt;code&gt;"Allowed request limit for the API/Resource, exceeded"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually proves the reset behavior
&lt;/h2&gt;

&lt;p&gt;I initially thought a follow-up request, sent well after the burst, proved the limit resets on the clock minute rather than counting 60 seconds from the first request. It doesn't. That follow-up landed 59.993 seconds after request 1, just 7 milliseconds short of a full 60-second window, well within the timing variation already visible across the rest of the log. It couldn't distinguish a rolling window from a clock-aligned one.&lt;/p&gt;

&lt;p&gt;The actual evidence is in the &lt;code&gt;x-ratelimit-reset&lt;/code&gt; header. At &lt;code&gt;12:14:02&lt;/code&gt;, the header already read &lt;code&gt;57&lt;/code&gt;, counting down to the top of the minute, not to 60 seconds after my first request. Earlier, in the flawed first run, a new count opened at &lt;code&gt;12:03:10&lt;/code&gt; and returned &lt;code&gt;reset=49&lt;/code&gt;, pointing to &lt;code&gt;12:03:59&lt;/code&gt;. A rolling 60-second window would have read &lt;code&gt;60&lt;/code&gt;. Both data points agree: the count is tied to the clock minute, not to whenever the first request happened to land.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving where the rejection actually happens
&lt;/h2&gt;

&lt;p&gt;Response timing alone can't prove where a request was stopped, so I needed a measurement that doesn't depend on timing. My backend sits behind a Cloudflare tunnel, and the tunnel exposes its own request counter, a number that only increases when a request actually reaches my machine. If Bijira rejects a request at the gateway, the counter doesn't move. If the request reaches my backend and gets rejected there instead, the counter moves anyway.&lt;/p&gt;

&lt;p&gt;I read the counter before a burst and again once every response had returned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before                            24
After (all responses returned)    28
After (+10s, stable)              28
Change                            +4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A later burst, at 14:33, produced this: 5 requests allowed, 5 rejected, same shape as before, except this run also hit one unexplained upstream timeout (504) on request 3, a failure mode that showed up intermittently throughout this work and that I never fully root-caused. That timeout still consumed one of the five allowed slots, the rate limiter counted it as a used request even though it never completed. Of the 5 requests the limiter allowed through, 4 succeeded and reached my backend; the remaining one, request 3, timed out before it reached the tunnel connector on my machine. The counter rose by exactly 4. Other traffic could only have pushed that number up, never down, so a result of exactly 4 leaves no room for the rejected requests to have reached my backend.&lt;/p&gt;

&lt;p&gt;The 5 requests Bijira rejected with a 429 added nothing to that count. My backend is only reachable through the tunnel, so if those requests had reached it, the counter would have risen by 9, not 4. It didn't. Bijira rejected them at the gateway; they never reached my tunnel.&lt;/p&gt;

&lt;p&gt;That's the actual answer to the question I started with. My AI search endpoint, sitting unprotected on the same backend, would let every one of those 10 requests straight through to my code, and, once that endpoint is configured with a live API key, straight through to a paid model behind it. A request Bijira rejects never gets that far. It never arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The form field doesn't warn you when it stops working
&lt;/h2&gt;

&lt;p&gt;One thing this project taught me that the clean numbers above don't show: after I changed the proxy's backend URL partway through this work, the rate limit silently stopped applying. A full burst of 10 requests came back 10 × 200, with none of the &lt;code&gt;x-ratelimit-*&lt;/code&gt; headers present, a check a few minutes later still showed none. The limit only came back after I went into Bijira and re-applied the policy to the live deployment. Nothing warned me it had dropped off. The only reason I noticed is that I'd gotten in the habit of checking for the rate-limit headers on a single request before trusting a full burst test, a habit this same project had already taught me the hard way, once with a database connection and once with a stale tunnel URL.&lt;/p&gt;

&lt;p&gt;Configuring the limit took a form field. Losing it took nothing at all, no error, no notification, just a deployment that quietly stopped enforcing what I'd set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still genuinely open
&lt;/h2&gt;

&lt;p&gt;I don't know whether the 5-per-minute limit is shared across every consumer of this API or scoped to my individual key. Bijira's error message says "limit for the API/Resource," which is ambiguous on exactly this point. If that limit is shared across every consumer, it protects my bill but not my users: one client could exhaust the quota and lock everyone else out of the feature. That distinction is worth confirming before relying on this in anything beyond a test.&lt;/p&gt;

&lt;p&gt;The unexplained 504s are the other loose end. They showed up intermittently across this whole project, never predictably enough to isolate a cause, and this run's timeout is the first time I could confirm one failed before it reached the tunnel connector on my machine, not inside my own code. I'm noting it rather than explaining it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually settles
&lt;/h2&gt;

&lt;p&gt;Bijira's limit enforced exactly what I configured, its reset is tied to the clock minute rather than a rolling window, setting it up took a form field instead of writing and testing rejection logic myself, and, now confirmed rather than inferred, requests it rejects never reach my backend at all.&lt;/p&gt;

&lt;p&gt;That last point is the one that matters most given where I started. My AI search endpoint has no protection of its own, so there is no ceiling on how many paid model calls a single client can trigger. A gateway limit wouldn't make unwanted traffic free. Whatever gets under the limit still reaches the model. But it turns "unbounded" into a number I chose, and everything over that number is turned away before my server, or my model bill, ever sees it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>security</category>
    </item>
    <item>
      <title>What I Actually Learned Adding Asgardeo Login to a Side Project</title>
      <dc:creator>Dinuka Shavinda</dc:creator>
      <pubDate>Tue, 15 Sep 2026 23:27:29 +0000</pubDate>
      <link>https://dev.to/dinukashavi/what-i-actually-learned-adding-asgardeo-login-to-a-side-project-26cc</link>
      <guid>https://dev.to/dinukashavi/what-i-actually-learned-adding-asgardeo-login-to-a-side-project-26cc</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsy2dgagcjs3o1za1zr8r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsy2dgagcjs3o1za1zr8r.png" alt=" " width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I recently added Asgardeo (WSO2's identity platform) to a hotel booking app I've been building. My main reason was to see how a managed identity provider compares to the JWT/OAuth authentication I'd built by hand. I expected to write a short "here's how you add login" post. Instead I found a scope-release gotcha worth understanding, a dependency bug worth filing, and one wrong conclusion I had to walk back before publishing this.&lt;/p&gt;

&lt;p&gt;The setup&lt;/p&gt;

&lt;p&gt;The app already has its own auth: a JWT held in Redux, attached to API calls via RTK Query, with role-based route protection. I added Asgardeo as a second, parallel system, not a replacement, using &lt;a class="mentioned-user" href="https://dev.to/asgardeo"&gt;@asgardeo&lt;/a&gt;/react, wired into a protected /asgardeo route that shows the signed-in user's ID token claims. The two systems share no state; it was a deliberately additive integration, so I could compare "what I built myself" against "what a managed identity platform hands you for free" without touching what already worked.&lt;/p&gt;

&lt;p&gt;Getting sign-in and sign-out working took about twenty minutes. Understanding what happened next took a lot longer, and taught me more.&lt;/p&gt;

&lt;p&gt;Claim release isn't one setting. It's three.&lt;/p&gt;

&lt;p&gt;The first surprise: I requested the profile scope and got back an ID token with username and sub, but email, given_name, and family_name all showed up empty. My first instinct was that I'd misconfigured something. The real answer was more interesting: a claim reaching an ID token has to clear three independent gates, and an empty field looks identical no matter which gate it failed.&lt;/p&gt;

&lt;p&gt;Gate 1: the value has to exist. I'd self-registered with just an email and password, so there was no first or last name stored on the account at all. No amount of configuration releases a value that was never set.&lt;/p&gt;

&lt;p&gt;Gate 2: the attribute has to be released to the application. Under the app's settings, each attribute needs to be explicitly marked as requested. This is a separate switch from the OAuth scope. Enabling one does nothing for the other.&lt;/p&gt;

&lt;p&gt;Gate 3: the owning scope has to be requested. Claims are grouped under scopes, and the grouping isn't always where you'd guess. I checked the tenant's discovery document directly and found email listed as its own scope, entirely separate from profile. So given_name and family_name depend only on gates 1 and 2 (they live under profile, which I was already requesting). email would stay empty no matter what I did to the other two gates, because I'd never requested its scope.&lt;/p&gt;

&lt;p&gt;To actually prove this rather than just assert it, I ran a controlled test: changed exactly one variable (set a first name, then released given_name under the app's attribute settings) while leaving everything else fixed, then re-authenticated.&lt;/p&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "sub": "a8d1df04-3147-450f-9ebd-c3d1fccda043",&lt;br&gt;
  "username": "&lt;a href="mailto:examplemail@gmail.com"&gt;examplemail@gmail.com&lt;/a&gt;"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;After (only given_name changed on either end):&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{&lt;br&gt;
  "given_name": "Dinuka",&lt;br&gt;
  "sub": "a8d1df04-3147-450f-9ebd-c3d1fccda043",&lt;br&gt;
  "username": "&lt;a href="mailto:examplemail@gmail.com"&gt;examplemail@gmail.com&lt;/a&gt;"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;email and family_name stayed empty, exactly as the three-gate model predicted. Changing every gate at once would have told me nothing; I'd have had three plausible explanations for one outcome. Isolating one variable is what turned a guess into a verified conclusion.&lt;/p&gt;

&lt;p&gt;The bug I found, and the conclusion I had to retract&lt;/p&gt;

&lt;p&gt;While digging into the SDK's dependency tree, I noticed &lt;a class="mentioned-user" href="https://dev.to/asgardeo"&gt;@asgardeo&lt;/a&gt;/react pins react-dom as a hard dependency at an exact version (19.x), while react is a much looser peer dependency (&amp;gt;=16.8.0). On a React 18 project, that's a contradiction npm can't resolve cleanly. It installs a second, mismatched react-dom alongside the app's own, and flags the whole tree as invalid.&lt;/p&gt;

&lt;p&gt;At first, I assumed this was the cause of a blank white screen I was seeing in the app. I built a workaround (an npm overrides entry plus a Vite dedupe config), the blank screen persisted, and I wrote up the dependency conflict as the culprit anyway. In hindsight, that persistence should have been the first red flag. A fix that doesn't fix the symptom is evidence the diagnosis is wrong, not a reason to keep the diagnosis and add more workaround.&lt;/p&gt;

&lt;p&gt;The actual cause, once I read the real browser console error instead of guessing, was a completely unrelated pre-existing bug: a component rendering a raw API error object directly as JSX, which crashes React and unmounts the whole page. Nothing to do with Asgardeo at all.&lt;/p&gt;

&lt;p&gt;To find out whether the dependency conflict was a real problem on its own merits, I built a clean, minimal reproduction: no workarounds, no other app code, just the SDK in a fresh Vite + React 18 project. It rendered fine, in both dev and production builds, with no console errors. The SDK's actual code never imports the mismatched react-dom copy, so the broken renderer just sits there unused, adding about 4KB of dead code to the bundle rather than causing any runtime failure.&lt;/p&gt;

&lt;p&gt;So the bug is real, but smaller than I first claimed: a dependency-declaration defect and a bundle-size cost, not a crash. I filed it upstream with that corrected scope, asgardeo/javascript#571, including the clean reproduction, the exact npm output showing the invalid tree, and a suggested fix (moving react-dom to peerDependencies, matching how react is already handled).&lt;/p&gt;

&lt;p&gt;What actually mattered here&lt;/p&gt;

&lt;p&gt;Neither of these findings was hard to produce once I stopped accepting the first plausible explanation. The claim-release model came from checking a real discovery document instead of guessing at scope groupings. The corrected bug report came from testing my own fix in isolation instead of trusting that a workaround I'd written must have worked. In both cases, the useful output wasn't the code. It was going back and checking whether what I believed actually matched what I could observe.&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>react</category>
      <category>security</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
