Skip to content

Commit 61d66a1

Browse files
Ke-vin-Sklesh
andauthored
Fix/graphql client token refresh (#8791)
* fix(github-graphql): prevent panic in graphql rate limit polling goroutine Replace panic in GraphqlAsyncClient rate-limit polling goroutine with graceful error handling. Previously, any error while fetching rate limit (e.g., transient network issues or 401 responses) would trigger a panic inside a background goroutine, crashing the entire DevLake process. Now, errors are logged and the client retries in the next cycle while retaining the last known rate limit. Design decisions: - Avoid panic in background goroutines: rate-limit polling is non-critical and should not bring down the entire pipeline. - Use last known rateRemaining on runtime failures instead of resetting or blocking, ensuring continued progress with eventual consistency. - Retry via existing polling mechanism instead of immediate retry to prevent tight retry loops and unnecessary API pressure. - Introduce a default fallback (5000) only for initial rate-limit fetch failures, since no prior state exists at startup. - Separate handling of initial vs runtime failures: - Initial failure → fallback to default (5000) - Runtime failure → retain previous value Fixes #8788 (bug 1) * fix(github-graphql): reuse ApiClient transport for GraphQL to enable token refresh Replace oauth2.StaticTokenSource-based HTTP client with the underlying http.Client from ApiAsyncClient. Previously, the GraphQL client constructed its own HTTP client using StaticTokenSource, which froze the access token at task start time. This caused GitHub App installation tokens (which expire after ~1 hour) to become invalid during long-running pipelines, leading to persistent 401 errors. Now, the GraphQL client reuses apiClient.GetClient(), which is already configured with RefreshRoundTripper and TokenProvider. This enables automatic token refresh on 401 responses, aligning GraphQL behavior with the REST client. Design decisions: - Reuse transport layer instead of duplicating authentication logic to ensure consistency across REST and GraphQL clients. - Avoid StaticTokenSource, as it prevents token refresh and breaks long-running pipelines. - Leverage existing RefreshRoundTripper for transparent token rotation without modifying GraphQL query logic. - Keep protocol-specific logic (GraphQL vs REST) separate while sharing the underlying HTTP transport. This ensures GraphQL pipelines using GitHub App authentication can run beyond token expiry without failure. Fixes #8788 (bug 2) * refactor(github): extract shared authenticated http client from api client - moved token provider and refresh round tripper setup into a reusable helper - introduced CreateAuthenticatedHttpClient to centralize auth + transport logic - updated CreateApiClient to use shared http client instead of inline setup Rationale: - decouples authentication (transport layer) from REST-specific client logic - enables reuse for GraphQL client without duplicating token refresh logic - aligns architecture with separation of concerns (http transport vs api clients) * feat(github-graphql): introduce graphql client with shared auth and integrate into task flow - added CreateGraphqlClient to encapsulate graphql client construction - reused CreateAuthenticatedHttpClient from github/tasks to inject token refresh via RoundTripper - replaced manual graphql client setup in PrepareTaskData with new factory function - preserved existing rate limit handling via getRateRemaining callback - preserved query cost calculation using SetGetRateCost Technical details: - graphql client now uses http transport with TokenProvider and RefreshRoundTripper - removes dependency on oauth2 client and avoids token expiration issues - decouples graphql client from REST ApiClient by avoiding reuse of apiClient.GetClient() - maintains compatibility with github.com and enterprise graphql endpoints Note: - shared auth logic remains in github/tasks and is imported with alias to avoid package name collision - introduces cross-plugin dependency (github_graphql → github/tasks) as a pragmatic tradeoff to avoid duplication * feat(github): support static token transport for GraphQL and REST clients add StaticRoundTripper for PAT authentication and use it in the shared http client. since the same client is used by both REST and GraphQL, auth handling must distinguish between refreshable tokens and static tokens. avoid applying refresh/retry logic to PAT. ensures correct behavior across clients and prevents unnecessary retries for static auth. * feat(github-graphql): introduce hierarchical fallback for GraphQL rate limit Implement a layered fallback mechanism for GraphQL rate limiting: 1. Dynamic rate limit from provider (getRateRemaining) 2. Per-client override (WithFallbackRateLimit) 3. Config override (GRAPHQL_RATE_LIMIT) 4. Default fallback (1000) Also moved GitHub-specific fallback (5000) via WithFallbackRateLimit to the Graphql client. * feat(github-graphql): Add graphql rate limit to .env example * fix(github): Fix leaked debug statement * fix(github-graphql): reuse http.Client proxy, auth configurations Reused `http.Client` inside the apiClient returned by `CreateApiClient` method, so keeping the proxy and auth configurations the same.That also keep the centralized management of logic. * fix(helpers): fix the priority order of fallback rate limit Priority order fixed for fallback rate limit, priority order is: 1.Env variable 2.Value set with `WithFallbackRateLimit` 3.default value in the code This all works only when the `getRateRemaining` fails: hence the fallback * fix(github): StaticRoundTripper now owns token splitting and rotation for AccessToken connections Previously, connection.Token (comma-separated PATs) was injected as-is into the Authorization header, sending "Bearer tok1,tok2,tok3" instead of a single rotated token. StaticRoundTripper now splits the raw token string on comma and rotates through tokens round-robin using an atomic counter. For REST: StaticRoundTripper operates at transport level and always overwrites the Authorization header set by SetupAuthentication. SetupAuthentication is retained because conn.tokens is still required by GetTokensCount() for rate limit calculation — but its header write is superseded by StaticRoundTripper on every request. For GraphQL: SetupAuthentication is never called by the graphql client, so StaticRoundTripper is the only auth mechanism on this path — without this fix, GraphQL requests were sent with the full unsplit token string. * refactor(github-graphql): Downgrade fetch failure logs from Warn to Info * fix(helper): use inline func type for GraphqlClientOption to avoid mock cycle Replace exported GraphqlClientOption type with inline func(*GraphqlAsyncClient) in CreateAsyncGraphqlClient signature. The named type caused mockery to generate a mock file (GraphqlClientOption.go) that created an import cycle in tests. * style(github): fix linting * fix(github): token rotation start from index * fix(helper): prevent graphql deadlock when rate limit fetch keeps failing --------- Co-authored-by: Klesh Wong <klesh@qq.com>
1 parent 144c2b7 commit 61d66a1

7 files changed

Lines changed: 265 additions & 87 deletions

File tree

backend/helpers/pluginhelper/api/graphql_async_client.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ package api
2020
import (
2121
"context"
2222
"fmt"
23+
"strconv"
24+
"sync"
25+
"time"
26+
2327
"github.com/apache/incubator-devlake/core/errors"
2428
"github.com/apache/incubator-devlake/core/log"
2529
"github.com/apache/incubator-devlake/core/plugin"
2630
"github.com/apache/incubator-devlake/core/utils"
27-
"sync"
28-
"time"
2931

3032
"github.com/merico-ai/graphql"
3133
)
@@ -47,30 +49,52 @@ type GraphqlAsyncClient struct {
4749
getRateCost func(q interface{}) int
4850
}
4951

52+
// defaultRateLimitConst is the generic fallback rate limit for GraphQL requests.
53+
// It is used as the initial remaining quota when dynamic rate limit
54+
// information is unavailable from the provider.
55+
const defaultRateLimitConst = 1000
56+
5057
// CreateAsyncGraphqlClient creates a new GraphqlAsyncClient
5158
func CreateAsyncGraphqlClient(
5259
taskCtx plugin.TaskContext,
5360
graphqlClient *graphql.Client,
5461
logger log.Logger,
5562
getRateRemaining func(context.Context, *graphql.Client, log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error),
63+
opts ...func(*GraphqlAsyncClient),
5664
) (*GraphqlAsyncClient, errors.Error) {
5765
ctxWithCancel, cancel := context.WithCancel(taskCtx.GetContext())
66+
5867
graphqlAsyncClient := &GraphqlAsyncClient{
5968
ctx: ctxWithCancel,
6069
cancel: cancel,
6170
client: graphqlClient,
6271
logger: logger,
6372
rateExhaustCond: sync.NewCond(&sync.Mutex{}),
64-
rateRemaining: 0,
73+
rateRemaining: defaultRateLimitConst,
6574
getRateRemaining: getRateRemaining,
6675
}
6776

77+
// apply options
78+
for _, opt := range opts {
79+
opt(graphqlAsyncClient)
80+
}
81+
82+
// Env config wins over everything, only if explicitly set
83+
if rateLimit := resolveRateLimit(taskCtx, logger); rateLimit != -1 {
84+
logger.Info("GRAPHQL_RATE_LIMIT env override applied: %d (was %d)", rateLimit, graphqlAsyncClient.rateRemaining)
85+
graphqlAsyncClient.rateRemaining = rateLimit
86+
}
87+
6888
if getRateRemaining != nil {
6989
rateRemaining, resetAt, err := getRateRemaining(taskCtx.GetContext(), graphqlClient, logger)
7090
if err != nil {
71-
panic(err)
91+
graphqlAsyncClient.logger.Info("failed to fetch initial graphql rate limit, fallback to default: %v", err)
92+
graphqlAsyncClient.updateRateRemaining(graphqlAsyncClient.rateRemaining, nil)
93+
} else {
94+
graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt)
7295
}
73-
graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt)
96+
} else {
97+
graphqlAsyncClient.updateRateRemaining(graphqlAsyncClient.rateRemaining, nil)
7498
}
7599

76100
// load retry/timeout from configuration
@@ -115,6 +139,10 @@ func (apiClient *GraphqlAsyncClient) updateRateRemaining(rateRemaining int, rese
115139
apiClient.rateExhaustCond.Signal()
116140
}
117141
go func() {
142+
if apiClient.getRateRemaining == nil {
143+
return
144+
}
145+
118146
nextDuring := 3 * time.Minute
119147
if resetAt != nil && resetAt.After(time.Now()) {
120148
nextDuring = time.Until(*resetAt)
@@ -126,7 +154,15 @@ func (apiClient *GraphqlAsyncClient) updateRateRemaining(rateRemaining int, rese
126154
case <-time.After(nextDuring):
127155
newRateRemaining, newResetAt, err := apiClient.getRateRemaining(apiClient.ctx, apiClient.client, apiClient.logger)
128156
if err != nil {
129-
panic(err)
157+
apiClient.logger.Info("failed to update graphql rate limit, will retry next cycle: %v", err)
158+
// Floor the reused value so Signal() always fires; prevents deadlock when
159+
// rateRemaining is 0 and the rate-limit endpoint keeps erroring (e.g. GHE).
160+
fallback := apiClient.rateRemaining
161+
if fallback < defaultRateLimitConst {
162+
fallback = defaultRateLimitConst
163+
}
164+
apiClient.updateRateRemaining(fallback, nil)
165+
return
130166
}
131167
apiClient.updateRateRemaining(newRateRemaining, newResetAt)
132168
}
@@ -218,3 +254,25 @@ func (apiClient *GraphqlAsyncClient) Wait() {
218254
func (apiClient *GraphqlAsyncClient) Release() {
219255
apiClient.cancel()
220256
}
257+
258+
// WithFallbackRateLimit sets the initial/fallback rate limit used when
259+
// rate limit information cannot be fetched dynamically.
260+
// This value may be overridden later by getRateRemaining.
261+
func WithFallbackRateLimit(limit int) func(*GraphqlAsyncClient) {
262+
return func(c *GraphqlAsyncClient) {
263+
if limit > 0 {
264+
c.rateRemaining = limit
265+
}
266+
}
267+
}
268+
269+
// resolveRateLimit returns -1 if GRAPHQL_RATE_LIMIT is not set or invalid
270+
func resolveRateLimit(taskCtx plugin.TaskContext, logger log.Logger) int {
271+
if v := taskCtx.GetConfig("GRAPHQL_RATE_LIMIT"); v != "" {
272+
if parsed, err := strconv.Atoi(v); err == nil {
273+
return parsed
274+
}
275+
logger.Warn(nil, "invalid GRAPHQL_RATE_LIMIT, using default")
276+
}
277+
return -1
278+
}

backend/plugins/github/tasks/api_client.go

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import (
2626
"github.com/apache/incubator-devlake/core/plugin"
2727
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
2828
"github.com/apache/incubator-devlake/plugins/github/models"
29-
"github.com/apache/incubator-devlake/plugins/github/token"
3029
)
3130

3231
func CreateApiClient(taskCtx plugin.TaskContext, connection *models.GithubConnection) (*api.ApiAsyncClient, errors.Error) {
@@ -35,40 +34,10 @@ func CreateApiClient(taskCtx plugin.TaskContext, connection *models.GithubConnec
3534
return nil, err
3635
}
3736

38-
logger := taskCtx.GetLogger()
39-
db := taskCtx.GetDal()
40-
encryptionSecret := taskCtx.GetConfig(plugin.EncodeKeyEnvStr)
41-
42-
// Inject TokenProvider for OAuth refresh or GitHub App installation tokens.
43-
var tp *token.TokenProvider
44-
if connection.RefreshToken != "" {
45-
tp = token.NewTokenProvider(connection, db, apiClient.GetClient(), logger, encryptionSecret)
46-
} else if connection.AuthMethod == models.AppKey && connection.InstallationID != 0 {
47-
tp = token.NewAppInstallationTokenProvider(connection, db, apiClient.GetClient(), logger, encryptionSecret)
48-
}
49-
if tp != nil {
50-
// Wrap the transport
51-
baseTransport := apiClient.GetClient().Transport
52-
if baseTransport == nil {
53-
baseTransport = http.DefaultTransport
54-
}
55-
56-
rt := token.NewRefreshRoundTripper(baseTransport, tp)
57-
apiClient.GetClient().Transport = rt
58-
logger.Info("Installed token refresh round tripper for connection %d (authMethod=%s)",
59-
connection.ID, connection.AuthMethod)
60-
}
61-
62-
// Persist the freshly minted token so the DB has a correctly encrypted value.
63-
// PrepareApiClient (called by NewApiClientFromConnection) mints the token
64-
// in-memory but does not persist it; without this, the DB may contain a stale
65-
// or corrupted token that breaks GET /connections.
66-
if connection.AuthMethod == models.AppKey && connection.Token != "" {
67-
if err := token.PersistEncryptedTokenColumns(db, connection, encryptionSecret, logger, false); err != nil {
68-
logger.Warn(err, "Failed to persist initial token for connection %d", connection.ID)
69-
} else {
70-
logger.Info("Persisted initial token for connection %d", connection.ID)
71-
}
37+
// inject the shared auth layer
38+
_, err = CreateAuthenticatedHttpClient(taskCtx, connection, apiClient.GetClient())
39+
if err != nil {
40+
return nil, err
7241
}
7342

7443
// create rate limit calculator
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package tasks
19+
20+
import (
21+
"net/http"
22+
23+
"github.com/apache/incubator-devlake/core/errors"
24+
"github.com/apache/incubator-devlake/core/plugin"
25+
"github.com/apache/incubator-devlake/plugins/github/models"
26+
"github.com/apache/incubator-devlake/plugins/github/token"
27+
)
28+
29+
func CreateAuthenticatedHttpClient(
30+
taskCtx plugin.TaskContext,
31+
connection *models.GithubConnection,
32+
baseClient *http.Client,
33+
) (*http.Client, errors.Error) {
34+
35+
logger := taskCtx.GetLogger()
36+
db := taskCtx.GetDal()
37+
encryptionSecret := taskCtx.GetConfig(plugin.EncodeKeyEnvStr)
38+
39+
if baseClient == nil {
40+
baseClient = &http.Client{}
41+
}
42+
43+
// Inject TokenProvider for OAuth refresh or GitHub App installation tokens.
44+
var tp *token.TokenProvider
45+
if connection.RefreshToken != "" {
46+
tp = token.NewTokenProvider(connection, db, baseClient, logger, encryptionSecret)
47+
} else if connection.AuthMethod == models.AppKey && connection.InstallationID != 0 {
48+
tp = token.NewAppInstallationTokenProvider(connection, db, baseClient, logger, encryptionSecret)
49+
}
50+
51+
baseTransport := baseClient.Transport
52+
if baseTransport == nil {
53+
baseTransport = http.DefaultTransport
54+
}
55+
56+
if tp != nil {
57+
baseClient.Transport = token.NewRefreshRoundTripper(baseTransport, tp)
58+
logger.Info(
59+
"Installed token refresh round tripper for connection %d (authMethod=%s)",
60+
connection.ID,
61+
connection.AuthMethod,
62+
)
63+
64+
} else if connection.Token != "" {
65+
baseClient.Transport = token.NewStaticRoundTripper(
66+
baseTransport,
67+
connection.Token,
68+
)
69+
logger.Info(
70+
"Installed static token round tripper for connection %d",
71+
connection.ID,
72+
)
73+
}
74+
75+
// Persist the freshly minted token so the DB has a correctly encrypted value.
76+
// PrepareApiClient (called by NewApiClientFromConnection) mints the token
77+
// in-memory but does not persist it; without this, the DB may contain a stale
78+
// or corrupted token that breaks GET /connections.
79+
if connection.AuthMethod == models.AppKey && connection.Token != "" {
80+
if err := token.PersistEncryptedTokenColumns(db, connection, encryptionSecret, logger, false); err != nil {
81+
logger.Warn(err, "Failed to persist initial token for connection %d", connection.ID)
82+
} else {
83+
logger.Info("Persisted initial token for connection %d", connection.ID)
84+
}
85+
}
86+
87+
return baseClient, nil
88+
}

backend/plugins/github/token/round_tripper.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package token
1919

2020
import (
2121
"net/http"
22+
"strings"
23+
"sync/atomic"
2224
)
2325

2426
// RefreshRoundTripper is an HTTP transport middleware that automatically manages OAuth token refreshes.
@@ -93,3 +95,37 @@ func (rt *RefreshRoundTripper) roundTripWithRetry(req *http.Request, refreshAtte
9395

9496
return resp, nil
9597
}
98+
99+
// StaticRoundTripper is an HTTP transport that injects a fixed bearer token.
100+
// Unlike RefreshRoundTripper, it does NOT attempt refresh or retries.
101+
type StaticRoundTripper struct {
102+
base http.RoundTripper
103+
tokens []string
104+
idx atomic.Uint64
105+
}
106+
107+
func NewStaticRoundTripper(base http.RoundTripper, rawToken string) *StaticRoundTripper {
108+
if base == nil {
109+
base = http.DefaultTransport
110+
}
111+
parts := strings.Split(rawToken, ",")
112+
tokens := make([]string, 0, len(parts))
113+
for _, t := range parts {
114+
if t = strings.TrimSpace(t); t != "" {
115+
tokens = append(tokens, t)
116+
}
117+
}
118+
if len(tokens) == 0 {
119+
tokens = []string{rawToken}
120+
}
121+
return &StaticRoundTripper{base: base, tokens: tokens}
122+
}
123+
124+
func (rt *StaticRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
125+
// always overrides headers put by SetupAuthentication, to make sure the token is always injected
126+
// Add(1)-1 yields a 0-based sequence (0, 1, 2, ...) so rotation starts at tokens[0].
127+
tok := rt.tokens[(rt.idx.Add(1)-1)%uint64(len(rt.tokens))]
128+
reqClone := req.Clone(req.Context())
129+
reqClone.Header.Set("Authorization", "Bearer "+tok)
130+
return rt.base.RoundTrip(reqClone)
131+
}

backend/plugins/github_graphql/impl/impl.go

Lines changed: 5 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,7 @@ package impl
2020
import (
2121
"context"
2222
"fmt"
23-
"net/http"
24-
"net/url"
2523
"reflect"
26-
"strings"
2724
"time"
2825

2926
"github.com/apache/incubator-devlake/core/models/domainlayer/devops"
@@ -39,7 +36,6 @@ import (
3936
"github.com/apache/incubator-devlake/plugins/github_graphql/model/migrationscripts"
4037
"github.com/apache/incubator-devlake/plugins/github_graphql/tasks"
4138
"github.com/merico-ai/graphql"
42-
"golang.org/x/oauth2"
4339
)
4440

4541
// make sure interface is implemented
@@ -180,46 +176,10 @@ func (p GithubGraphql) PrepareTaskData(taskCtx plugin.TaskContext, options map[s
180176
return nil, err
181177
}
182178

183-
tokens := strings.Split(connection.Token, ",")
184-
src := oauth2.StaticTokenSource(
185-
&oauth2.Token{AccessToken: tokens[0]},
186-
)
187-
oauthContext := taskCtx.GetContext()
188-
proxy := connection.GetProxy()
189-
if proxy != "" {
190-
pu, err := url.Parse(proxy)
191-
if err != nil {
192-
return nil, errors.Convert(err)
193-
}
194-
if pu.Scheme == "http" || pu.Scheme == "socks5" {
195-
proxyClient := &http.Client{
196-
Transport: &http.Transport{Proxy: http.ProxyURL(pu)},
197-
}
198-
oauthContext = context.WithValue(
199-
taskCtx.GetContext(),
200-
oauth2.HTTPClient,
201-
proxyClient,
202-
)
203-
logger.Debug("Proxy set in oauthContext to %s", proxy)
204-
} else {
205-
return nil, errors.BadInput.New("Unsupported scheme set in proxy")
206-
}
207-
}
208-
209-
httpClient := oauth2.NewClient(oauthContext, src)
210-
endpoint, err := errors.Convert01(url.Parse(connection.Endpoint))
211-
if err != nil {
212-
return nil, errors.BadInput.Wrap(err, fmt.Sprintf("malformed connection endpoint supplied: %s", connection.Endpoint))
213-
}
214-
215-
// github.com and github enterprise have different graphql endpoints
216-
endpoint.Path = "/graphql" // see https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
217-
if endpoint.Hostname() != "api.github.com" {
218-
// see https://docs.github.com/en/enterprise-server@3.11/graphql/guides/forming-calls-with-graphql
219-
endpoint.Path = "/api/graphql"
220-
}
221-
client := graphql.NewClient(endpoint.String(), httpClient)
222-
graphqlClient, err := helper.CreateAsyncGraphqlClient(taskCtx, client, taskCtx.GetLogger(),
179+
graphqlClient, err := tasks.CreateGraphqlClient(
180+
taskCtx,
181+
connection,
182+
apiClient.ApiClient.GetClient(),
223183
func(ctx context.Context, client *graphql.Client, logger log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error) {
224184
var query GraphQueryRateLimit
225185
dataErrors, err := errors.Convert01(client.Query(taskCtx.GetContext(), &query, nil))
@@ -230,8 +190,7 @@ func (p GithubGraphql) PrepareTaskData(taskCtx plugin.TaskContext, options map[s
230190
return 0, nil, errors.Default.Wrap(dataErrors[0], `query rate limit fail`)
231191
}
232192
if query.RateLimit == nil {
233-
logger.Info(`github graphql rate limit are disabled, fallback to 5000req/hour`)
234-
return 5000, nil, nil
193+
return 0, nil, errors.Default.New("rate limit unavailable")
235194
}
236195
logger.Info(`github graphql init success with remaining %d/%d and will reset at %s`,
237196
query.RateLimit.Remaining, query.RateLimit.Limit, query.RateLimit.ResetAt)

0 commit comments

Comments
 (0)