Skip to content

Commit 9de654f

Browse files
ernadoclaude
andcommitted
feat(errors): map MTProto RPC errors to Bot API errors
asAPIError normalizes tgerr.Error into a Bot-API-shaped *Error: flood waits become 429 with retry_after, and the common types (chat/message not found, blocked/deactivated user, channel private, not-modified, ...) get Bot API codes and descriptions. nil, existing *Error and non-RPC errors pass through unchanged. Full table lands in Phase 6. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f723121 commit 9de654f

2 files changed

Lines changed: 121 additions & 0 deletions

File tree

errors_map.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package botapi
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"time"
7+
8+
"github.com/gotd/td/tgerr"
9+
)
10+
11+
// asAPIError normalizes an error from the MTProto layer into a Bot-API-shaped
12+
// *Error. It is a no-op for nil, for errors that are already *Error, and for
13+
// non-RPC errors (e.g. context cancellation or network failures), which are
14+
// returned unchanged so callers can branch on them.
15+
//
16+
// The mapping here covers the common cases; the full tgerr -> Bot API table is
17+
// completed in Phase 6.
18+
func asAPIError(err error) error {
19+
if err == nil {
20+
return nil
21+
}
22+
var already *Error
23+
if errors.As(err, &already) {
24+
return err
25+
}
26+
rpcErr, ok := tgerr.As(err)
27+
if !ok {
28+
return err
29+
}
30+
31+
// Flood wait maps to 429 with a retry hint.
32+
if d, ok := tgerr.AsFloodWait(err); ok {
33+
return &Error{
34+
Code: http.StatusTooManyRequests,
35+
Description: "Too Many Requests: retry later",
36+
Parameters: &ResponseParameters{RetryAfter: int(d / time.Second)},
37+
err: err,
38+
}
39+
}
40+
41+
out := &Error{Code: rpcErr.Code, Description: rpcErr.Message, err: err}
42+
switch rpcErr.Type {
43+
case "PEER_ID_INVALID", "CHAT_ID_INVALID", "CHAT_NOT_FOUND":
44+
out.Code, out.Description = http.StatusBadRequest, "Bad Request: chat not found"
45+
case "MESSAGE_ID_INVALID", "MESSAGE_NOT_FOUND":
46+
out.Code, out.Description = http.StatusBadRequest, "Bad Request: message not found"
47+
case "MESSAGE_NOT_MODIFIED":
48+
out.Code = http.StatusBadRequest
49+
out.Description = "Bad Request: message is not modified: specified new message content " +
50+
"and reply markup are exactly the same as a current content and reply markup of the message"
51+
case "MESSAGE_EMPTY":
52+
out.Code, out.Description = http.StatusBadRequest, "Bad Request: message text is empty"
53+
case "REPLY_MARKUP_TOO_LONG":
54+
out.Code, out.Description = http.StatusBadRequest, "Bad Request: reply markup is too long"
55+
case "USER_IS_BLOCKED":
56+
out.Code, out.Description = http.StatusForbidden, "Forbidden: bot was blocked by the user"
57+
case "INPUT_USER_DEACTIVATED":
58+
out.Code, out.Description = http.StatusForbidden, "Forbidden: user is deactivated"
59+
case "CHANNEL_PRIVATE":
60+
out.Code, out.Description = http.StatusForbidden, "Forbidden: bot is not a member of the chat"
61+
case "USER_ADMIN_INVALID":
62+
out.Code, out.Description = http.StatusBadRequest, "Bad Request: user is an administrator of the chat"
63+
default:
64+
if out.Description == "" {
65+
out.Description = http.StatusText(out.Code)
66+
}
67+
}
68+
return out
69+
}

errors_map_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/gotd/td/tgerr"
9+
)
10+
11+
func TestAsAPIError(t *testing.T) {
12+
t.Run("Nil", func(t *testing.T) {
13+
if asAPIError(nil) != nil {
14+
t.Fatal("nil should stay nil")
15+
}
16+
})
17+
t.Run("PassThroughNonRPC", func(t *testing.T) {
18+
if got := asAPIError(context.Canceled); !errors.Is(got, context.Canceled) {
19+
t.Fatalf("non-RPC error should pass through, got %v", got)
20+
}
21+
})
22+
t.Run("AlreadyAPIError", func(t *testing.T) {
23+
in := &Error{Code: 400, Description: "x"}
24+
if got := asAPIError(in); got != error(in) {
25+
t.Fatalf("existing *Error should be returned unchanged, got %v", got)
26+
}
27+
})
28+
t.Run("FloodWait", func(t *testing.T) {
29+
got := asAPIError(&tgerr.Error{Code: 420, Type: "FLOOD_WAIT", Argument: 12})
30+
var apiErr *Error
31+
if !errors.As(got, &apiErr) || apiErr.Code != 429 {
32+
t.Fatalf("want 429, got %#v", got)
33+
}
34+
if apiErr.Parameters == nil || apiErr.Parameters.RetryAfter != 12 {
35+
t.Fatalf("want retry_after=12, got %#v", apiErr.Parameters)
36+
}
37+
})
38+
t.Run("Forbidden", func(t *testing.T) {
39+
got := asAPIError(&tgerr.Error{Code: 400, Type: "USER_IS_BLOCKED"})
40+
var apiErr *Error
41+
if !errors.As(got, &apiErr) || apiErr.Code != 403 {
42+
t.Fatalf("USER_IS_BLOCKED should map to 403, got %#v", got)
43+
}
44+
})
45+
t.Run("ChatNotFound", func(t *testing.T) {
46+
got := asAPIError(&tgerr.Error{Code: 400, Type: "PEER_ID_INVALID"})
47+
var apiErr *Error
48+
if !errors.As(got, &apiErr) || apiErr.Code != 400 || apiErr.Description != "Bad Request: chat not found" {
49+
t.Fatalf("unexpected: %#v", got)
50+
}
51+
})
52+
}

0 commit comments

Comments
 (0)