Calling the API
This page covers the general rules for calling OpenAPI with the Go SDK: how REST APIs map to SDK methods and parameters, how optional parameters work, how to page, read batch results and check errors, which requests the SDK retries and how it waits when rate-limited, plus file uploads, how app status affects calls, and troubleshooting.
What each API's parameters and returned fields mean is defined in the server REST API and isn't repeated here; for which API each method maps to, see Services and methods.
| You want to | See |
|---|---|
| Know what to check before exposing operations to users | App permissions and what users can see |
Find a method, and write parameters, optional parameters and null | Naming rules, Optional parameters and zero values |
| Page through results, iterate over everything, export messages incrementally | Paging |
| Read per-item results of batch APIs, import in bulk | Batch results |
| Check errors | Error types and checks |
| Know which requests are retried, avoid sending twice | Retries and idempotency |
| Handle rate limits, throttle background jobs | Rate limits |
| Upload and download files | Uploading and downloading files |
| Logs, metrics and unit tests | Logs and troubleshooting |
| Stream replies from AI bots | AI streaming replies |
App permissions and what users can see
The server SDK calls APIs as the app. It doesn't know who is signed in to your system and doesn't check permissions for you. Before exposing an operation to the browser, your back end must check the user's sign-in, that the conversation belongs to them, and whether they're a group member or admin.
Messages.Recallis an admin recall, not bound by the user recall time limit or roles, so don't use it directly as a "user recall" API.Conversations.MessagesandAllMessagesread the full admin view and don't filter per user (messages they deleted or cleared, messages from before they joined or after they left a group). A user's chat screen should read what they can see through a client SDK, or with the user's own sign-in token (User Token).- The Client Secret, App Token and users' sign-in tokens stay on the back end and are never sent to the browser.
The server demo shows how the server SDK and APIs called as the user work together; when you need a user's persistent connection, use the Go client SDK.
Naming rules
SDK methods map one-to-one to REST APIs. The client is split into 13 services by area:
| Service | Covers | REST docs |
|---|---|---|
client.Users | Users, sign-in tickets, signed-in devices, global mutes | User management |
client.Friends | Friends, friend requests, how others can add a user | Friends |
client.Blacklist | Users' blocklists | Blocklist |
client.Groups | Groups, group members, group block lists, join requests, users' group settings | Group management |
client.Messages | Sending, batch sending, querying, exporting, recalling, editing and pinning messages | Send messages |
client.Conversations | Conversation info, conversation messages, users' conversation state and unread counts | Query conversations |
client.Media | Uploading, downloading, querying and deleting files | Files |
client.Push | Push settings, conversation Do Not Disturb, push devices | Push |
client.Chatrooms | Chat rooms, members, mutes, bans, allowlists, attributes, messages | Chat room management |
client.Moderation | Content moderation config, word lists, moderation items, violations, reports | Content moderation |
client.Calls | Querying and ending calls, and call stats | Audio and video |
client.Channels | Standalone RTC channels, tickets, members, stats and corrections | Channels |
client.Presence | Presence | Presence |
How methods and parameters map:
- Path parameters are positional arguments, in path order, after
ctx. For examplePATCH /users/{username}isclient.Users.Update(ctx, username, params), andPUT /groups/{group_id}/members/{username}/roleisclient.Groups.SetRole(ctx, groupID, username, role). - Query parameters and body fields go in a parameter struct named "action + object +
Params", such asCreateUserParams,SendMessageParamsandListUsersParams. APIs with only one or two parameters take them positionally, such asclient.Groups.Delete(ctx, groupID, reason). - Field names are the JSON names in Go style:
snake_casebecomesCamelCase, with acronyms in capitals, soclient_msg_idisClientMsgID,avatar_urlisAvatarURLandto_userisToUser. - Results are matching types with JSON tags, such as
*drim.Userand*drim.SendResult. APIs with no response body (204 No Content) only returnerror. - Every result type has a
Rawfield with the raw JSON the server returned. Fields the server adds later can be read from it without waiting for an SDK upgrade; unknown fields and enum values never cause errors. - Free-form content is
json.RawMessage: messageBody,Extand the like are sent and returned as is, byte for byte (only whitespace is stripped when sending). Turn JSON text into one withdrim.JSON(s), or encode Go values withdrim.Marshal(v)(which doesn't escape<,>and&). - Times are
time.Time: sent as RFC 3339 and parsed intotime.Time. - Usernames are case-insensitive: the server lowercases them, and usernames in responses are all lowercase. The SDK doesn't change usernames you pass in; when using usernames from responses as keys (such as failed items in batch results), compare them in lowercase.
Optional parameters and zero values
In parameter structs, zero-valued fields are left out of the request (not sent). There are three ways to write fields:
| Field type | Meaning | How to write it |
|---|---|---|
Plain types (string, bool, int and so on) | The zero value means "not sent"; it has no meaning of its own | Nickname: "Zhang San" |
Pointers (*string, *int64, *bool and so on) | nil means "not sent", and the zero value means something (such as an empty string to clear, false to turn off) | drim.String(""), drim.Int64(3600), drim.Bool(false) |
drim.Field[T] | Distinguishes three states: not sent, sent as null, and sent with a value | Zero value is not sent, drim.Null[T]() sends null, drim.Set(v) sends a value |
drim.String, drim.Int, drim.Int64, drim.Bool and drim.Float64 return a pointer to the value.
Field is for fields where null means something, such as user profile updates where attributes set to null clears all custom attributes and an entry set to null deletes that entry:
// 修改昵称,设置 vip 属性、删除 level 属性,其他属性保持不变
user, err := client.Users.Update(ctx, "zhangsan", drim.UpdateUserParams{
Nickname: drim.String("张三"),
Attributes: drim.Set(map[string]drim.Field[string]{
"vip": drim.Set("1"),
"level": drim.Null[string](),
}),
})
if err != nil {
return err
}
fmt.Println(user.Nickname, user.Version)
// 清空全部自定义属性
_, err = client.Users.Update(ctx, "zhangsan", drim.UpdateUserParams{
Attributes: drim.Null[map[string]drim.Field[string]](),
})
return errConcurrent updates
Updatable objects have a version number (a user's Version, a group's InfoVersion and so on), and update parameters have an optional Version: when given, the update only happens if the version matches, otherwise you get version_conflict; when omitted, it overwrites. See Concurrent updates and version. The SDK neither retries version_conflict nor reads the latest version for you.
Local checks
The SDK checks some parameters before sending. Invalid ones aren't sent and return a *drim.Error with code invalid_params, with Details["field"] naming the parameter:
- Missing required parameters (such as sending a message, which needs exactly one of
ToUserandToGroup, plusTypeandBody). - Path parameters that are empty,
.or... - Batch APIs with more items than the limit (such as at most 100 usernames per call).
- A badly formatted
ClientMsgIDorWithRequestID. - A request body over 1 MiB (error code
payload_too_large,StatusCode0).
Rules that depend on app settings (such as message size limits and member limits) are checked by the server.
Paging
List APIs have two methods: a page method that returns one page (such as List), and an iterator method that pages automatically (such as All), returning a Go 1.23 iterator iter.Seq2[T, error].
Automatic paging
// 遍历全部正常状态的用户
for user, err := range client.Users.All(ctx, drim.ListUsersParams{Status: "active"}) {
if err != nil {
return err // 某一页最终失败:之前产出的项有效
}
fmt.Println(user.Username, user.Nickname)
}
return nil- Each page's request goes through retries and rate-limit waits as usual. If a page finally fails, the iterator yields that error and stops.
- The end is decided only by the server's next-page marker, not by item count: a page may have fewer than
Limititems, or even none, with more data after it. breakout of the loop to stop; no further pages are requested.- If the server returns the same next-page cursor as the current page, the iterator yields an
invalid_responseerror and stops, to avoid looping forever.
Page by page
To control paging yourself (such as processing in batches, or resuming where you stopped), use the page methods. Cursor-paged parameters embed drim.ListOptions (Cursor, Limit), and the result is *drim.Page[T]:
// 从上次保存的游标继续,每页 100 个
cursor := loadCursor()
for {
page, err := client.Users.List(ctx, drim.ListUsersParams{
Status: "active",
ListOptions: drim.ListOptions{Cursor: cursor, Limit: 100},
})
if err != nil {
return err
}
for _, user := range page.Items {
fmt.Println(user.Username)
}
if page.NextCursor == "" {
return nil // 没有下一页
}
cursor = page.NextCursor
saveCursor(cursor)
}Limit0 uses the server's default (20 for most lists, up to 100); see Paging.- An empty
NextCursormeans there's no next page. Pass cursors back as is without parsing them; a cursor only works with the same filters, so start from the first page when filters change.
Other kinds of paging
| Kind | Methods | Result type | Notes |
|---|---|---|---|
| Cursor | Most lists, such as Users.List and Groups.List | *Page[T] | Iterator methods are All, AllMembers and so on; see Services and methods |
| Versioned lists | Friends.List, Blacklist.List, Groups.Members, Push.Mutes | *VersionedPage[T] | KnownVersion in the parameters only applies to the first page: when it matches the current version, NotModified is true and Items is empty. There's also Version, Count (friend count, blocklist size, member count) and MaxCount (limit for friends and blocklists). Iterator methods don't use KnownVersion |
By seq | Conversations.Messages | *SeqPage | HasMore, NextAfterSeq, NextBeforeSeq; with AroundSeq, also HasMoreBefore and HasMoreAfter. The iterator method is Conversations.AllMessages (no AroundSeq) |
| Export by message ID | Messages.Export | *ExportPage | HasMore, NextAfterMessageID. The iterator method is Messages.ExportAll |
| Delete in a loop | Friends.DeleteAll | Total deleted | The SDK calls until has_more is false. On an error it returns the count deleted so far plus the error; call again to continue |
| Not paged | Such as Users.Sessions, Push.Devices, Moderation.WordLists, Chatrooms.RecentMessages | Slice | No iterator method |
Only read the friend list when it has changed:
// 第一页带上本地保存的版本号;没有变化时只返回版本号
page, err := client.Friends.List(ctx, "zhangsan", drim.ListFriendsParams{KnownVersion: drim.Int64(localVersion)})
if err != nil {
return err
}
if page.NotModified {
return nil
}
var friends []drim.Friend
for friend, err := range client.Friends.All(ctx, "zhangsan", drim.ListFriendsParams{}) {
if err != nil {
return err
}
friends = append(friends, friend)
}
saveFriends(page.Version, friends)
return nilIf data changes during iteration, the SDK doesn't start over; fetch again with the new version when needed.
Export all of the app's messages (oldest message ID first, only messages sent more than 30 seconds ago), continuing from the last one after an interruption. Save lastMessageID when iteration ends, and continuing from it next time gives an incremental export:
for msg, err := range client.Messages.ExportAll(ctx, drim.ExportParams{AfterMessageID: lastMessageID, Limit: 1000}) {
if err != nil {
return err // 下次从 lastMessageID 之后继续
}
if err := archive(msg); err != nil {
return err
}
lastMessageID = msg.MessageID
}
return nilConversations.AllMessages iterates toward newer messages (ascending seq) when AfterSeq (or only StartTime) is given, and otherwise toward older messages from BeforeSeq (the latest if omitted), in descending seq.
Batch results
Batch APIs return 200 overall with a result per item; see Batch APIs. Errors for the whole request, such as invalid parameters or exhausted quota, are still returned as error; failures of single items are in the result, typed drim.ItemError (not *drim.Error):
| Field | Description |
|---|---|
Key | The item's identifier: username (lowercase), message ID, record ID, attribute key and so on |
Code | Error code, with the same values as *drim.Error |
Message | Description, for logs only; some APIs don't give one |
Details | Extra information, may be nil; Reason() returns details.reason |
Each batch API has its own result type, all with three methods: FailedItems() returns the failed items, OK() says whether everything succeeded, and Err() returns a combined error (*drim.BatchError, with the failed items in Failed) when anything failed, or nil otherwise.
| Method | Result type | Succeeded items | Failed items |
|---|---|---|---|
Users.BatchCreate | BatchCreateUsersResult | Created | Failed (no Details) |
Friends.BatchAdd | BatchAddFriendsResult | Added (including those already friends) | Failed |
Friends.BatchDelete | BatchDeleteFriendsResult | Deleted | Failed |
Blacklist.BatchAdd, BatchRemove | BatchBlacklistResult | Added or Removed | Failed |
Groups.Create, AddMembers, RemoveMembers | CreateGroupResult, GroupMembersResult | Entries in Results with Error nil, with Status such as joined, already_member, removed or not_member | Entries in Results with non-nil Error |
Messages.BatchSend | BatchSendResult | Entries in Results with Status sent or duplicate | Entries in Results with non-nil Error |
Messages.BatchRecall | BatchRecallResult | Entries in Results with Error nil | Entries with non-nil Error |
Chatrooms.Kick, Mute, Unmute, Ban, Unban, AddToAllowlist, RemoveFromAllowlist | RoomBatchResult | Entries in Results with Error nil, with the outcome in Result | Entries in Results with non-nil Error |
Chatrooms.SetAttributes, RemoveAttributes | RoomAttributesChange | Keys not in Failed | Failed (keyed by attribute name) |
Moderation.AddWords | AddWordsResult | Added (count), Duplicated | Invalid (Key is the word) |
Moderation.BatchDecide | BatchDecideResult | Entries in Results with non-nil Item | Entries in Results with non-nil Error |
Media.DownloadURLs | []DownloadURLItem | Entries with a non-empty DownloadURL | Entries with non-nil Error |
result, err := client.Groups.AddMembers(ctx, groupID, []string{"lisi", "wangwu", "zhaoliu"})
if err != nil {
return err // 整个请求失败,没有任何一项执行
}
for _, item := range result.FailedItems() {
if item.Code == drim.CodePermissionDenied && item.Reason() == "group_blacklisted" {
log.Printf("%s 在群黑名单中,没有加入", item.Key)
continue
}
log.Printf("%s 加入失败:%s", item.Key, item.Code)
}
return nilAutomatic splitting
Users.BatchCreateAll, Friends.BatchAddAll and Messages.BatchSendAll split by the API's limit, submit the batches in turn and merge the results, for imports, broadcasts and the like:
- When the server gives a smaller limit with
too_many_items(such as the app'smax_recipients_per_message), the SDK splits again by it. - Every batch of
BatchSendAlluses the sameClientMsgID; the server de-duplicates per recipient, so resubmitting never sends twice. - If a whole batch fails, it stops and returns the merged results of what finished plus that batch's error. Items missing from the results can be submitted again.
- The merged
Rawis{"batches":[raw JSON of each batch]}. - Each batch counts toward the OpenAPI quota by item count; combine it with local rate limiting to leave quota for live requests.
// 从旧系统迁移用户:每批 100 个,本地限速每秒 200 项
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
RateLimit: &drim.RateLimit{PerSecond: 200},
})
if err != nil {
return err
}
defer client.Close()
var users []drim.CreateUserParams
for i := 0; i < 1000; i++ {
users = append(users, drim.CreateUserParams{Username: fmt.Sprintf("legacy-%04d", i), Nickname: fmt.Sprintf("用户 %d", i)})
}
result, err := client.Users.BatchCreateAll(ctx, users)
if result != nil {
log.Printf("创建了 %d 个", len(result.Created))
for _, item := range result.FailedItems() {
if item.Code != drim.CodeAlreadyExists { // 已存在的视为上次已导入
log.Printf("%s 失败:%s", item.Key, item.Code)
}
}
}
return err // 不为 nil 时,某一批整体失败,之后的没有提交Error types and checks
Errors returned by the SDK are all *drim.Error (except per-item failures of batch APIs and callback handling errors); get one with errors.As:
| Field | Description |
|---|---|
StatusCode | HTTP status code; 0 for the SDK's own errors (invalid_response has the actual status) |
Code | The server's error code, or the SDK's own |
Message | The server's description (in Chinese), for logs only; don't branch on it |
Details | details as is, nil if absent; Reason() returns details.reason |
RequestID | This request's ID; give it when troubleshooting |
RetryAfter | The Retry-After response header |
Op | API name, such as users.create; app.token when fetching the token failed |
Attempts | How many attempts this call made in total |
Retried | This error came from an automatic retry after an earlier attempt's outcome was unknown; see Retries and idempotency |
Err | The underlying error (network error, context.Canceled, context.DeadlineExceeded and so on), returned by Unwrap |
err.Error() looks like users.create: 409 already_exists: 用户名已被占用 (request_id=...), without the request body or tokens, so it's safe to log as is.
Server error codes
For the server's error codes and details values, see Error codes. The SDK defines a constant for each, such as drim.CodeNotFound and drim.CodeRateLimited; codes the server adds later still appear in Code. Check Code, and look at Reason() and Details when you need to tell reasons apart:
_, err := client.Groups.AddToBlacklist(ctx, groupID, "lisi", "广告")
var e *drim.Error
switch {
case err == nil:
return nil
case drim.IsCode(err, drim.CodeNotFound):
return fmt.Errorf("群不存在或已解散")
case drim.IsReason(err, drim.CodeLimitExceeded, "group_blacklist_limit"):
return fmt.Errorf("群黑名单已满")
case drim.IsCode(err, drim.CodePermissionDenied):
return fmt.Errorf("不能把群主加入群黑名单")
case errors.As(err, &e) && e.Code == drim.CodeInvalidArgument:
log.Printf("参数错误:%v,字段 %v", e.Reason(), e.Details["field"])
return err
default:
return err
}Check functions:
| Function | True when |
|---|---|
drim.IsCode(err, code) | err is a *drim.Error with error code code |
drim.IsReason(err, code, reason) | The error code is code and details.reason is reason |
drim.IsTemporary(err) | Network errors, timeouts, internal, 5xx, rate_limited: you can try again later |
drim.IsServiceUnavailable(err) | The service is paused or read-only: tenant_unavailable, app_unavailable, and the resulting client_unavailable during the cooldown |
drim.IsPermanentlyUnavailable(err) | The app was deleted or the tenant closed (unauthenticated with app_deleted or tenant_closed), and the client_unavailable returned by the permanently unavailable client: clear the saved credentials |
The SDK's own error codes
| Error code | Constant | Meaning |
|---|---|---|
network_error | CodeNetworkError | Connection failed or dropped, or a TLS error (such as certificate verification failing); Err is the underlying error |
timeout | CodeTimeout | An attempt timed out, or ctx's deadline passed; errors.Is(err, context.DeadlineExceeded) is true |
canceled | CodeCanceled | ctx was cancelled; errors.Is(err, context.Canceled) is true |
invalid_params | CodeInvalidParams | A local check failed; Details["field"] names the parameter |
payload_too_large | CodePayloadTooLarge | The request body is over 1 MiB and wasn't sent (same name as the server code, with StatusCode 0) |
invalid_response | CodeInvalidResponse | The response isn't the expected JSON (such as an HTML page from a gateway); Details["body_prefix"] has the first 512 bytes of the body |
client_unavailable | CodeClientUnavailable | The client is closed, permanently unavailable, or in the cooldown after a failed token fetch; Details["cause"] gives the reason (closed or the original error code) |
Retries and idempotency
The SDK marks whether each API can be retried safely, and uses that to decide whether to retry on network errors, timeouts and 5xx (each method's category is in Services and methods):
| Category | APIs | Network errors, timeouts, 5xx |
|---|---|---|
| Query | Every GET, plus POSTs that query (looking up users by username, checking friendships, getting download addresses, querying presence, checking text and so on) | Retried automatically |
| Writes with a dedup ID | Sending messages, batch sending one-to-one messages, sending chat room messages (client_msg_id) | Retried automatically with the same dedup ID |
| Created with a unique key | Creating users, batch creating users (username) | Retried automatically |
| Idempotent writes | Calls that give the same or a harmless result when repeated: updates, deletes, bans, blocks, adding and removing members, mutes, recalls, pins, marking read, completing uploads, issuing sign-in tickets, reporting on a user's behalf, moderation decisions without an action or with a Version, and so on | Retried automatically |
| Non-idempotent writes | Creating groups, creating chat rooms, creating uploads, creating word lists, escalating to the platform, setting passwords, signing out all devices, online-only messages, low-priority chat room messages, moderation decisions with an action but no Version | Not retried if the request may already have been sent; the error is returned directly |
- Retries back off after 0.5, 1 and 2 seconds (each multiplied by a random 0.5 to 1.5), up to 3 retries by default, and never shorter than any
Retry-Afterthe server gave. Change the count withConfig.Retryordrim.WithMaxAttempts. - A request whose connection never succeeded (DNS failure, connection refused) was certainly not sent, so every category is retried. Certificate verification failures aren't retried.
- Rate-limited requests weren't executed, so every category is resent after waiting; see Rate limits.
- When the App Token becomes invalid (
unauthenticated), the SDK gets a new token and resends once; see Getting and caching the App Token.
Results after a retry
When a retry happens because an earlier attempt's outcome was unknown (such as a dropped connection), the earlier attempt may have run. If the retry then gets one of the errors below, the error's Retried is true, and you can treat the earlier attempt as having run:
- Creating a user gets
already_exists: the earlier attempt created the user. - A delete gets
not_found: the earlier attempt deleted it. - An update with
Versiongetsversion_conflict: the earlier attempt updated it, and reading again shows the updated data.
_, err := client.Users.Create(ctx, drim.CreateUserParams{Username: "zhangsan", Nickname: "张三"})
var e *drim.Error
if errors.As(err, &e) && e.Code == drim.CodeAlreadyExists && e.Retried {
err = nil // 前一次尝试已经创建
}
return errDedup IDs for sending messages
When you send a message or a chat room message without a ClientMsgID, the SDK generates one (32 hex characters) so automatic retries never send twice. We recommend using a business ID as the ClientMsgID (1 to 64 visible ASCII characters, not starting with evt_ or sys_), so your own business-level retries don't send twice either:
- A repeated
ClientMsgIDin the same conversation isn't sent again; the result'sDuplicateistrueand you get the message from the first send. ClientMsgIDis required for batch sending one-to-one messages; the server de-duplicates per recipient, and repeated items haveStatusduplicate.- Online-only messages (
OnlineOnly) and low-priority chat room messages aren't de-duplicated; the SDK generates no dedup ID and doesn't retry them.
Reports on a user's behalf also have Duplicate in the result (each reporter's report on the same target counts once).
When a non-idempotent call times out
After a non-idempotent write times out or the connection drops, look up the result before deciding whether to retry:
- Creating a group: query the groups the owner belongs to (writing a business ID into the group's
Attributeswhen creating makes it easy to confirm). - Creating a chat room: query the chat room list by name or owner.
- Creating a word list: find it by name with
Moderation.WordLists. - Issuing a sign-in ticket: just issue another, and only give the app the last one.
Rate limits
OpenAPI rate-limits per app, returning 429 rate_limited with Retry-After when exceeded; see Rate limits and quotas. On rate_limited, the SDK waits for Retry-After (plus a random 0 to 20%) and resends, up to 3 times by default. What waits is decided by details.reason:
details.reason | Limit | Requests that wait together |
|---|---|---|
| None | The app's OpenAPI quota | The whole client: requests sent afterward all wait until the same moment, instead of each retrying separately |
app_message_rate | The app's messages per minute | Message-sending requests in this client (Messages.Send, BatchSend); the wait is random between Retry-After and twice that |
request_rate | Rate of reports on a user's behalf | Report requests in this client |
Others, such as group_send_rate, room_send_rate, image_busy | A limit on one group, chat room or file | Only this request |
- A
Retry-Afterlonger thanMaxRetryAfter(60 seconds by default) isn't waited for, andrate_limitedis returned: usually the quota is used up, and your job queue should decide when to try again. - Waiting counts toward
ctx's time limit: if it would pass the deadline, the SDK doesn't wait and returnsrate_limited. - After waiting and resending
MaxRateLimitedtimes (3 by default) and still being limited,rate_limitedis returned. The error'sRetryAfteris the wait the server gave. - To never wait in live requests, use
drim.WithMaxRetryAfter(ctx, 0)and get the error immediately when limited.
Local rate limiting
Background jobs such as bulk imports and broadcasts can be throttled locally, leaving the app's quota for live requests:
// 后台任务专用的客户端:每秒最多 200 次,批量接口按项数计
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
RateLimit: &drim.RateLimit{PerSecond: 200, Burst: 200},
})
if err != nil {
return err
}
defer client.Close()- Local rate limiting is a token bucket:
PerSecondis requests per second, andBurstis the bucket size (by defaultPerSecondrounded up, at least 1). - Batch APIs count by item (matching how the server counts quota), and one request takes at most the bucket size.
- Waiting for the local limit also counts toward
ctx's time limit, returningtimeoutif it would pass the deadline. - Create separate clients for live requests and background jobs, and only set
RateLimiton the background one.
Uploading and downloading files
Uploading a file takes three steps: create the upload, upload the content, and complete the upload (see Upload files). client.Media.Upload does it in one call:
f, err := os.Open("photo.jpg")
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
file, err := client.Media.Upload(ctx, f, st.Size(), drim.UploadParams{Purpose: "attachment", Kind: "image"})
if err != nil {
return err
}
// 把文件地址(不是下载地址)写入图片消息
body, err := drim.Marshal(map[string]any{"url": file.URL, "size": file.Size, "width": file.Width, "height": file.Height})
if err != nil {
return err
}
_, err = client.Messages.Send(ctx, drim.SendMessageParams{From: "notice", ToUser: "zhangsan", Type: "image", Body: body})
return err- The content source is an
io.ReaderAt(*os.File,*bytes.Readerand*strings.Readerall implement it), used for reading parts concurrently and resending. With only anio.Reader(such as a network stream), write it to a temporary file first.sizeis the content's byte count. - Up to 32 MB is uploaded in one request; larger files are uploaded in 16 MB parts, 4 at a time by default (
Concurrency). - Content uploads go straight to object storage without
Authorization. On failure each part is retried 3 times on its own, and expired addresses are requested again. - On completion the server verifies the content and generates thumbnails, which can take up to about 2 minutes; the SDK waits, retries or uploads missing parts as the server says, and finally returns the file info.
- When
ctxis cancelled or expires, the upload stops and the SDK tries to cancel it (with its own 5-second limit; failure isn't reported).
UploadParams fields:
| Field | Description |
|---|---|
Purpose | Use: attachment (message attachment), group_file (group file), user_avatar, group_avatar, chatroom_avatar |
Kind | Kind: image, voice, video, file; avatars can only be image, and group files can't be voice |
Name | File name, required for file and group files; up to 255 characters, without /, \ or control characters |
ContentType | Only a hint; the server identifies the format from the content |
GroupID | The group of a group file, required for group files |
Owner | The owning user, required for user avatars |
Concurrency | Concurrent parts for multipart uploads, 4 by default |
OnProgress | Called after each part (or the whole file for single uploads) with bytes uploaded and total; must not block |
The SDK checks locally that the purpose and kind fit together, that avatars are at most 5 MB, the file name rules, GroupID for group files and Owner for user avatars, returning invalid_params otherwise. Limits that depend on app settings are checked by the server. Errors from uploading to object storage have Op media.upload or media.upload_part.
Set a user's avatar
f, err := os.Open("avatar.png")
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
file, err := client.Media.Upload(ctx, f, st.Size(), drim.UploadParams{Purpose: "user_avatar", Kind: "image", Owner: "zhangsan"})
if err != nil {
return err
}
_, err = client.Users.Update(ctx, "zhangsan", drim.UpdateUserParams{AvatarURL: drim.String(file.URL)})
return errResuming uploads
A multipart upload (files over 32 MB) interrupted within 24 hours (from when the upload was created) can be resumed with the file ID and the same content: ResumeUpload checks which parts were uploaded, uploads missing or wrong-sized ones, then completes the upload. When ctx is cancelled during a resume, it only stops without cancelling the upload, so you can resume again later.
Upload doesn't return the file ID when it fails (and cancels the upload when ctx is cancelled). So for large files you may need to resume, create the upload with CreateUpload first and save the file ID, then upload the content with ResumeUpload: the first call finds no uploaded parts and uploads everything, and after an interruption you call it again with the same file ID.
// 第一步:创建上传,把文件 ID 保存到你的数据库
func startUpload(ctx context.Context, client *drim.Client, size int64) (string, error) {
session, err := client.Media.CreateUpload(ctx, drim.CreateUploadParams{Purpose: "attachment", Kind: "video", Size: size})
if err != nil {
return "", err
}
return session.FileID, nil
}
// 第二步:上传内容并完成;中断后用同一个文件 ID 再调用
func uploadContent(ctx context.Context, client *drim.Client, fileID, path string) (*drim.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
return client.Media.ResumeUpload(ctx, fileID, f, st.Size(), drim.UploadParams{
OnProgress: func(uploaded, total int64) { log.Printf("已上传 %d / %d", uploaded, total) },
})
}Download
out, err := os.Create("download.jpg")
if err != nil {
return err
}
defer out.Close()
n, err := client.Media.Download(ctx, fileURL, out)
if err != nil {
return err
}
log.Printf("下载了 %d 字节", n)
return nilDownload gets a download address first, then requests it directly (without Authorization, and outside OpenAPI retries and rate limits), writing the content to an io.Writer; if the address expires, it gets a new one once. Thumbnail addresses (the file URL plus /thumb) can be downloaded the same way. Download addresses are only valid for 60 to 90 minutes; when you need the address itself, use client.Media.DownloadURLs, and don't save it or put it in messages.
Media.Download only fetches the file content; it doesn't set the content type, caching or download headers of your own HTTP responses. When proxying attachments to the browser, don't trust the MIME type the browser sends; content that can run scripts, such as HTML and SVG, should be forced to download or served from a separate domain. The server demo only shows content recognized as PNG, JPEG, GIF or WebP inline, sends everything else as a download, and sets nosniff, a sandboxed CSP and no caching.
Process pending moderation items
client.Moderation.ProcessPending goes through records awaiting handling in the tenant's moderation queue (pending, auto_violation), reads their content, passes them to your function, and acts on the decision it returns:
decided, err := client.Moderation.ProcessPending(ctx, drim.ProcessOptions{Scene: "message"},
func(ctx context.Context, item drim.ModerationItem, content drim.ItemContent) (*drim.Decision, error) {
if needHumanReview(item) {
return nil, nil // 返回 nil 跳过这条
}
return &drim.Decision{Decision: "no_violation", Note: "自动复核通过"}, nil
})
log.Printf("处理了 %d 条", decided)
return err- Decisions carry the
Versionthat was read, so they're safe to retry; records already handled by someone else (version_conflict) are skipped. - If your function returns an error or making the decision fails, a warning is logged and it moves on to the next one by default; with
StopOnErrortrue, it stops and returns the error. - It returns the number of decisions made; if fetching the next page fails, it returns the count so far and that error.
For parameters and decision values, see Moderation items.
App and tenant status
When the app's or tenant's status changes (see App and tenant status), the SDK behaves like this:
| Status | What OpenAPI calls get | How to check |
|---|---|---|
| Read-only (set by the platform, or unpaid bills) | Queries and actions work; creating data and updating profiles return app_unavailable | drim.IsServiceUnavailable(err) |
| App disabled, tenant suspended, or in the closure cooling-off period | Existing tokens stop working immediately; when the SDK fetches again it gets app_unavailable or tenant_unavailable, and for the next 2 to 5 minutes returns client_unavailable straight away without fetching | drim.IsServiceUnavailable(err) |
| App deleted, tenant closed | unauthenticated (app_deleted, tenant_closed); the client becomes permanently unavailable | drim.IsPermanentlyUnavailable(err) |
Once the status is restored, the SDK fetches a new token automatically on the first request after the cooldown, with no need to create a new client. Events from while it was suspended aren't called back or resent afterward; fill in the data you need by querying or exporting through OpenAPI.
Logs and troubleshooting
Logs
The SDK logs nothing by default. Pass your *slog.Logger as Config.Logger:
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
Logger: logger,
})
if err != nil {
return err
}
defer client.Close()| Level | Content |
|---|---|
debug | Failures that are business outcomes (such as not_found and already_exists), rate-limit waits, tokens read from the token store |
info | A new token fetched (with its lifetime, without the token), credentials updated |
warn | A failed request about to be retried, rate-limit waits over 5 seconds, token store errors |
error | Final network errors, timeouts, 5xx, unauthenticated, ip_not_allowed, method_not_allowed, invalid_response, client_unavailable, token fetch failures, the client becoming permanently unavailable |
Log entries have fields such as op (API name), status, code, reason, request_id and attempts. They never include the Client Secret, App Token, sign-in tickets, passwords, message content, or upload and download addresses. Other 4xx errors are your business outcomes, so the SDK only logs them at debug and leaves it to you whether to record them.
Request IDs
Every request carries X-Request-ID. Unless you set one with drim.WithRequestID, the SDK generates it, and retries of the same call reuse it. On errors, e.RequestID is the request ID the server returned; give it to support when asking for help.
Hooks and stats
Config.Hooks is called before and after each attempt, for injecting trace headers and recording metrics (token requests call it too, with API name app.token):
requests := map[string]int{} // 示例:按接口名和结果计数(实际请换成你的指标库)
var mu sync.Mutex
client, err := drim.New(drim.Config{
BaseURL: "https://im.example.com",
OrgName: "1100250925",
AppName: "demo",
ClientID: os.Getenv("IM_CLIENT_ID"),
ClientSecret: os.Getenv("IM_CLIENT_SECRET"),
Hooks: drim.Hooks{
BeforeRequest: func(ctx context.Context, req *http.Request) {
req.Header.Set("traceparent", traceparentFrom(ctx)) // 注入追踪头
},
AfterResponse: func(ctx context.Context, info drim.RequestInfo) {
mu.Lock()
requests[info.Op+" "+info.Code]++
mu.Unlock()
if info.Duration > time.Second {
log.Printf("慢请求 %s %s 第 %d 次尝试 %v request_id=%s", info.Method, info.URL, info.Attempt, info.Duration, info.RequestID)
}
},
},
})
if err != nil {
return err
}
defer client.Close()- Hooks are called synchronously on the goroutine making the request, so don't block for long; the SDK doesn't recover panics in hooks.
- The request seen by
BeforeRequestincludesAuthorizationand the body; don't log them. RequestInfohasOp,Method,URL(without the query),Attempt,StatusCode(0 when no response arrived),Code(empty on success),Reason,RequestID,DurationandRateLimitWait(the rate-limit wait before this attempt).
client.Stats() returns a snapshot of stats per API name: Requests (attempts, including retries), Failures (calls that finally failed), Retries and RateLimitWait (total rate-limit wait). Read it periodically to report:
for op, s := range client.Stats().Ops {
log.Printf("%s 请求 %d 次,失败 %d 次,重试 %d 次,限流等待 %v", op, s.Requests, s.Failures, s.Retries, s.RateLimitWait)
}Simulating results with the test server
drimtest.NewServer() starts a fake OpenAPI server where you can set responses or errors per API name and record the requests it gets, to test how your code behaves under rate limits and errors:
package notify_test
import (
"context"
"testing"
drim "github.com/DeepRespond-AI/deeprespond-im-server-go-sdk"
"github.com/DeepRespond-AI/deeprespond-im-server-go-sdk/drimtest"
)
func TestSendRetriesAfterRateLimit(t *testing.T) {
srv := drimtest.NewServer()
defer srv.Close()
// 后设置的规则先用:第一次发送被限流(只生效一次),SDK 等待 1 秒后重发,得到 201
srv.Respond("messages.send", 201, `{"message_id":"1840000000000000001","seq":7}`)
srv.Fail("messages.send", drim.CodeRateLimited, drimtest.FailOptions{RetryAfter: "1"})
client, err := drim.New(srv.Config())
if err != nil {
t.Fatal(err)
}
defer client.Close()
result, err := client.Messages.Send(context.Background(), drim.SendMessageParams{
From: "notice", ToUser: "zhangsan", ClientMsgID: "order-1001-paid", Type: "text", Body: drim.JSON(`{"text":"hi"}`),
})
if err != nil {
t.Fatal(err)
}
if result.Seq != 7 || result.Duplicate {
t.Fatalf("unexpected result: %+v", result)
}
calls := srv.Calls("messages.send")
if len(calls) != 2 || calls[1].JSON()["client_msg_id"] != "order-1001-paid" {
t.Fatalf("expected 2 sends with the same client_msg_id, got %d", len(calls))
}
}| Method | Description |
|---|---|
srv.Config() | A drim.Config connected to this server (without a proxy) |
srv.Respond(op, status, body, opts...) | Sets an API's response; body as string or []byte is sent as is, other values are encoded as JSON |
srv.Fail(op, code, drimtest.FailOptions{...}) | Makes an API return an error, once by default; Status defaults by error code, RetryAfter is the response header, and Times -1 keeps it in effect |
srv.Handle(op, handler, opts...) | Handles it with your own http.HandlerFunc |
drimtest.Times(n) | Option for Respond and Handle: only applies n times. Rules set later are used first |
srv.RevokeTokens() | Invalidates every App Token issued |
srv.Calls(op), srv.Requests() | Requests received: Op, Method, Path, Query, Header, Body, with JSON() to parse the body |
op is the API name, such as users.create or messages.send, and app.token for getting tokens; each method's API name is in Services and methods. APIs with no response set return 200 with {} if they have a response body, or 204 if not.
Using standalone channels
The example below creates a video channel and issues a ticket for one user to join it (client and ctx as before):
func issueMeetingTickets(ctx context.Context, client *drim.Client) (*drim.RTCChannelTicketsResult, error) {
created, err := client.Channels.Create(ctx, "meeting-demo", drim.RTCChannelCreateParams{
Name: "项目会议", Media: "video", AccessMode: "ticket",
})
if err != nil { return nil, err }
return client.Channels.IssueTickets(ctx, created.Channel.ChannelID, drim.RTCChannelIssueTicketsParams{
Users: []drim.ChannelsIssueTicketsUsers{
{Username: "alice", Role: "publisher"},
},
})
}
// 调用方经已鉴权业务接口交付返回的票据,勿打印原文。- Parameter types are
RTCChannel…Params, and results are the matching types. Fields such as expiry times useField[time.Time]to distinguish not sent,null(clear) and a value. - Usage corrections use
Channels.UsageAdjustments. - Channel lists have no automatic paging method; page with the returned
NextCursoryourself. - Issuing tickets isn't retried automatically; when retrying member removal or ticket revocation, use the same
OperationIDfor the same operation. - Channel methods are only in newer SDKs; see Channel version. For which API each method maps to, see Channel methods.
Game rooms
Game rooms give multiplayer games a real-time match channel. Your server uses client.Game to create rooms, issue join tickets to players and close rooms when matches end; players join and play through the client SDK. Here is what your server does for one match (client and ctx as above):
// startMatch 匹配成功后建房,给每个玩家签一张加入票据。
func startMatch(ctx context.Context, client *drim.Client, matchID string, players []string) (map[string]string, error) {
// 房间 ID 用你的对局 ID:重试或重复调用都只有一个房间
_, err := client.Game.CreateRoom(ctx, matchID, drim.CreateGameRoomParams{
MaxPlayers: drim.Int64(int64(len(players))),
StartMode: "all_ready",
Ext: json.RawMessage(`{"mode":"ranked"}`),
})
switch {
case drim.IsReason(err, drim.CodeUnavailable, "game_capacity_unavailable"):
return nil, errors.New("游戏服务繁忙,请稍后再试") // 大区暂时没有空闲的游戏节点
case drim.IsReason(err, drim.CodeLimitExceeded, "open_rooms"):
return nil, errors.New("同时进行的对局太多")
case err != nil:
return nil, err
}
users := make([]drim.GameTicketUser, len(players))
for i, name := range players {
users[i] = drim.GameTicketUser{Username: name, Seat: drim.Int64(int64(i))}
}
tickets, err := client.Game.IssueTickets(ctx, matchID, drim.IssueGameTicketsParams{Users: users})
if err != nil {
return nil, err // 不会自动重试;超时后直接再签一次,旧票据随之作废
}
byUser := make(map[string]string, len(tickets))
for _, t := range tickets {
byUser[t.Username] = t.Ticket // 经你的业务接口分别交给每个玩家,不要写进日志
}
return byUser, nil
}
// finishMatch 对局结束时带上结算结果结束房间。
func finishMatch(ctx context.Context, client *drim.Client, matchID, winner string) error {
result, _ := json.Marshal(map[string]string{"winner": winner})
res, err := client.Game.CloseRoom(ctx, matchID, drim.CloseGameRoomParams{Reason: "对局结束", Result: result})
if err != nil {
return err
}
// res.Accepted 为 false:房间之前已在结束(例如超时自动结束),这次的原因和结果不生效
_ = res.Accepted
return nil
}
// kickCheater 把作弊的玩家移出房间。
func kickCheater(ctx context.Context, client *drim.Client, matchID, username string) error {
res, err := client.Game.RemoveMember(ctx, matchID, username, drim.RemoveGameMemberParams{Reason: "作弊"})
if err != nil {
return err
}
_ = res.Removed // false:他已经不在房间里
return nil
}| To do | Method | Notes |
|---|---|---|
| Create a room | CreateRoom | Use your match ID as the room ID. Calling again with the same parameters returns the same room (Created is false), and network errors are retried automatically |
| Issue join tickets | IssueTickets | 1–10 players per call. Not retried automatically: after a timeout just issue again, which revokes the user's previous unused ticket. The raw ticket is returned only once; don't log it |
| Start manually | StartRoom | Only for rooms whose start mode is manual |
| Close a room | CloseRoom | The result is passed as-is to the players and to the game_room.closed callback. If the room was already closing, Accepted is false and this call's reason and result don't apply |
| Remove a player | RemoveMember | The SDK generates OperationID and keeps it across retries. If the player isn't in the room, Removed is false |
| Query | GetRoom, ListRooms / AllRooms, ListMembers, ListRegions | Lists are newest first; you can pass several statuses separated by commas |
- Optional parameters are left out with a nil pointer or empty string;
drim.Int64(4)gives you a pointer, see Optional parameters and zero values.ExtandResultarejson.RawMessage. Created,AcceptedandRemovedcome from the HTTP status code; they are not fields of the response body.- Common errors:
unavailable(game_capacity_unavailable) when the region has no free game node — retry later or give fallback regions when creating the room;limit_exceeded(open_rooms) when the number of open rooms hits the limit. See game room errors for the full list. extcan be up to 2KB andresultup to 4KB; for limits and billing see Console, billing and limits.- The settlement callback after a match (
game_room.closed) is covered in Game room callbacks. - For the API behind each method see game room methods; for request and response fields see Game rooms: server API. Game room methods are available from v1.0.3.
AI streaming replies
In AI conversations, after a user messages a bot you get an ai.message_received callback, then start a reply with client.AI.StartStream and write it as it's generated. The returned *drim.Stream handles numbering and batching deltas (sending a chunk every 20 milliseconds or 4 KB), switching between continuous and batch writes, and resending on failure; after three failed writes in a row it ends with Fail("transport") and returns an error. Manage bots with client.AI.CreateBot and related methods; see Services and methods.
Start a reply when a message arrives
Callback handlers should return quickly, with the model call on another goroutine. Use the event ID as ClientStreamID so a redelivered callback doesn't start a second reply:
handler, err := callback.NewHandler(callback.Config{
Secrets: callback.StaticSecrets{endpointID: {secret}},
Nonces: callback.NewMemoryNonceStore(),
Dedup: callback.NewMemoryDedupStore(0),
Events: callback.EventHandlers{
AIMessageReceived: func(ctx context.Context, ev *callback.Event, d *callback.AIMessageReceived) error {
var msg struct {
Seq int64 `json:"seq"`
Body struct {
Text string `json:"text"`
} `json:"body"`
}
if err := json.Unmarshal(d.Message, &msg); err != nil {
return err
}
stream, err := client.AI.StartStream(ctx, drim.StartStreamInput{
Bot: d.Bot, To: d.From, ReplyToSeq: msg.Seq, ClientStreamID: ev.ID,
})
if err != nil {
return err
}
go reply(stream, msg.Body.Text)
return nil
},
},
})When Replace in StartStreamInput is true, any reply being generated in the conversation is ended first; when BatchOnly is true, only batch writes are used (for runtimes without streaming request bodies, such as some cloud functions).
Connect a model
PipeOpenAI, PipeOpenAIResponses, PipeAnthropic and PipeGemini read the model's stream to the end and then Finish automatically (with the finish reason, model name and usage); if the model returns an error, they end with Fail("model_error", description) and return that error. The argument can be an official SDK's stream (anything with Next, Current and Err methods, such as *ssestream.Stream in openai-go and anthropic-sdk-go) or an SSE HTTP response body (io.Reader). Models compatible with the OpenAI format (DeepSeek, Qwen, Kimi, Doubao, vLLM, Ollama and so on) all use PipeOpenAI, with reasoning_content written as reasoning:
func reply(stream *drim.Stream, question string) {
defer stream.Close() // 没有结束时以 Fail("canceled") 结束
resp := openaiClient.Chat.Completions.NewStreaming(stream.Context(), openai.ChatCompletionNewParams{
Model: "gpt-4.1",
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage(question)},
})
if _, err := stream.PipeOpenAI(resp); err != nil {
log.Printf("回复 %s 没有正常结束:%v", stream.ID, err)
}
}| Method | Conversion |
|---|---|
PipeOpenAI | Chat Completions: delta.content → text, reasoning_content / reasoning → reasoning, tool_calls → tool calls (marked done when the stream ends) |
PipeOpenAIResponses | Responses API: response.output_text.delta → text, reasoning_summary_text.delta → reasoning, *_call output items → tool calls |
PipeAnthropic | Messages: text_delta → text, thinking_delta → reasoning, tool_use blocks → tool calls |
PipeGemini | generateContent: parts with thought true → reasoning, the rest → text |
Write manually
_ = stream.WriteReasoning("先查询天气。")
_ = stream.WriteTool("call_1", "weather", "查询天气", drim.ToolRunning)
// … 调用工具 …
_ = stream.WriteTool("call_1", "weather", "查询天气", drim.ToolDone)
_ = stream.WriteText("明天上海**多云**,")
_ = stream.WriteText("最高 23℃。")
_ = stream.WriteImage(drim.ImagePart{URL: "https://cdn.example.com/a.png", Width: 800, Height: 600})
result, err := stream.Finish(ctx, drim.FinishOptions{Model: "gpt-4.1", Usage: &drim.AIUsage{InputTokens: 120, OutputTokens: 380}})Write*only buffers in memory without waiting on the network, and can be called from several goroutines; text of the same type as the previous part is appended to the same part. CallingWriteToolagain with the same id updates the same part.Flush(ctx)sends immediately and waits for confirmation;Finishconfirms all content first, then ends.- When the model call fails, call
stream.Fail(ctx, "model_error", "The model is temporarily unavailable"); content generated so far is kept, and the user sees "Generation interrupted" with your message. - Writing after the stream has ended returns an error.
Stop generating
When the user taps stop, the reply times out, or it's replaced by a new reply, stream.Context() is cancelled with context.Cause drim.ErrStreamStopped, and later writes return an error with drim.CodeStreamClosed. Pass stream.Context() to the model request so the model call stops too; Pipe* also stops reading and closes the model's stream.
callback.Handler cancels the Context of the matching stream in this process automatically when ai.stream_stopped arrives, with no handler needed. If the stream is in another process, its next send gets stream_closed, with the same effect. If you handle callbacks yourself (such as taking events from a queue), call client.AI.StreamStopped(streamID).
