Anankke/SSPanel-UIM · error · Exception

$response->getBody()->getContents()

Error message

$response->getBody()->getContents()

What it means

Slack::send() throws a plain Exception whose message is the raw response body after POSTing to slack.com/api/chat.postMessage returned a status other than 200. Slack's Web API mostly answers 200 even for logical failures (with body {"ok":false,"error":...}), so this specific throw fires only on HTTP-level rejections: 401/403 for token problems, 404 for a wrong endpoint, or 429 when rate limited. That also means an invalid slack_token can silently pass this check when Slack answers 200 with ok:false — check the body, not just the status.

Source

Thrown at src/Services/IM/Slack.php:47

        $url = 'https://slack.com/api/chat.postMessage';

        $headers = [
            'Authorization' => 'Bearer '.$this->token,
            'Content-Type' => 'application/json',
        ];

        $body = [
            'channel' => $to,
            'text' => $msg,
        ];

        $response = $this->client->post($url, [
            'headers' => $headers,
            'json' => $body,
        ]);

        if ($response->getStatusCode() !== 200) {
            throw new Exception($response->getBody()->getContents());
        }
    }
}

View on GitHub (pinned to d55a607191)

Solutions

  1. Decode the exception message JSON: {"ok":false,"error":"invalid_auth"} points to the token; {"error":"rate_limited"} points to throttling.
  2. For auth errors, verify the slack_token config value: OAuth token format (xoxb-...), still active, and issued by the same Slack app — reinstall the app if the token was regenerated.
  3. Add the chat:write scope to the app's bot token (Slack app config -> OAuth & Permissions) and re-install so the scope takes effect.
  4. On 429, honour the Retry-After response header and throttle chat.postMessage calls (e.g. queue notifications).
  5. Because Slack returns 200 with ok:false for logical errors, also parse the response body and fail when ok is false — not only when the status differs from 200.

Example fix

// before
$response = $this->client->post($url, ['headers' => $headers, 'json' => $body]);
if ($response->getStatusCode() !== 200) {
    throw new Exception($response->getBody()->getContents());
}

// after
$response = $this->client->post($url, ['headers' => $headers, 'json' => $body]);
$payload = json_decode((string) $response->getBody(), true) ?: [];
if ($response->getStatusCode() !== 200) {
    throw new Exception(sprintf('Slack HTTP %d: %s', $response->getStatusCode(), $payload['error'] ?? 'unknown'));
}
if (($payload['ok'] ?? false) !== true) {
    throw new Exception('Slack error: ' . ($payload['error'] ?? 'unknown'));
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-flight: token shape and channel ID sanity before Slack::send()
$token = trim((string) Config::obtain('slack_token'));
if ($token === '' || !str_starts_with($token, 'xox')) {
    throw new InvalidArgumentException('slack_token missing or not an xoxb-/xoxp- token');
}
if ($to <= 0 && !is_string($to)) {
    throw new InvalidArgumentException('Slack channel must be an ID (C...) or name');
}
// Optional authoritative check (1 extra call): does the token work at all?
$probe = $client->post('https://slack.com/api/auth.test', ['headers' => ['Authorization' => 'Bearer ' . $token]]);
if ((json_decode((string) $probe->getBody(), true)['ok'] ?? false) !== true) {
    throw new RuntimeException('Slack token rejected by auth.test');
}

Type guard

function isSlackRateLimited(Exception $e): bool
{
    $json = json_decode($e->getMessage(), true);
    return is_array($json) && (($json['error'] ?? null) === 'rate_limited' || ($json['ok'] ?? true) === false);
}

Try / catch

try {
    $slack->send($channelId, $text);
} catch (GuzzleHttp\Exception\GuzzleException $e) {
    $resp = $e instanceof GuzzleHttp\Exception\RequestException ? $e->getResponse() : null;
    if ($resp !== null && $resp->getStatusCode() === 429) {
        $wait = (int) $resp->getHeaderLine('Retry-After');
        // schedule the notification again after $wait seconds instead of dropping it
        return defer(fn () => $slack->send($channelId, $text), $wait);
    }
    throw $e;
} catch (Exception $e) {
    $payload = json_decode($e->getMessage(), true);
    $log->warning('Slack delivery failed', ['error' => $payload['error'] ?? $e->getMessage()]);
}

Prevention

When it happens

Trigger: 401 invalid_auth / permission_denied: slack_token config empty, malformed, revoked, or a rotated token after reinstalling the app; 403 missing_scope: token lacks chat:write (or chat:writecustomize:write for custom sender names); 429 rate_limited: too many chat.postMessage calls, Slack answers 429 with a Retry-After header; 404: someone changed $url away from https://slack.com/api/chat.postMessage; channel_not_found / channel ID typos typically arrive as 200 + ok:false, escaping this check entirely.

Common situations: Legacy xoxb- bot token revoked when the Slack app was reinstalled or converted to granular permissions without the chat:write scope; slack_token left blank in panel config; heavy notification storms (mass user notifications) tripping Slack's tiered rate limits; sending to channels the bot was never invited to.

Related errors


AI-assisted analysis of Anankke/SSPanel-UIM@d55a607191 (2026-08-21). Data as JSON: /api/errors/f4e26b8490d04480. Report an issue: GitHub.