DEV Community

Cover image for I Was Right Two Months Too Early
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Was Right Two Months Too Early

On 8 July I wrote two hypotheses into my notes. The blog's LinkedIn posts had been dead for two days and I did not know why. First hypothesis: the app's posting permission had been suspended on the provider's side. Second: the 202509 API version pinned in my code had been retired.

I waved the second one away that day. My reasoning was not bad, either — that version was supposed to live until September. I was right. On 15 September that is exactly what happened.

So in July I had formed the correct sentence, just two months early. But that is not really the point. The point is this: while those two hypotheses sat side by side on the same list, I had no test that could tell me which one was true. I spent four days on that.

Six days of silence

On the morning of 6 July a post went to LinkedIn. The next day it did not. I had touched nothing in the code in between.

Here was what I had: every POST to https://api.linkedin.com/rest/posts came back as a 500 with "Unsupported state or unable to authenticate data" in the body. The token in the database was where it belonged — scope w_member_social, expiry 4 September. I tried it with two different articles and both failed at the same point. So this was not a defect specific to one post; it was the whole integration.

The conclusion I drew was this: the code did not change, the token is valid, therefore the problem is on the other side.

There are few sentences in engineering more comforting than that one, and none more dangerous.

According to the records, the silence ran from the morning of 6 July to midday on 12 July. Six days.

Two hypotheses, one observation

Looking back, what bothers me most is the shape of that list.

I had two hypotheses and both predicted exactly the same observation: "posting doesn't work." A list like that gets you nowhere. Eliminating a hypothesis means taking a measurement that makes the difference between them visible; if they all expect the same outcome, there is no measurement to take, only a wall to stare at.

And yet the discriminating test was right there, and free. The difference was not in the outcome but in the timing. Issuing a fresh token, validating it immediately, then waiting five minutes and repeating the same validation would have been enough. If permission had been suspended, the token is invalid on the first try too. Under whatever the real cause turned out to be, the token works at first and dies shortly after. Two hypotheses, two different observations; only a few minutes between them.

Instead I kept redoing the same job: re-authorise, try posting, no luck, re-authorise again. Repeating the same test at different hours is not diagnosis. It is hope.

I listened to the 500 and did not hear the 401

I had a second signal and I treated it as noise.

While posting returned 500, the GET /v2/userinfo and /rest/me endpoints I probed by hand were returning 401 INVALID_ACCESS_TOKEN. I pushed that aside, because in this integration there had been earlier cases where userinfo sulked while posting worked fine; so I reflexively said "that again."

But this time the two came together, and together they said something very clear: the token is sitting fresh in my database and LinkedIn does not recognise it.

Google's SRE book, listing the classic traps of troubleshooting, describes this almost exactly — looking at symptoms that are not relevant or misunderstanding what metrics mean, and latching on to the causes of past problems. I did both at once: I looked at the loud one and filtered out the quiet one through the memory of an older failure.

What is more, the loud one was the code that by definition carries no information. RFC 9110 defines 500 as the server having "encountered an unexpected condition that prevented it from fulfilling the request." In other words, 500 tells you "I don't know." The 401 was telling me something. I read "I don't know" as "the other side is broken."

The bitter part: LinkedIn's own Posts API documentation sends me down a dead end at precisely this point. In its error table, the suggested path for 500 is to retry the request and, if the issue persists, contact support. Neither would have helped me.

The real cause was not on the list

On the morning of 12 July, I stopped eliminating hypotheses and started walking through the provider's developer portal page by page. In the app's settings tab there was one line: the app had not been verified against a LinkedIn Page.

I had the verification done, and before noon that same day real post IDs started coming back.

Let me be honest about the mechanism: LinkedIn does not document it. What I observed was this — a token issued by an unverified app worked the instant it was created and died within a few minutes. My only evidence is my own trial record, so I am writing this as my observation, not as documented provider behaviour.

But that observation explains all six days. Every re-authorisation attempt looked like it was "working," because a token really was being issued; then it quietly died. So each attempt reinforced my wrong hypothesis: "the token is being issued but posting doesn't work, therefore posting permission is gone."

The only thing missing was an approval. The products were attached, the scopes were right, the code was right, the token was right.

And this is also where the gap in my hypothesis list came from. I had asked "what broke?" The answer was "this was never complete." Those two questions do not look in the same place. In an integration that has worked for months, it does not occur to you to go looking for a setup step that was left unfinished — because it was working. The moment the provider starts enforcing that gap a little more strictly, a door left open months ago becomes today's outage.

Isolation was the right call, and it delayed the diagnosis

That same week I had done one thing right. I separated the social posting queue so a dead platform could not freeze the whole line; X, Bluesky, DEV and burncpu kept flowing. According to the records, posts kept going out to the other four platforms throughout those six days. Only LinkedIn went quiet.

As engineering, I am still happy with that decision. But it had a price: urgency dropped. LinkedIn's silence stopped being an "outage" and became an "open item," and open items let you live with a wrong hypothesis for four more days.

I am not telling this as a fault, because the alternative is worse. But it is worth asking: who is still chasing the failure you isolated? For a while my answer was "nobody." I catalogued the breaking points of this automation in bulk when I counted the 67 repairs I made to what the agent wrote; which individual diagnostic mistakes that count was made of is something I am looking at here for the first time.

15 September: the zebra really did show up

Two months later, the same symptom. This time the answer was something else entirely: the request came back with 426 and the error code was NONEXISTENT_VERSION — the message said the version I asked for was no longer active. LinkedIn's error documentation already documents this response as an example:

{
  "status": 426,
  "code": "NONEXISTENT_VERSION",
  "message": "Requested version yyyymmdd is not active"
}
Enter fullscreen mode Exit fullscreen mode

The hypothesis I had pushed away in July was knocking at the door in September.

And this time the provider had kept its word. LinkedIn's versioning documentation states that versions are released monthly and are supported for a minimum of one year before sunset. The version in my code was 202509, sent on every request in the Linkedin-Version header, published in September 2025. According to the official migration table its sunset date was 15 September 2026. The last successful post in my records is the afternoon of 15 September; I noticed the situation on the evening of the 16th.

So the calendar worked exactly as promised. I was the one not reading it — and that calendar had been published a year in advance.

The repair was a single line:

const LINKEDIN_API_VERSION = '202609'; // YYYYMM — 202509 sunset 15 Sep 2026 (426 NONEXISTENT_VERSION); 202609 sunset 15 Sep 2027
Enter fullscreen mode Exit fullscreen mode

Both the Turkish and English posts went out again that evening; the time from seeing the error to shipping the fix was measured in minutes. Because the error code had told me its own name.

Two details endeared this case to me. First: the header is six digits (202509), but the error message echoes the version back converted to eight. The second is more entertaining. Under RFC 9110, 426 means the server refuses to perform the request using the current protocol, and the standard requires the server to send an Upgrade header field naming the required protocol. An API version is not a protocol. The provider is using the code outside the meaning the standard gives it.

I am not annoyed, because there is no suitable code available either: 400 is too broad and 410 means "permanently gone." But it says this much: what carried the information here was not the status code, it was the NONEXISTENT_VERSION string next to it.

The sentence I underlined most heavily in that documentation is this: the provider expects every call to state its version explicitly, and the latest version is not applied by default. There is no quiet "we'll use the newest one for you" behaviour. There is also fine print on the "minimum one year" guarantee — the same document reserves the right to release a patch version even inside the support window for critical security and privacy issues or bug fixes. A year is solid ground for planning; it is not a guarantee.

The third cause, the most boring one

There is also 10 September in between. The same symptom, an entirely different cause: the token had expired.

In this case the answer did not even come from LinkedIn; it came from the door of our own code. If the token has expired the request never goes out at all, and the error message says outright that the token has expired and admin re-authorisation is required. Diagnosis time: zero.

I owe a correction here too. In this integration the token is renewed by hand roughly every 60 days, and for a long time I described that as having no cure. The accurate version: the LinkedIn OpenID flow we use does not issue a refresh token by default, but programmatic refresh can be requested separately — that path is already written in our code and is used automatically if a refresh token arrives. So there is a cure; I have not asked for it. What I have is not a permanent fix. It is a calendar.

These are not the only sources of the "posts aren't going out" symptom, either. That same summer I fixed two more bugs where the slug of English articles returned a 404 to LinkedIn. I am not counting them here because the cause was me and the diagnosis was easy — but they illustrate nicely why looking at the symptom and saying "LinkedIn again" is a bad habit.

Putting the three cases side by side finally gave me something useful: a list of discriminating signals.

Diagram

The date belongs in a calendar, not in your head

The one sentence I take from these three cases is this: reading release notes gets postponed because it is filed under "maintenance," when the work it actually does is diagnosis.

Here is where I see the difference. Postpone a maintenance task and the status quo holds. Not reading release notes does something else — it turns you into an engineer whose hypothesis list is incomplete when the failure arrives. I lost those four days in July not because I lacked the knowledge of when versions get retired, but because I had not recorded that knowledge anywhere as a date. In my head there was an impression: "good until September."

So what I did after this run was not to draw a lesson but to write down a date. LinkedIn's migration table gives the sunset day of every version a year in advance: for the 202609 I now use, that is 15 September 2027. The date sits in a code comment — but that is not where it belongs. It belongs in a calendar. A comment line wakes nobody up.

In integrations like this the real technical debt is not the code. The code works; it simply has an expiry date, and that date is nowhere to be found in your repository.

Three failures, three different ways of answering: the provider's 500 that says nothing, its 426 that says its own name, and my own code turning the request away at the door. The outage durations follow the same order — six days, thirty hours, thirteen hours. How fast a system gets repaired is very nearly the same thing as how plainly it speaks to you.

Black boxes spend their worst nights without telling anyone. But once in a while one comes along, knocks on your door, and names its trouble out loud. That is the night not to waste.

Official Sources

Top comments (0)