Skip to content

fix: tolerate unknown polymorphic types and skip undecodable updates in getUpdates - #300

Closed
ingvarch wants to merge 3 commits into
go-telegram:mainfrom
ingvarch:fix/unknown-discriminator-stall
Closed

ingvarch wants to merge 3 commits into
go-telegram:mainfrom
ingvarch:fix/unknown-discriminator-stall

Conversation

@ingvarch

@ingvarch ingvarch commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Any new discriminator value from a Bot API release stalls long polling permanently.

Thirteen polymorphic models (ChatMember, ReactionType, ChatBoostSource, OwnedGift,
MenuButton, MessageOrigin, StoryAreaType, TransactionPartner, RevenueWithdrawalState,
BackgroundType, BackgroundFill, RichBlock, RichText, PaidMedia) returned
unsupported <Type> type from UnmarshalJSON when the type / status / source value
was not in their switch.

getUpdates decoded the whole batch with one json.Unmarshal into []*models.Update, so a single update carrying an unknown value failed the entire call. lastUpdateID was never advanced, the next request used the same offset, received the same batch and failed again. The loop backed off to 5s and retried forever.

Impact

This is a guaranteed, silent, unrecoverable outage of every bot built on this library, triggered by an external event that is certain to happen.

  • It will happen. The trigger is a routine Bot API release, not an edge case. Telegram adds new status / type values in almost every version (recently: paid reaction, unique gift, telegram_api transaction partner, new rich blocks). Thirteen models are thirteen places for it to fire. All users break on the same day, with no change on their side.
  • Total stop, not degradation. Not one lost update: the whole batch is rejected, the offset never moves, and the same batch is re-requested forever. The bot handles nothing from that moment on: no messages, no commands, no payments.
  • Silent. The process stays up, health checks pass, getMe answers. The only trace is one error line every 5 seconds that reads like network noise. The bot looks alive and does nothing.
  • No workaround on the user side. WithAllowedUpdates does not help: ChatMember is nested in Message (new_chat_members, chat_member), ReactionType in message_reaction, MessageOrigin in every forward. The only remedy is to wait for a library release that knows the new value, then redeploy.

Fix

Two layers, each a root cause on its own:

  1. models/: an unknown discriminator is no longer an error. The wrapper keeps the raw value in its Type (Source for ChatBoostSource), leaves every variant pointer nil and returns nil. Consumers switching on Type fall into their default branch instead of never seeing the update.
  2. get_updates.go: the batch is decoded as []json.RawMessage and each update on its own (decodeUpdate). An update that still fails to decode is reported through the errors handler with its update_id, the offset moves past it and the rest of the batch is delivered. This also covers any future decode failure that is not a discriminator.

How to reproduce on main

Point the bot at a fake server whose getUpdates returns one update with "new_chat_member":{"status":"future_status"} next to a normal message update. Observe: the default handler is never called, every request carries offset=1, and the errors handler repeats error get updates, error decode response result for method getUpdates, unsupported ChatMember type. The new Test_getUpdates_skipsUndecodableUpdate fails on main with lastUpdateID=0.

Behaviour change

  • Decoding {"type":"<unknown>"} into any of the models above now succeeds with
    Type == "<unknown>" and nil variants. TestRichBlock_UnknownType and
    TestRichText_UnknownType asserted the old error and are replaced by the table test.
  • MarshalJSON of such a value still returns an error: there is no payload to encode.
  • The webhook path gets the same tolerance through the models; it needed no code change.

Tests

  • models/unknown_type_test.go: table test over all 14 wrappers, unknown value decodes
    without error, discriminator preserved, every variant pointer nil (checked via reflect).
  • get_updates_test.go: a batch of 100, 101 (undecodable), 102 delivers 100 and
    102, moves lastUpdateID to 102 and reports one error mentioning 101.
  • go test -race ./..., go vet, gofmt, golangci-lint clean.

…in getUpdates

A new ChatMember status, ReactionType, PaidMedia type etc. from a Bot API release failed UnmarshalJSON, and getUpdates decoded the batch atomically, so the offset never advanced and polling stalled forever. Models now keep the unknown discriminator with nil variants; getUpdates decodes each update on its own, reports the failure with the update id and moves past it.
@codecov-commenter

codecov-commenter commented Sep 3, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 54.84%. Comparing base (3d38d39) to head (359b3cd).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #300      +/-   ##
==========================================
+ Coverage   52.02%   54.84%   +2.81%     
==========================================
  Files          34       34              
  Lines        2935     2881      -54     
==========================================
+ Hits         1527     1580      +53     
+ Misses       1363     1252     -111     
- Partials       45       49       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@negasus negasus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a real problem and the approach (per-update decode + tolerant discriminators) is the right one. Tests, go vet and -race pass on the branch. Two things block the merge, the rest is minor.

Blocking

1. MarshalJSON still rejects what UnmarshalJSON now accepts.
All paired marshalers (ChatMember, ReactionType, MessageOrigin, ChatBoostSource, MenuButton, BackgroundType, BackgroundFill, RichBlock, RichText) still return unsupported X type. Verified on the branch: an update with forward_origin.type = "future" or new_reaction[].type = "future" decodes fine, then json.Marshal(update) fails with

json: error calling MarshalJSON for type *models.MessageOrigin: unsupported MessageOrigin type

Anyone who logs, persists or queues updates via JSON, or echoes MessageReaction.NewReaction back into SetMessageReaction, breaks on the day Telegram ships a new variant. That is the same failure class this PR is fixing, moved one step later. Please marshal {"type":"<Type>"} (status / source where applicable) for the unknown case instead of returning an error.

Related: ReactionType.MarshalJSON has no paid case, so {"type":"paid"} already fails to round-trip today.

2. Tight loop without backoff when update_id cannot be read.
In get_updates.go, if decodeUpdate fails and the head parse leaves ID == 0, the offset does not move and continue skips the backoff (timeoutAfterError was just reset to 0). A batch containing a single element like {"update_id":"abc","message":"x"} produced ~4000 requests and errors-handler calls in 50 ms in a local test. Before this PR the same batch went through the errRequest path with 100ms..5s backoff. On ID == 0 please set timeoutAfterError = incErrTimeout(timeoutAfterError) (or route to the error path) instead of continue.

Should fix

3. RichText with an empty or missing type silently becomes an empty plain string.
Type == "" is RichText's plain-string form, so {"text":"hi"} now decodes with err == nil, Type == "", and marshals as "". Previously it returned unsupported RichText type "". A guard if v.Type == "" { return error } before the switch keeps the tolerance for real unknown types without this aliasing. The other 13 models have no "" alias and are fine.

4. CHANGELOG.md. Please add an entry. The v1.24 line "reports an unknown Type as unsupported" now describes only the marshal side and needs rewording.

5. Skip path logs only id + error. webhook_handler.go also logs the raw body on decode failure; doing the same here makes the failing payload diagnosable in production.

Nits

  • Type assignment could be hoisted before the switch, as paid.go already does. That removes the per-arm x.Type = Const lines in 13 methods and makes all 14 decoders read the same.
  • In the new tests please name error variables descriptively (unmarshalErr, handlerErr) rather than err.
  • clientFunc / jsonResponse in package bot have fairly generic names; consider prefixing them to avoid collisions with future tests.

What looks good

  • Decoding the batch as []json.RawMessage and each update on its own also covers future non-discriminator failures.
  • The error reaches the errors handler with the update_id and wraps with %w.
  • The table test with the reflect check that every variant pointer stays nil is a nice guard.

ingvarch and others added 2 commits September 7, 2026 09:09
…ate_id

Addresses the review on go-telegram#300.

MarshalJSON rejected what UnmarshalJSON had just accepted: the nine paired
marshalers still returned "unsupported X type", so an update carrying a variant
added by a Bot API release decoded fine and then failed as soon as it was logged,
persisted, queued as JSON or echoed back into a request. That is the same failure
class the PR fixes, moved one step later. An unknown discriminator is now encoded
as the bare {"type":"<Type>"} ("status" / "source" where applicable) through a
shared marshalUnknownVariant. An empty discriminator is an unset value rather than
a variant from a future release and stays an error.

ReactionType.MarshalJSON had no case for "paid", so a paid reaction has never
round-tripped; added.

In getUpdates, an update whose update_id could not be read left the offset in
place and skipped the backoff, so the same batch was re-requested in a tight loop.
That path now increments timeoutAfterError as a failed request does. The skip log
carries the raw payload, as webhook_handler.go already does on a decode failure.

RichText treats an empty Type as its plain-string form, so tolerating a missing
"type" aliased a tagged object to it: {"text":"hi"} decoded without error and
marshalled back as "". A tagged object without a "type" is an error again. The
other thirteen models have no such alias and stay tolerant.

The Type assignment is hoisted above the switch in the thirteen remaining
decoders, matching paid.go, and the new test helpers in package bot are prefixed
to keep them out of the way of future tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137fHQ9PA1NxHyDS9XFfkay
A new section rather than appending to v1.25.0, which is already tagged. Minor
bump, since the repository has only ever cut x.y.0.

The v1.24.0 entry describing an unknown Type as unsupported now points at this
release, where it stopped being an error on the marshal side too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137fHQ9PA1NxHyDS9XFfkay
@ingvarch

ingvarch commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

All five items are fixed in 2eec952 (code) and 359b3cd (changelog). Both blocking findings reproduced exactly as described.

1. MarshalJSON now accepts what UnmarshalJSON accepts. An unknown discriminator is encoded as the bare {"type":"<Type>"} (status for ChatMember, source for ChatBoostSource) through a shared marshalUnknownVariant in models/union.go, wired into all nine paired marshalers. An empty discriminator is an unset value rather than a variant from a future release, so it stays an error — that keeps a zero-value ReactionCount{} from silently encoding as {"type":""}.

Only the discriminator is written; the unknown variant's other fields are not preserved, since no variant pointer was populated to hold them. Keeping the raw object would mean carrying json.RawMessage on thirteen structs, so I left it out — say the word if you want it.

ReactionType.MarshalJSON has a paid case now, and {"type":"paid"} round-trips.

2. Tight loop fixed. Reproduced at 90µs between requests. get_updates.go now sets timeoutAfterError = incErrTimeout(timeoutAfterError) when decodeUpdate fails and the head parse leaves ID == 0, so an unreadable update_id backs off 100ms..5s like the errRequest path. When the id is readable the offset still moves and the batch continues without a backoff.

3. RichText empty type. {"text":"hi"} is an error again. Guard is on v.Type == "" before the switch, so a real unknown type stays tolerated. The other thirteen models have no "" alias and are unchanged.

4. CHANGELOG. New ## v1.26.0 section — a separate one rather than appending to the already-tagged v1.25.0, and a minor bump since the repo has only cut x.y.0. The v1.24.0 line now ends with a pointer that an unknown Type stopped being an error on either side in v1.26.0, instead of being rewritten in place.

5. Skip path logs the raw payload alongside the id, in the same shape webhook_handler.go uses: error decode update %d, skipped, %s, %w.

Nits: the Type assignment is hoisted above the switch in the thirteen remaining decoders, so all fourteen read like paid.go (this also let ReactionType's discriminator become ReactionTypeType instead of string, and its case literals become the constants). Test error variables are unmarshalErr / marshalErr / handlerErr. clientFunc / jsonResponse are getUpdatesClientFunc / getUpdatesJSONResponse.

Tests: TestPolymorphicMarshal_UnknownDiscriminator (round-trip over the nine marshalers), TestPolymorphicMarshal_EmptyDiscriminator, TestReactionType_PaidRoundTrip, TestRichText_ObjectWithoutType, and Test_getUpdates_backsOffWhenUpdateIDUnreadable, which asserts the gap between the first and second request. Each was written first and confirmed failing against the previous head. go test -race ./..., go vet, gofmt and golangci-lint are clean; every changed non-test line is covered.

@ingvarch
ingvarch requested a review from negasus September 8, 2026 11:48
negasus added a commit that referenced this pull request Sep 8, 2026
…in getUpdates (#300)

Any new discriminator value from a Bot API release stalled long polling
permanently: fourteen polymorphic models returned an error from
UnmarshalJSON for an unknown type/status/source, getUpdates decoded the
whole batch at once, so the offset never advanced and the same batch was
re-requested forever.

- models: an unknown discriminator is kept in Type, every variant pointer
  stays nil and no error is returned. The nine unions with a MarshalJSON
  encode it back as the bare {"type":"<Type>"}. An object without a
  discriminator is rejected on both sides.
- ReactionType.MarshalJSON handles paid and goes through marshalVariant,
  so a Type without its variant returns an error instead of panicking.
- getUpdates decodes each update on its own, reports and skips an
  undecodable one, and backs off (100ms..5s, one step per request) while
  the offset is stuck on an element without a readable update_id.
- CHANGELOG: cut v1.26.0.

Co-authored-by: Igor Churmeev <ingvarch@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qm7Hc47dqwLEchhMFziwMf
@negasus

negasus commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Merged into main as edea494 (squash, with you as co-author). Thank you for the thorough work and the quick turnaround on the review — the per-update decode plus tolerant discriminators is exactly the shape this needed.

I finished a few things on top of your branch before merging, so you know what changed relative to 359b3cd:

  • Backoff never grew. timeoutAfterError = 0 ran before the batch loop, so incErrTimeout always started from zero and the poll sat at a constant 100ms (measured: five consecutive gaps of ~101ms). It now steps once per request while the offset is stuck on the last element and resets as soon as the offset moves, so a stuck batch backs off 100ms..5s as intended and a bad element followed by a good one does not delay the next poll at all.
  • An element without a readable update_id on the success path (null, {}) decoded without error into an Update{ID: 0} and stored lastUpdateID = 0, which would re-fetch every unconfirmed update. Such elements are now reported through the errors handler and skipped, and never touch the offset.
  • ReactionType.MarshalJSON panicked on a Type set without its variant pointer (ReactionType{Type: paid} is the natural way to build a paid reaction for setMessageReaction). All three cases now go through marshalVariant, which returns an error instead.
  • Empty discriminator. {} was accepted by UnmarshalJSON in the thirteen unions other than RichText but rejected by MarshalJSON, so a value could decode and then fail to encode. All fourteen now reject an object without a discriminator via a shared missingDiscriminator helper.
  • CHANGELOG scoped to what actually happens: the round-trip applies to the nine unions that have an encoder, and only the discriminator survives.

Tests for each of these were added and confirmed failing against your head before the fix. Keeping the raw payload of an unknown variant (your open question) is worth doing, but as its own PR.

@negasus

negasus commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closing: landed on main via squash commit edea494.

@negasus negasus closed this Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants