forked from hub4j/github-api
-
Notifications
You must be signed in to change notification settings - Fork 0
/
GHRateLimit.java
691 lines (627 loc) · 24.7 KB
/
GHRateLimit.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
package org.kohsuke.github;
import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.apache.commons.lang3.StringUtils;
import org.kohsuke.github.connector.GitHubConnectorResponse;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Date;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import static java.util.logging.Level.FINEST;
// TODO: Auto-generated Javadoc
/**
* Rate limit.
*
* @author Liam Newman
*/
@SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD", justification = "JSON API")
public class GHRateLimit {
/**
* Remaining calls that can be made.
*
* @deprecated This field should never have been made public. Use {@link #getRemaining()}
*/
@Deprecated
public int remaining;
/**
* Allotted API call per hour.
*
* @deprecated This field should never have been made public. Use {@link #getLimit()}
*/
@Deprecated
public int limit;
/**
* The time at which the current rate limit window resets in UTC epoch seconds. WARNING: this field was implemented
* using {@link Date#Date(long)} which expects UTC epoch milliseconds, so this Date instance is meaningless as a
* date. To use this field in any meaningful way, it must be converted to a long using {@link Date#getTime()}
* multiplied by 1000.
*
* @deprecated This field should never have been made public. Use {@link #getResetDate()}
*/
@Deprecated
public Date reset;
@Nonnull
private final Record core;
@Nonnull
private final Record search;
@Nonnull
private final Record graphql;
@Nonnull
private final Record integrationManifest;
/**
* The default GHRateLimit provided to new {@link GitHubClient}s.
*
* Contains all expired records that will cause {@link GitHubClient#rateLimit(RateLimitTarget)} to refresh with new
* data when called.
*
* Private, but made internal for testing.
*/
@Nonnull
static final GHRateLimit DEFAULT = new GHRateLimit(UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT);
/**
* Creates a new {@link GHRateLimit} from a single record for the specified endpoint with place holders for other
* records.
*
* This is used to create {@link GHRateLimit} instances that can merged with other instances.
*
* @param record
* the rate limit record. Can be a regular {@link Record} constructed from header information or an
* {@link UnknownLimitRecord} placeholder.
* @param rateLimitTarget
* which rate limit record to fill
* @return a new {@link GHRateLimit} instance containing the supplied record
*/
@Nonnull
static GHRateLimit fromRecord(@Nonnull Record record, @Nonnull RateLimitTarget rateLimitTarget) {
if (rateLimitTarget == RateLimitTarget.CORE || rateLimitTarget == RateLimitTarget.NONE) {
return new GHRateLimit(record,
UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT);
} else if (rateLimitTarget == RateLimitTarget.SEARCH) {
return new GHRateLimit(UnknownLimitRecord.DEFAULT,
record,
UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT);
} else if (rateLimitTarget == RateLimitTarget.GRAPHQL) {
return new GHRateLimit(UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT,
record,
UnknownLimitRecord.DEFAULT);
} else if (rateLimitTarget == RateLimitTarget.INTEGRATION_MANIFEST) {
return new GHRateLimit(UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT,
UnknownLimitRecord.DEFAULT,
record);
} else {
throw new IllegalArgumentException("Unknown rate limit target: " + rateLimitTarget.toString());
}
}
/**
* Instantiates a new GH rate limit.
*
* @param core
* the core
* @param search
* the search
* @param graphql
* the graphql
* @param integrationManifest
* the integration manifest
*/
@JsonCreator
GHRateLimit(@Nonnull @JsonProperty("core") Record core,
@Nonnull @JsonProperty("search") Record search,
@Nonnull @JsonProperty("graphql") Record graphql,
@Nonnull @JsonProperty("integration_manifest") Record integrationManifest) {
// The Nonnull annotation is ignored by Jackson, we have to check manually
Objects.requireNonNull(core);
Objects.requireNonNull(search);
Objects.requireNonNull(graphql);
Objects.requireNonNull(integrationManifest);
this.core = core;
this.search = search;
this.graphql = graphql;
this.integrationManifest = integrationManifest;
// Deprecated fields
this.remaining = core.getRemaining();
this.limit = core.getLimit();
// This is wrong but is how this was implemented. Kept for backward compat.
this.reset = new Date(core.getResetEpochSeconds());
}
/**
* Returns the date at which the Core API rate limit will reset.
*
* @return the calculated date at which the rate limit has or will reset.
*/
@Nonnull
public Date getResetDate() {
return getCore().getResetDate();
}
/**
* Gets the remaining number of Core APIs requests allowed before this connection will be throttled.
*
* @return an integer
* @since 1.100
*/
public int getRemaining() {
return getCore().getRemaining();
}
/**
* Gets the total number of Core API calls per hour allotted for this connection.
*
* @return an integer
* @since 1.100
*/
public int getLimit() {
return getCore().getLimit();
}
/**
* Gets the time in epoch seconds when the Core API rate limit will reset.
*
* @return a long
* @since 1.100
*/
public long getResetEpochSeconds() {
return getCore().getResetEpochSeconds();
}
/**
* Whether the reset date for the Core API rate limit has passed.
*
* @return true if the rate limit reset date has passed. Otherwise false.
* @since 1.100
*/
public boolean isExpired() {
return getCore().isExpired();
}
/**
* The core object provides the rate limit status for all non-search-related resources in the REST API.
*
* @return a rate limit record
* @since 1.100
*/
@Nonnull
public Record getCore() {
return core;
}
/**
* The search record provides the rate limit status for the Search API.
*
* @return a rate limit record
* @since 1.115
*/
@Nonnull
public Record getSearch() {
return search;
}
/**
* The graphql record provides the rate limit status for the GraphQL API.
*
* @return a rate limit record
* @since 1.115
*/
@Nonnull
public Record getGraphQL() {
return graphql;
}
/**
* The integration manifest record provides the rate limit status for the GitHub App Manifest code conversion
* endpoint.
*
* @return a rate limit record
* @since 1.115
*/
@Nonnull
public Record getIntegrationManifest() {
return integrationManifest;
}
/**
* To string.
*
* @return the string
*/
@Override
public String toString() {
return "GHRateLimit {" + "core " + getCore().toString() + ", search " + getSearch().toString() + ", graphql "
+ getGraphQL().toString() + ", integrationManifest " + getIntegrationManifest().toString() + "}";
}
/**
* Equals.
*
* @param o
* the o
* @return true, if successful
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
GHRateLimit rateLimit = (GHRateLimit) o;
return getCore().equals(rateLimit.getCore()) && getSearch().equals(rateLimit.getSearch())
&& getGraphQL().equals(rateLimit.getGraphQL())
&& getIntegrationManifest().equals(rateLimit.getIntegrationManifest());
}
/**
* Hash code.
*
* @return the int
*/
@Override
public int hashCode() {
return Objects.hash(getCore(), getSearch(), getGraphQL(), getIntegrationManifest());
}
/**
* Merge a {@link GHRateLimit} with another one to create a new {@link GHRateLimit} keeping the latest
* {@link Record}s from each.
*
* @param newLimit
* {@link GHRateLimit} with potentially updated {@link Record}s.
* @return a merged {@link GHRateLimit} with the latest {@link Record}s from these two instances. If the merged
* instance is equal to the current instance, the current instance is returned.
*/
@Nonnull
GHRateLimit getMergedRateLimit(@Nonnull GHRateLimit newLimit) {
GHRateLimit merged = new GHRateLimit(getCore().currentOrUpdated(newLimit.getCore()),
getSearch().currentOrUpdated(newLimit.getSearch()),
getGraphQL().currentOrUpdated(newLimit.getGraphQL()),
getIntegrationManifest().currentOrUpdated(newLimit.getIntegrationManifest()));
if (merged.equals(this)) {
merged = this;
}
return merged;
}
/**
* Gets the specified {@link Record}.
*
* {@link RateLimitTarget#NONE} will return {@link UnknownLimitRecord#DEFAULT} to prevent any clients from
* accidentally waiting on that record to reset before continuing.
*
* @param rateLimitTarget
* the target rate limit record
* @return the target {@link Record} from this instance.
*/
@Nonnull
Record getRecord(@Nonnull RateLimitTarget rateLimitTarget) {
if (rateLimitTarget == RateLimitTarget.CORE) {
return getCore();
} else if (rateLimitTarget == RateLimitTarget.SEARCH) {
return getSearch();
} else if (rateLimitTarget == RateLimitTarget.GRAPHQL) {
return getGraphQL();
} else if (rateLimitTarget == RateLimitTarget.INTEGRATION_MANIFEST) {
return getIntegrationManifest();
} else if (rateLimitTarget == RateLimitTarget.NONE) {
return UnknownLimitRecord.DEFAULT;
} else {
throw new IllegalArgumentException("Unknown rate limit target: " + rateLimitTarget.toString());
}
}
/**
* A limit record used as a placeholder when the actual limit is not known.
*
* @since 1.100
*/
public static class UnknownLimitRecord extends Record {
private static final long defaultUnknownLimitResetSeconds = Duration.ofSeconds(30).getSeconds();
/**
* The number of seconds until a {@link UnknownLimitRecord} will expire.
*
* This is set to a somewhat short duration, rather than a long one. This avoids
* {@link {@link GitHubClient#rateLimit(RateLimitTarget)}} requesting rate limit updates continuously, but also
* avoids holding on to stale unknown records indefinitely.
*
* When merging {@link GHRateLimit} instances, {@link UnknownLimitRecord}s will be superseded by incoming
* regular {@link Record}s.
*
* @see GHRateLimit#getMergedRateLimit(GHRateLimit)
*/
static long unknownLimitResetSeconds = defaultUnknownLimitResetSeconds;
/** The Constant unknownLimit. */
static final int unknownLimit = 1000000;
/** The Constant unknownRemaining. */
static final int unknownRemaining = 999999;
// The default UnknownLimitRecord is an expired record.
private static final UnknownLimitRecord DEFAULT = new UnknownLimitRecord(Long.MIN_VALUE);
// The starting current UnknownLimitRecord is an expired record.
private static final AtomicReference<UnknownLimitRecord> current = new AtomicReference<>(DEFAULT);
/**
* Create a new unknown record that resets at the specified time.
*
* @param resetEpochSeconds
* the epoch second time when this record will expire.
*/
private UnknownLimitRecord(long resetEpochSeconds) {
super(unknownLimit, unknownRemaining, resetEpochSeconds);
}
/**
* Current.
*
* @return the record
*/
static Record current() {
Record result = current.get();
if (result.isExpired()) {
current.set(new UnknownLimitRecord(System.currentTimeMillis() / 1000L + unknownLimitResetSeconds));
result = current.get();
}
return result;
}
/**
* Reset the current UnknownLimitRecord. For use during testing only.
*/
static void reset() {
current.set(DEFAULT);
unknownLimitResetSeconds = defaultUnknownLimitResetSeconds;
}
}
/**
* A rate limit record.
*
* @author Liam Newman
* @since 1.100
*/
public static class Record {
/**
* Remaining calls that can be made.
*/
private final int remaining;
/**
* Allotted API call per time period.
*/
private final int limit;
/**
* The time at which the current rate limit window resets in UTC epoch seconds.
*/
private final long resetEpochSeconds;
/**
* EpochSeconds time (UTC) at which this instance was created.
*/
private final long createdAtEpochSeconds = System.currentTimeMillis() / 1000;
/**
* The date at which the rate limit will reset, adjusted to local machine time if the local machine's clock not
* synchronized with to the same clock as the GitHub server.
*
* @see #calculateResetDate(String)
* @see #getResetDate()
*/
@Nonnull
private final Date resetDate;
/**
* Instantiates a new Record.
*
* @param limit
* the limit
* @param remaining
* the remaining
* @param resetEpochSeconds
* the reset epoch seconds
*/
public Record(@JsonProperty(value = "limit", required = true) int limit,
@JsonProperty(value = "remaining", required = true) int remaining,
@JsonProperty(value = "reset", required = true) long resetEpochSeconds) {
this(limit, remaining, resetEpochSeconds, null);
}
/**
* Instantiates a new Record. Called by Jackson data binding or during header parsing.
*
* @param limit
* the limit
* @param remaining
* the remaining
* @param resetEpochSeconds
* the reset epoch seconds
* @param connectorResponse
* the response info
*/
@JsonCreator
Record(@JsonProperty(value = "limit", required = true) int limit,
@JsonProperty(value = "remaining", required = true) int remaining,
@JsonProperty(value = "reset", required = true) long resetEpochSeconds,
@JacksonInject @CheckForNull GitHubConnectorResponse connectorResponse) {
this.limit = limit;
this.remaining = remaining;
this.resetEpochSeconds = resetEpochSeconds;
String updatedAt = null;
if (connectorResponse != null) {
updatedAt = connectorResponse.header("Date");
}
this.resetDate = calculateResetDate(updatedAt);
}
/**
* Determine if the current {@link Record} is outdated compared to another. Rate Limit dates are only accurate
* to the second, so we look at other information in the record as well.
*
* {@link Record}s with earlier {@link #getResetEpochSeconds()} are replaced by those with later.
* {@link Record}s with the same {@link #getResetEpochSeconds()} are replaced by those with less remaining
* count.
*
* {@link UnknownLimitRecord}s compare with each other like regular {@link Record}s.
*
* {@link Record}s are replaced by {@link UnknownLimitRecord}s only when the current {@link Record} is expired
* and the {@link UnknownLimitRecord} is not. Otherwise Regular {@link Record}s are not replaced by
* {@link UnknownLimitRecord}s.
*
* Expiration is only considered after other checks, meaning expired records may sometimes be replaced by other
* expired records.
*
* @param other
* the other {@link Record}
* @return the {@link Record} that is most current
*/
Record currentOrUpdated(@Nonnull Record other) {
// This set of checks avoids most calls to isExpired()
// Depends on UnknownLimitRecord.current() to prevent continuous updating of GHRateLimit rateLimit()
if (getResetEpochSeconds() > other.getResetEpochSeconds()
|| (getResetEpochSeconds() == other.getResetEpochSeconds()
&& getRemaining() <= other.getRemaining())) {
// If the current record has a later reset
// or the current record has the same reset and fewer or same requests remaining
// Then it is most recent
return this;
} else if (!(other instanceof UnknownLimitRecord)) {
// If the above is not the case that means other has a later reset
// or the same resent and fewer requests remaining.
// If the other record is not an unknown record, the other is more recent
return other;
} else if (this.isExpired() && !other.isExpired()) {
// The other is an unknown record.
// If the current record has expired and the other hasn't, return the other.
return other;
}
// If none of the above, the current record is most valid.
return this;
}
/**
* Recalculates the {@link #resetDate} relative to the local machine clock.
* <p>
* {@link RateLimitChecker}s and {@link RateLimitHandler}s use {@link #getResetDate()} to make decisions about
* how long to wait for until for the rate limit to reset. That means that {@link #getResetDate()} needs to be
* calculated based on the local machine clock.
* </p>
* <p>
* When we say that the clock on two machines is "synchronized", we mean that the UTC time returned from
* {@link System#currentTimeMillis()} on each machine is basically the same. For the purposes of rate limits an
* differences of up to a second can be ignored.
* </p>
* <p>
* When the clock on the local machine is synchronized to the same time as the clock on the GitHub server (via a
* time service for example), the {@link #resetDate} generated directly from {@link #resetEpochSeconds} will be
* accurate for the local machine as well.
* </p>
* <p>
* When the clock on the local machine is not synchronized with the server, the {@link #resetDate} must be
* recalculated relative to the local machine clock. This is done by taking the number of seconds between the
* response "Date" header and {@link #resetEpochSeconds} and then adding that to this record's
* {@link #createdAtEpochSeconds}.
*
* @param updatedAt
* a string date in RFC 1123
* @return reset date based on the passed date
*/
@Nonnull
private Date calculateResetDate(@CheckForNull String updatedAt) {
long updatedAtEpochSeconds = createdAtEpochSeconds;
if (!StringUtils.isBlank(updatedAt)) {
try {
// Get the server date and reset data, will always return a time in GMT
updatedAtEpochSeconds = ZonedDateTime.parse(updatedAt, DateTimeFormatter.RFC_1123_DATE_TIME)
.toEpochSecond();
} catch (DateTimeParseException e) {
if (LOGGER.isLoggable(FINEST)) {
LOGGER.log(FINEST, "Malformed Date header value " + updatedAt, e);
}
}
}
// This may seem odd but it results in an accurate or slightly pessimistic reset date
// based on system time rather than assuming the system time synchronized with the server
long calculatedSecondsUntilReset = resetEpochSeconds - updatedAtEpochSeconds;
return new Date((createdAtEpochSeconds + calculatedSecondsUntilReset) * 1000);
}
/**
* Gets the remaining number of requests allowed before this connection will be throttled.
*
* @return an integer
*/
public int getRemaining() {
return remaining;
}
/**
* Gets the total number of API calls per hour allotted for this connection.
*
* @return an integer
*/
public int getLimit() {
return limit;
}
/**
* Gets the time in epoch seconds when the rate limit will reset.
*
* This is the raw value returned by the server. This value is not adjusted if local machine time is not
* synchronized with server time. If attempting to check when the rate limit will reset, use
* {@link #getResetDate()} or implement a {@link RateLimitChecker} instead.
*
* @return a long representing the time in epoch seconds when the rate limit will reset
* @see #getResetDate()
*/
public long getResetEpochSeconds() {
return resetEpochSeconds;
}
/**
* Whether the rate limit reset date indicated by this instance is expired
*
* If attempting to wait for the rate limit to reset, consider implementing a {@link RateLimitChecker} instead.
*
* @return true if the rate limit reset date has passed. Otherwise false.
*/
public boolean isExpired() {
return getResetDate().getTime() < System.currentTimeMillis();
}
/**
* The date at which the rate limit will reset, adjusted to local machine time if the local machine's clock not
* synchronized with to the same clock as the GitHub server.
*
* If attempting to wait for the rate limit to reset, consider implementing a {@link RateLimitChecker} instead.
*
* @return the calculated date at which the rate limit has or will reset.
*/
@Nonnull
public Date getResetDate() {
return new Date(resetDate.getTime());
}
/**
* To string.
*
* @return the string
*/
@Override
public String toString() {
return "{" + "remaining=" + getRemaining() + ", limit=" + getLimit() + ", resetDate=" + getResetDate()
+ '}';
}
/**
* Equals.
*
* @param o
* the o
* @return true, if successful
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Record record = (Record) o;
return getRemaining() == record.getRemaining() && getLimit() == record.getLimit()
&& getResetEpochSeconds() == record.getResetEpochSeconds()
&& getResetDate().equals(record.getResetDate());
}
/**
* Hash code.
*
* @return the int
*/
@Override
public int hashCode() {
return Objects.hash(getRemaining(), getLimit(), getResetEpochSeconds(), getResetDate());
}
}
private static final Logger LOGGER = Logger.getLogger(Requester.class.getName());
}