Conversation
…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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
negasus
left a comment
There was a problem hiding this comment.
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
Typeassignment could be hoisted before theswitch, aspaid.goalready does. That removes the per-armx.Type = Constlines in 13 methods and makes all 14 decoders read the same.- In the new tests please name error variables descriptively (
unmarshalErr,handlerErr) rather thanerr. clientFunc/jsonResponsein packagebothave fairly generic names; consider prefixing them to avoid collisions with future tests.
What looks good
- Decoding the batch as
[]json.RawMessageand each update on its own also covers future non-discriminator failures. - The error reaches the errors handler with the
update_idand wraps with%w. - The table test with the reflect check that every variant pointer stays nil is a nice guard.
…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
|
All five items are fixed in 2eec952 (code) and 359b3cd (changelog). Both blocking findings reproduced exactly as described. 1. 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
2. Tight loop fixed. Reproduced at 90µs between requests. 3. 4. CHANGELOG. New 5. Skip path logs the raw payload alongside the id, in the same shape Nits: the Tests: |
…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
|
Merged into I finished a few things on top of your branch before merging, so you know what changed relative to
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. |
|
Closing: landed on main via squash commit edea494. |
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) returnedunsupported <Type> typefromUnmarshalJSONwhen thetype/status/sourcevaluewas not in their switch.
getUpdatesdecoded the whole batch with onejson.Unmarshalinto[]*models.Update, so a single update carrying an unknown value failed the entire call.lastUpdateIDwas 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.
status/typevalues in almost every version (recently:paidreaction,uniquegift,telegram_apitransaction 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.getMeanswers. The only trace is one error line every 5 seconds that reads like network noise. The bot looks alive and does nothing.WithAllowedUpdatesdoes not help:ChatMemberis nested inMessage(new_chat_members,chat_member),ReactionTypeinmessage_reaction,MessageOriginin 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:
models/: an unknown discriminator is no longer an error. The wrapper keeps the raw value in itsType(SourceforChatBoostSource), leaves every variant pointer nil and returnsnil. Consumers switching onTypefall into their default branch instead of never seeing the update.get_updates.go: the batch is decoded as[]json.RawMessageand each update on its own (decodeUpdate). An update that still fails to decode is reported through the errors handler with itsupdate_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
getUpdatesreturns one update with"new_chat_member":{"status":"future_status"}next to a normal message update. Observe: the default handler is never called, every request carriesoffset=1, and the errors handler repeatserror get updates, error decode response result for method getUpdates, unsupported ChatMember type. The newTest_getUpdates_skipsUndecodableUpdatefails onmainwithlastUpdateID=0.Behaviour change
{"type":"<unknown>"}into any of the models above now succeeds withType == "<unknown>"and nil variants.TestRichBlock_UnknownTypeandTestRichText_UnknownTypeasserted the old error and are replaced by the table test.MarshalJSONof such a value still returns an error: there is no payload to encode.Tests
models/unknown_type_test.go: table test over all 14 wrappers, unknown value decodeswithout error, discriminator preserved, every variant pointer nil (checked via reflect).
get_updates_test.go: a batch of100,101(undecodable),102delivers100and102, moveslastUpdateIDto102and reports one error mentioning101.go test -race ./...,go vet,gofmt,golangci-lintclean.